Problem / Background
PR #241 (issue #239) replaced the no-op accept-new host key mode with real TOFU. Four fail-open or unsupported paths were identified during that work and deliberately left out of scope to keep an already-large security PR reviewable. They are collected here. Each one can result in a host key being accepted without meaningful verification, so they matter more now that users will reasonably assume accept-new provides real protection.
Technical Analysis
1. accept-new with no determinable home directory disables verification entirely
src/ssh/known_hosts.rs:65-77: when get_default_known_hosts_path() returns None, get_check_method(AcceptNew) returns ServerCheckMethod::NoCheck after printing a warning. NoCheck accepts any server key unconditionally. Because accept-new is the CLI default (src/cli/bssh.rs, where --strict-host-key-checking defaults to accept-new), this is a silent accept-all in the default mode. It is reachable in containers and service environments where HOME is unset and no passwd entry supplies a home directory. Strict yes mode was deliberately fixed to fail closed in this same situation and is not affected.
2. An unreadable known_hosts file is indistinguishable from an absent one
russh::keys::known_hosts::known_host_keys_path returns Ok(vec![]) whenever File::open fails, for any reason, so a file that exists but cannot be read (permission denied, a dangling symlink, a directory in its place) is reported as an empty known_hosts rather than an error. lookup_known_host in src/ssh/tokio_client/host_verification.rs is built on that function, so in accept-new mode such a file makes every host look like a genuine first use: the key is accepted, and the subsequent learn_known_hosts_path append will usually also fail, which is only a warning because recording is intentionally best effort. The net effect is a connection accepted unverified on every run, with no pinning ever established and no error exit code. A host key that changes between runs is never detected.
3. No cross-process locking, and a successfully injected entry becomes permanently trusted
KNOWN_HOSTS_LOCK in src/ssh/tokio_client/host_verification.rs is a process-wide tokio::sync::Mutex. It correctly prevents duplicate appends within one bssh invocation, but two concurrent bssh processes can both observe a host as unknown and both append. If one of them is talking to a man-in-the-middle, the file ends up holding two entries of the same algorithm for that host. This interacts badly with the acceptance rule: lookup_known_host deliberately implements OpenSSH's "any recorded key matches" semantics (itself a security fix, since the alternative rejects legitimate layouts such as a stale key kept beside a rotated one), so from then on the attacker's key is accepted silently and permanently, and the changed-key warning never fires again.
4. @cert-authority marker lines are not honored
src/ssh/tokio_client/host_verification.rs detects @cert-authority lines and prints a warning that the marker is unsupported, then falls through to ordinary TOFU. This was a deliberate choice: bssh has no CA signature validation to fall back on, and failing closed would break working CA-based setups with no workaround available to the user. It is recorded here so the limitation is tracked rather than rediscovered. Before PR #241 these lines were ignored completely and silently, so the warning is an improvement, not a regression.
Impact
Items 1 and 2 mean the documented and recommended default mode can silently provide zero MITM protection in specific but realistic environments. Item 3 means a single successful race, in a threat model where the attacker can already intercept one connection, converts into permanent trust. Item 4 means users relying on an SSH CA get TOFU semantics instead of certificate validation.
Proposed Solution
- Process-lifetime in-memory pin. Maintain a
(host, port) -> PublicKey map for the lifetime of the process, populated on every successful verification and consulted alongside the file. This closes items 1 and 2 for the common fan-out case: even with no usable known_hosts file, all connections within a single run must agree on a host's key, so a mid-run substitution is caught. It also strengthens item 3, since a conflicting entry appended by another process is detected against the in-memory pin.
- Distinguish absent from unreadable. Before the lookup, check whether the path exists (for example
Path::try_exists() plus a metadata or open probe). A path that is absent is a legitimate first-use situation. A path that exists but cannot be read, or is not a regular file, must fail closed with a distinct error rather than being treated as empty, in both accept-new and strict mode.
- Advisory cross-process file lock. Wrap the check-then-record critical section in an advisory lock on the known_hosts file (
flock on Unix, LockFileEx on Windows, or a dedicated lock file) held for the duration the process-wide mutex is already held. Keep the existing in-process mutex; the two compose.
- Decide
@cert-authority explicitly. Either implement certificate validation against the CA key, or make the behavior configurable so security-sensitive deployments can opt into failing closed. Do not change the default silently.
Acceptance Criteria
Problem / Background
PR #241 (issue #239) replaced the no-op
accept-newhost key mode with real TOFU. Four fail-open or unsupported paths were identified during that work and deliberately left out of scope to keep an already-large security PR reviewable. They are collected here. Each one can result in a host key being accepted without meaningful verification, so they matter more now that users will reasonably assumeaccept-newprovides real protection.Technical Analysis
1.
accept-newwith no determinable home directory disables verification entirelysrc/ssh/known_hosts.rs:65-77: whenget_default_known_hosts_path()returnsNone,get_check_method(AcceptNew)returnsServerCheckMethod::NoCheckafter printing a warning.NoCheckaccepts any server key unconditionally. Becauseaccept-newis the CLI default (src/cli/bssh.rs, where--strict-host-key-checkingdefaults toaccept-new), this is a silent accept-all in the default mode. It is reachable in containers and service environments whereHOMEis unset and no passwd entry supplies a home directory. Strictyesmode was deliberately fixed to fail closed in this same situation and is not affected.2. An unreadable known_hosts file is indistinguishable from an absent one
russh::keys::known_hosts::known_host_keys_pathreturnsOk(vec![])wheneverFile::openfails, for any reason, so a file that exists but cannot be read (permission denied, a dangling symlink, a directory in its place) is reported as an empty known_hosts rather than an error.lookup_known_hostinsrc/ssh/tokio_client/host_verification.rsis built on that function, so inaccept-newmode such a file makes every host look like a genuine first use: the key is accepted, and the subsequentlearn_known_hosts_pathappend will usually also fail, which is only a warning because recording is intentionally best effort. The net effect is a connection accepted unverified on every run, with no pinning ever established and no error exit code. A host key that changes between runs is never detected.3. No cross-process locking, and a successfully injected entry becomes permanently trusted
KNOWN_HOSTS_LOCKinsrc/ssh/tokio_client/host_verification.rsis a process-widetokio::sync::Mutex. It correctly prevents duplicate appends within one bssh invocation, but two concurrent bssh processes can both observe a host as unknown and both append. If one of them is talking to a man-in-the-middle, the file ends up holding two entries of the same algorithm for that host. This interacts badly with the acceptance rule:lookup_known_hostdeliberately implements OpenSSH's "any recorded key matches" semantics (itself a security fix, since the alternative rejects legitimate layouts such as a stale key kept beside a rotated one), so from then on the attacker's key is accepted silently and permanently, and the changed-key warning never fires again.4.
@cert-authoritymarker lines are not honoredsrc/ssh/tokio_client/host_verification.rsdetects@cert-authoritylines and prints a warning that the marker is unsupported, then falls through to ordinary TOFU. This was a deliberate choice: bssh has no CA signature validation to fall back on, and failing closed would break working CA-based setups with no workaround available to the user. It is recorded here so the limitation is tracked rather than rediscovered. Before PR #241 these lines were ignored completely and silently, so the warning is an improvement, not a regression.Impact
Items 1 and 2 mean the documented and recommended default mode can silently provide zero MITM protection in specific but realistic environments. Item 3 means a single successful race, in a threat model where the attacker can already intercept one connection, converts into permanent trust. Item 4 means users relying on an SSH CA get TOFU semantics instead of certificate validation.
Proposed Solution
(host, port) -> PublicKeymap for the lifetime of the process, populated on every successful verification and consulted alongside the file. This closes items 1 and 2 for the common fan-out case: even with no usable known_hosts file, all connections within a single run must agree on a host's key, so a mid-run substitution is caught. It also strengthens item 3, since a conflicting entry appended by another process is detected against the in-memory pin.Path::try_exists()plus a metadata or open probe). A path that is absent is a legitimate first-use situation. A path that exists but cannot be read, or is not a regular file, must fail closed with a distinct error rather than being treated as empty, in bothaccept-newand strict mode.flockon Unix,LockFileExon Windows, or a dedicated lock file) held for the duration the process-wide mutex is already held. Keep the existing in-process mutex; the two compose.@cert-authorityexplicitly. Either implement certificate validation against the CA key, or make the behavior configurable so security-sensitive deployments can opt into failing closed. Do not change the default silently.Acceptance Criteria
accept-newand no determinable home directory, host keys are still pinned for the lifetime of the process, and a host presenting a different key later in the same run is rejected.accept-newandyesmodes, and is not reported as an unknown host.@cert-authoritybehavior is either implemented or explicitly configurable, and the CLI help text plusdocs/architecture/ssh-client.mdstate the actual guarantee.HOMEenvironment variable (see the companion cleanup issue for the existing HOME-mutation race).