Pin SSH host key on first use instead of disabling the check#254
Pin SSH host key on first use instead of disabling the check#254jackson-asmith wants to merge 2 commits into
Conversation
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>
There was a problem hiding this comment.
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.
| 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(" ", "") |
There was a problem hiding this comment.
Robustness and Defensive Programming Improvement
The current implementation of ssh_opts_disable_host_key_checking has a few potential robustness issues:
- Potential
NoneTypeCrash: Ifssh_optsisNone(which can happen ifTC_SSH_OPTSis missing or explicitly set toNonein some configurations), callingssh_opts.lower()will raise anAttributeError. - 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. - 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.
| 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 |
| 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")) |
There was a problem hiding this comment.
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>
|
Addressed in aa44ad0. |
|
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". |
What
Pins the device SSH host key on first use instead of disabling host-key checking entirely.
Why
configurewritesTC_SSH_OPTSwithStrictHostKeyChecking=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
TC_SSH_OPTSnow usesStrictHostKeyChecking=accept-newwith a dedicatedUserKnownHostsFile=~/.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.$HOME, so it survives repo moves and doesn't pollute the user's main~/.ssh/known_hostswith the legacy AirPort key).doctoremits aWARNwhen a saved.envstill disables host-key checking, pointing the user to rerunconfigure..env.exampleupdated to match.Existing
.envfiles are not modified; the warning nudges those users to re-runconfigure.Tests
tests/test_config.py: the default pins the key and drops=no//dev/null;ssh_opts_disable_host_key_checkingdetection.tests/test_checks.py:doctorconfig validation warns on a host-key-disablingTC_SSH_OPTS.Full suite green (
python -m pytest, 1637 passed).Notes / limitations
accept-newwriting 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.🤖 Generated with Claude Code