Files
orca/docs/reference/ssh-host-key-verification.md
T
Neil 0f37005de0 fix(ssh): bind the host key store to the active profile at startup
Without this the store reports nothing trusted on every launch. Safe — known_hosts
still decides, and a host trusted only by us degrades to first contact and is
re-recorded — but it silently discarded our own accept records, so the store the
design calls for was not actually in use.

Bound beside the profile Store, since it is a sidecar of the same data file.

Also records why the paired-web carve-out the migration review asked for is NOT
implemented in Phase 1, rather than leaving it looking forgotten. That carve-out
exists to stop a web client waiting out the 120s prompt timeout — a hang only
reachable if a prompt exists, and Phase 1 has none, which the decision function
pins with a test asserting it never returns 'prompt'. An RPC connect therefore
behaves exactly like a local one. Adding a fail-fast path now would introduce a
failure mode for a hang that cannot occur; it becomes load-bearing when the
dialog lands and is listed under Phase 2.

Noted there for whoever builds Phase 2: runtime/rpc/methods/ssh.ts already
swallows the specific error and rethrows a generic one, so the host-key reason
will not reach a web user without a change there too.

Full unit suite: 52,352 pass. The 8 failures are the known environment baseline
(5 osc8, 2 IME) plus one browser-cookie suite-ordering flake that passes in
isolation — none in src/main/ssh, and none related to this change.
2026-08-14 03:10:23 -07:00

17 KiB

SSH host key verification (STA-4319)

Revised after security and migration review. Where a first draft was wrong, the correction is kept visible rather than quietly edited out — the reasoning matters for anyone changing this later.

The defect

src/main/ssh/ssh-connection.ts:1184 installs a hostVerifier that records a SHA-256 fingerprint and then return true. Every ssh2 connection accepts every host key. There is no known_hosts consult, no trust record, and no change detection anywhere in src/main/ssh/. There is exactly one ssh2 Client construction site, so the fix has a single chokepoint.

Scope is per-connection, not per-feature: one SshConnection per target serves exec, SFTP, port forwarding, the filesystem watcher and relay deploy.

Threat model, corrected

Traffic is still encrypted, so a passive observer gets nothing. The exposure is an active attacker who can redirect the connection — ARP/DNS spoofing, hostile Wi-Fi, a hijacked internal name.

Three corrections to the first draft:

  • Jump hosts are NOT the worst case; they are already safe. shouldUseSystemSshTransport (ssh-transport-selection.ts:71-91) returns true for exactly the conditions under which resolveEffectiveProxy (ssh-proxy-command.ts:17-38) returns a proxy — the two branch on the same inputs in the same order — and attemptConnect returns unconditionally after the system probe (ssh-connection.ts:670-673). So ProxyJump/ProxyCommand go through OpenSSH and are already verified. The ssh2 proxy-spawn at :697 is effectively unreachable. Good news for migration, and the first draft's motivating example was simply wrong.
  • Agent forwarding was overstated. agentForward is gated on the user's ForwardAgent yes (ssh-connection-utils.ts:203-205). config.agent is always set, but that is agent auth, whose signatures bind the session id and cannot be replayed onward. The risk applies to users who opted into ForwardAgent, not everyone.
  • Credential theft was understated, and the relay claim was backwards. isAgentFallbackError treats any auth error as agent fallback (ssh-connection-utils.ts:59-61), so a MITM that rejects publickey walks the user to the password prompt (ssh-connection.ts:844) and the private-key passphrase prompt (:834), and cachedPassword is replayed without prompting on every reconnect (:709). Meanwhile the relay upload matters less than assumed — the attacker already owns their machine. The real client-side impact is the return direction: the attacker becomes the host our workspace trusts, driving relay protocol frames, landing SFTP content in local worktrees, and feeding agent-hook payloads in.

Decisions

D1. Read the user's known_hosts; write only to our own store

Consult the user's real known_hosts as a trust source — most developers already have their hosts there from ssh and git, which is the entire migration story. Do not write to it: that file is shared with every other SSH tool on the machine, and appending brings line-endings, permissions, concurrent writers, and a corruption blast radius well beyond us.

Two consequences to own rather than discover:

  • Revocation does not propagate. ssh-keygen -R host clears known_hosts but not our store, so our acceptance outlives the user's own remediation and is invisible to ssh. The "forget" action (D5) is the only cure, so it must be discoverable, and mismatch messaging must name which source disagreed.
  • ssh -G on the HOME-divergent -F path suppresses /etc/ssh/ssh_config (ssh-g-config-resolution.ts:44-52), hiding site-wide StrictHostKeyChecking yes and GlobalKnownHostsFile. On that path we must fail strict, never laxer than ssh would.

D2. Ask ssh -G, do not reimplement config resolution

ssh -G reports userknownhostsfile, globalknownhostsfile, stricthostkeychecking, checkhostip, hostkeyalgorithms, fingerprinthash, hashknownhosts, updatehostkeys and hostkeyalias — with Match and Include already applied. resolveWithSshG exists and simply does not read them yet.

userknownhostsfile is a space-separated list on one line, may contain ~, and may contain double-quoted paths with spaces. When ssh -G is unavailable (no ssh, non-zero exit, >5s timeout) fall back to ~/.ssh/known_hosts + known_hosts2 — never to accept.

HostKeyAlias must be honoured: users tunnelling bastions through localhost:port depend on it and would otherwise hit spurious mismatches. It appears nowhere in src/main/ssh/ today.

Lookup key. Config resolution uses configHost || label (ssh-connection.ts:660) while ssh2 dials effectiveHost (ssh-connection-utils.ts:188). The known_hosts lookup must use HostKeyAlias if set, else the resolved hostname — keying on the Orca label would miss every existing entry.

Two ordered lookup passes, not one candidate set. Verified against OpenSSH 10.2p1: a non-default port looks up [host]:port first, and if that finds nothing it retries the bare host. Crucially, on that second pass a wrong key is downgraded to unknown rather than reported as changed. So the passes are [['[host]:port'], ['host']], and the fallback pass can only yield match or unknown. Collapsing them into one set would give a spurious first-contact prompt to anyone who has a bare line and connects on a non-default port; treating the fallback as authoritative would raise a false change-of-key alarm.

Hashed entries hash the candidate form, not the bare host[example.com]:2222 is what gets HMAC'd for a bracketed entry, so each candidate must be hashed separately.

Multiple files union. Any exact hit in any file wins; a disagreeing entry in another file does not make it a mismatch. Confirmed live in both orderings.

A @cert-authority line whose key equals the presented plain host key is not a match — a CA line only validates certificates. A normal line alongside it still decides.

D3. Six outcomes, and type scoping is only safe with algorithm ordering

match | mismatch | revoked | ca-only | unknown-type-known-host | unknown.

Mismatch is scoped to the same key type: a host with only an RSA entry that presents ed25519 is not "changed". Without scoping we would false-alarm nearly every RSA-era user on their first upgraded connect, training them to dismiss the one warning that matters.

But scoping alone is a downgrade vector, and this is the correction that most changes the design. OpenSSH is safe here only because order_hostkeyalgs() reorders the client's proposed host-key algorithms to put the types already in known_hosts first, and RFC 4253 gives the client's order priority — so a server cannot choose a type the client deprioritised. ssh2 negotiates ed25519 first regardless. An attacker who cannot forge the RSA key on file simply presents ed25519 and receives a friendly first-contact prompt instead of a hard failure.

Therefore: set ssh2's algorithms.serverHostKey to lead with the key types already known for that host. Type scoping without algorithm ordering is not a safe design.

And when the presented type is unknown while other types are known for this host, that is unknown-type-known-host — never a plain TOFU prompt. It must say we already hold a different key for this host.

D4. Outcomes

  • match → connect silently.
  • unknown → trust-on-first-use (see the phasing below for whether that is silent or prompted).
  • mismatch → hard fail, no override in the failure surface.
  • revoked → hard fail, always.
  • ca-only → hard fail with a message naming certificate-authority hosts as unsupported on this transport, plus the documented escape (below). These hosts connect today, so this is a live functional regression, and without an escape it will generate exactly the support pressure that produces the override D4 refuses.
  • unknown-type-known-host → treat as suspicious, not first contact.

StrictHostKeyChecking is honoured: no/off accepts unknown but never persists and still hard-fails changed and revoked; accept-new persists silently; yes denies unknown.

Documented escape for ca-only and any host we cannot verify: ORCA_SSH_FORCE_SYSTEM_TRANSPORT=1 routes through OpenSSH, which handles CA hosts correctly. That is a real answer, not a bypass.

D5. Recovery must not live in the failure dialog

A "forget this host key" button in the mismatch dialog is D4's rejected "trust anyway" with one extra click. Recovery lives in target settings: a separate, deliberate surface, no auto-retry, and it shows the stored fingerprint so the user is choosing knowingly.

Offer it only when our store is what disagreed; when known_hosts disagrees, forgetting our record cannot unblock the connect. Messages, written to avoid naming internals:

Ours disagreed — "The host key for build-01 changed since you last connected from Orca. If you rebuilt or reprovisioned this machine, this is expected." → Forget the saved key / Cancel

known_hosts disagreed — "The host key for build-01 does not match the entry in ~/.ssh/known_hosts. ssh and git will refuse this host too. Run ssh-keygen -R build-01." → no button, because a button would not help.

D6. Never prompt on a background reconnect

A prompt only means something when a human initiated the connect. userInitiated does not exist on the connect path today and must be threaded through connect → attemptConnect → doSsh2Connect, defaulting false.

Two traps: useAutomationDispatchEvents.ts:203 and pty-connection.ts:857 reach ssh:connect without a human click — automation must pass false, but terminal-pane focus reconnects must count as user-initiated or terminals die silently. And the denial string must avoid "authentication failed"/"permission denied", or isAgentFallbackError/isAuthError (ssh-connection-utils.ts:46-61) misclassifies it and the reconnect ladder retries a decision that will never change.

D7. Fail closed — three known fail-open shapes

  1. The existing generation/disposed guard at :1185 has the fail-open shape today: skip recording, still return true. Post-fix that branch must deny.
  2. A synchronous throw inside the verifier may not be caught by ssh2 — wrap and verify(false).
  3. Any non-undefined return accepts immediately (see Traps).

Plus: no prompt channel registered → deny (the load-bearing default lives in doSsh2Connect, not in IPC, so a caller that forgets to wire it cannot accidentally accept); no window → deny; timeout → deny; dialog dismissed → deny.

D8. Store shape and scope

Accepted keys are scoped to host + port + key type, not target id — aliases point at different machines, two targets can name one machine, and a re-created target must not lose trust.

The store is a dedicated file, not the main persistence blob (persistence.ts:7088): a settings restore or rollback must not silently reset trust. Accept and mismatch events are logged.

hostKeyFingerprint is now security-relevant and wire-relevant — it is an isolation namespace sent to the host (ssh-relay-session.ts:1298, managed-hook-owner-identity.ts:187). It is undefined on the system transport, so no trust logic may key off it, and its format must not change (see Traps).

Phasing — ship the defence before the dialog

Review made the case that the riskiest part of this change is not the security model but the modal. Startup restore fires eager connects for all previously-active targets in parallel (App.tsx:1041) with a 15s timeout, while a prompt would live 120s — N unknown hosts means N stacked dialogs outliving the timeout that already deferred them. Runtime-owned ephemeral VMs (ephemeral-vm-runtime-ssh.ts:31) dial a freshly provisioned host with a brand-new key on every launch. Paired-web connects run on the host desktop (runtime/rpc/methods/ssh.ts:32), so the dialog would open on someone else's screen while the web user watches a spinner.

Phase 1 — no new modal. Consult known_hosts + our store. match connects. unknown persists silently with accept-new semantics and a passive notification naming the host and fingerprint. mismatch (same type) and revoked hard-fail. This is the entire MITM defence with zero prompts, zero startup storms and zero web hang.

Phase 2 — the TOFU dialog, StrictHostKeyChecking honouring, ca-only, userInitiated plumbing, and the D5 settings surface.

Carve-outs required before Phase 1 ships:

  • Runtime-owned ephemeral targets are exempt from persistence — a new key every launch is expected, not suspicious, and recording one would accumulate a row per launch that eventually reads as a spurious change. Implemented via target.owner?.type === 'on-demand-runtime'.

  • RPC-originated connects: NOT needed in Phase 1, required in Phase 2. The review asked for these to fail fast rather than leave a paired-web user watching a spinner for the 120s prompt timeout. That hang is only reachable if a prompt exists, and Phase 1 has none — the decision function is pinned by a test asserting it never returns prompt. An RPC connect therefore behaves exactly like a local one: it accepts and records on first contact, or fails immediately with the host-key reason. Adding a fail-fast path now would introduce a failure mode for a hang that cannot occur. It becomes load-bearing the moment the dialog lands, and is listed in Phase 2.

    Worth noting for Phase 2: runtime/rpc/methods/ssh.ts already swallows the specific error and rethrows getPublicSshError(status), so a web client sees a generic failure rather than the host-key reason. Pre-existing, but it means the Phase 2 message will not reach the web user without a change there too.

Traps

Each of these makes the fix silently do nothing. All confirmed in our tree.

  1. An async verifier defeats it entirely. ssh2 does const ret = hashCb(key, verify); if (ret !== undefined) verify(ret). An async function returns a Promise — not undefined, and truthy — so ssh2 accepts before our callback settles.
  2. Do not set ssh2's hostHash. It hands the callback a hex digest and discards the raw blob we must compare — and it would change hostKeyFingerprint's format, which is a cross-version state break, not a local refactor.
  3. The existing test mock calls hostVerifier(key) with one argument and ignores the return (ssh-connection.test.ts:86-91). Under an async verifier every connect test there breaks. The mock must change — flagged deliberately, not rewritten silently.
  4. Validate the blob: embedded algorithm name must match the line's key-type field; reject empty decodes, empty salts, and hashed entries whose hash is not 20 bytes.
  5. ssh-relay-live-connect.test.ts:59 constructs a connection with no credential callback — headless with no prompt channel must deny, not hang.

Scope

In scope, corrected: IPv6 literals and [host]:port bracket parsing. Review was right that this is a parser requirement, not a scope call — getting it wrong means hosts ssh knows come back unknown, which is the prompt-training harm D3 exists to avoid.

Out of scope, with consequences stated:

  • CheckHostIP — OpenSSH defaults it off; we form candidates from the hostname only.
  • WSLsrc/main/ssh/ has no WSL awareness; a distro's known_hosts is unreachable, so WSL users get first-contact treatment for hosts they already verified.
  • UpdateHostKeys — we read it and use nothing, so we never learn a rotated key, which makes D5 the routine path for key rotation rather than an exception.
  • Moving SFTP to the system transport — correct direction, separate change.

Test plan

Parser (against the file format, not our code's shape): plain lines, host,host2 lists, [host]:port used only when port ≠ 22, IPv6 literals, hashed |1|salt|hash with a real computable vector, @revoked, @cert-authority, */? globs, ! negation vetoing a whole line, unrecognised @marker skipping the line, malformed lines skipped not fatal, multiple keys per host, CRLF, blank lines, comments, user file and global file disagreeing.

Decision function: all six outcomes; type scoping; revocation resolved before match regardless of line order; every StrictHostKeyChecking value; no/off never persists.

Algorithm ordering: algorithms.serverHostKey leads with types on file — the test that makes D3 safe rather than merely scoped.

Wiring: unknown persists (Phase 1) without a prompt; match never notifies; mismatch fails with no accept path; revoked fails; background reconnect denies; aborted connect settles pending verify false; no prompt channel denies; runtime-owned targets are exempt; the denial string does not match isAuthError; and — catching the worst regression — the verifier returns nothing, so a refactor to async reddens a test rather than reaching a user.