Skip to content

Pin SSH host key on first use instead of disabling the check#254

Closed
jackson-asmith wants to merge 2 commits into
jamesyc:mainfrom
jackson-asmith:harden/ssh-hostkey-pinning
Closed

Pin SSH host key on first use instead of disabling the check#254
jackson-asmith wants to merge 2 commits into
jamesyc:mainfrom
jackson-asmith:harden/ssh-hostkey-pinning

Conversation

@jackson-asmith

Copy link
Copy Markdown

What

Pins the device SSH host key on first use instead of disabling host-key checking entirely.

Why

configure writes TC_SSH_OPTS with StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null. That means SSH never records or verifies the device's host key, so a LAN attacker can transparently MITM the setup and deploy sessions — which carry the device password and upload binaries that run as root on the device. The legacy AirPort key-exchange/host-key algorithms are a genuine firmware constraint and are unchanged; only the "never check the key" part is fixed.

Changes

  • Default TC_SSH_OPTS now uses StrictHostKeyChecking=accept-new with a dedicated UserKnownHostsFile=~/.tcapsule_known_hosts. This still works unattended for the first deploy (trust-on-first-use), but a changed key on a later connection fails loudly instead of being silently accepted.
  • A dedicated known_hosts file (in $HOME, so it survives repo moves and doesn't pollute the user's main ~/.ssh/known_hosts with the legacy AirPort key).
  • doctor emits a WARN when a saved .env still disables host-key checking, pointing the user to rerun configure.
  • .env.example updated to match.

Existing .env files are not modified; the warning nudges those users to re-run configure.

Tests

  • tests/test_config.py: the default pins the key and drops =no//dev/null; ssh_opts_disable_host_key_checking detection.
  • tests/test_checks.py: doctor config validation warns on a host-key-disabling TC_SSH_OPTS.

Full suite green (python -m pytest, 1637 passed).

Notes / limitations

  • The on-device first-connect (accept-new writing the key) and the changed-key rejection were not exercised against real Time Capsule hardware here; the change is a standard OpenSSH option and the legacy algorithm set is untouched.
  • I did not include the plan's optional "drop legacy algorithms when the device negotiates modern ones" step — that genuinely needs per-device hardware testing and is better as a separate change.

🤖 Generated with Claude Code

configure previously wrote StrictHostKeyChecking=no with
UserKnownHostsFile=/dev/null, so a LAN attacker could MITM the setup and
deploy traffic (which carries the device password and uploads root-run
binaries) undetected.

New configures use StrictHostKeyChecking=accept-new with a dedicated
~/.tcapsule_known_hosts, pinning the device key on first connect and
failing on a later key change. doctor warns when a saved .env still
disables host-key checking. The legacy AirPort algorithms are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the default SSH options (TC_SSH_OPTS) to pin the device host key on first use (StrictHostKeyChecking=accept-new and UserKnownHostsFile=~/.tcapsule_known_hosts) instead of disabling host-key checking. It also introduces a warning in the doctor command for configurations that still disable host-key checking, along with corresponding unit tests. The review feedback suggests improving the robustness of the ssh_opts_disable_host_key_checking helper to handle potential None values, arbitrary whitespace, and quotes, and expanding the test suite to cover these edge cases.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/timecapsulesmb/core/config.py Outdated
Comment on lines +651 to +653
def ssh_opts_disable_host_key_checking(ssh_opts: str) -> bool:
lowered = ssh_opts.lower()
return "stricthostkeychecking=no" in lowered.replace(" ", "") or "userknownhostsfile=/dev/null" in lowered.replace(" ", "")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Robustness and Defensive Programming Improvement

The current implementation of ssh_opts_disable_host_key_checking has a few potential robustness issues:

  1. Potential NoneType Crash: If ssh_opts is None (which can happen if TC_SSH_OPTS is missing or explicitly set to None in some configurations), calling ssh_opts.lower() will raise an AttributeError.
  2. Whitespace Handling: Using .replace(" ", "") only handles spaces. If tabs (\t) are used as separators (e.g., StrictHostKeyChecking\t=\tno), the check will fail to detect it.
  3. Quote Handling: If the option values are quoted (e.g., UserKnownHostsFile="/dev/null"), the check will fail to match because of the quotes.

To enforce defensive programming and ensure the security warning cannot be bypassed, we should handle None, strip all whitespace, and remove quotes.

Suggested change
def ssh_opts_disable_host_key_checking(ssh_opts: str) -> bool:
lowered = ssh_opts.lower()
return "stricthostkeychecking=no" in lowered.replace(" ", "") or "userknownhostsfile=/dev/null" in lowered.replace(" ", "")
def ssh_opts_disable_host_key_checking(ssh_opts: str | None) -> bool:
if not ssh_opts:
return False
lowered = ssh_opts.lower()
normalized = "".join(lowered.split()).replace('"', '').replace("'", "")
return "stricthostkeychecking=no" in normalized or "userknownhostsfile=/dev/null" in normalized

Comment thread tests/test_config.py
Comment on lines +162 to +166
def test_ssh_opts_disable_host_key_checking_detection(self) -> None:
self.assertTrue(ssh_opts_disable_host_key_checking("-o StrictHostKeyChecking=no"))
self.assertTrue(ssh_opts_disable_host_key_checking("-o UserKnownHostsFile=/dev/null"))
self.assertFalse(ssh_opts_disable_host_key_checking(DEFAULTS["TC_SSH_OPTS"]))
self.assertFalse(ssh_opts_disable_host_key_checking("-o ProxyJump=bastion"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Test Coverage Improvement

Expand the test cases for ssh_opts_disable_host_key_checking to verify the robust handling of None, empty strings, tabs, and quotes.

    def test_ssh_opts_disable_host_key_checking_detection(self) -> None:
        self.assertTrue(ssh_opts_disable_host_key_checking("-o StrictHostKeyChecking=no"))
        self.assertTrue(ssh_opts_disable_host_key_checking("-o UserKnownHostsFile=/dev/null"))
        self.assertTrue(ssh_opts_disable_host_key_checking("-o StrictHostKeyChecking\\t=\\tno"))
        self.assertTrue(ssh_opts_disable_host_key_checking("-o UserKnownHostsFile=\"/dev/null\""))
        self.assertFalse(ssh_opts_disable_host_key_checking(None))
        self.assertFalse(ssh_opts_disable_host_key_checking(""))
        self.assertFalse(ssh_opts_disable_host_key_checking(DEFAULTS["TC_SSH_OPTS"]))
        self.assertFalse(ssh_opts_disable_host_key_checking("-o ProxyJump=bastion"))

Addresses review feedback on jamesyc#254. ssh_opts_disable_host_key_checking now
returns False for None/empty, strips all whitespace (not just spaces), and
removes quotes, so tabbed or quoted forms like StrictHostKeyChecking\t=\tno
or UserKnownHostsFile="/dev/null" cannot bypass the warning. Expands the
test to cover None, empty, tabs, and quotes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jackson-asmith

Copy link
Copy Markdown
Author

Addressed in aa44ad0. ssh_opts_disable_host_key_checking now returns False for None/empty, strips all whitespace via "".join(...split()) (so tabs are handled, not just spaces), and removes quotes, so StrictHostKeyChecking\t=\tno and UserKnownHostsFile="/dev/null" are both detected. Expanded the test to cover None, empty string, tabs, and quoted values.

@jamesyc

jamesyc commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Strong no.

SSH adds various key related messages to STDIN and STDERR sometimes. We directly parse SSH (especially NetBSD4 devices which lack SCP). These bugs are rare edge cases and were not easy to catch either.

This has a lot of edge cases IRL, where the app breaks due to SSH adding extra lines.

It's a Time Capsule. If someone is MITMing your Time Capsule, you have larger problems. The README of the project was very clear about security limitations, and if you're being pursued by a state level entity who is MITMing you, a better idea is "do not use a modded Time Capsule".

@jamesyc jamesyc closed this Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants