Skip to content

Verify the SSH host key instead of accepting any server - #58

Open
sashyo wants to merge 1 commit into
mainfrom
feat/ssh-host-key-verification
Open

sashyo wants to merge 1 commit into
mainfrom
feat/ssh-host-key-verification

Conversation

@sashyo

@sashyo sashyo commented Aug 23, 2026

Copy link
Copy Markdown
Owner

UV is right: we never validated the SSH host key. This closes it, as the stopgap ahead of VVK-signed host certificates.

The gap

sshClient.ts accepted every server, and said so:

// Handle server authentication (accept any server for now)
this.session.onAuthenticating((e: SshAuthenticatingEventArgs) => {
  // For server authentication, just accept
  // In production, you'd verify the host key
  e.authenticationPromise = Promise.resolve({});
});

null means reject in this library; {} accepts. Every SSH path went through it — SFTP and SCP take an existing session rather than opening their own, so this was the single choke point.

What an attacker gets. Publickey auth signs over the session identifier, so a man-in-the-middle cannot replay the user's credentials to the real host — no credential theft, no lateral access as that user. What it can do is impersonate the host completely: the operator gets a shell that looks right, types real commands into it, pastes secrets into it, and reads fabricated output. The relevant adversary is anything in the relay path — a compromised gateway or signal server, or anyone able to influence which endpoint the gateway dials.

What this does

Trust on first use, matching OpenSSH's model:

  • First connection to a target shows the fingerprint as SHA256:…, formatted exactly as ssh-keygen -lf prints it so the two can be compared by eye, and pins it on confirmation.
  • Later connections must present the same key.
  • A changed key aborts with a loud error rather than prompting. Being asked to re-approve is precisely how people are trained to click through the one prompt that matters; clearing a pin is a deliberate admin act.

Three decisions worth calling out:

Pins are deployment-wide, not per-user. One operator accepting a key protects everyone, and a man-in-the-middle can't work through users one at a time until someone clicks accept.

Verification fails closed. A missing key, an unreadable key, an unreachable pin store, or any unexpected error refuses the connection. Falling through to "accept" on error is how verification quietly stops happening.

Pins are never silently replaced. The pin endpoint returns 409 on a different key rather than overwriting, and after a first-use pin the client re-checks what actually got stored — so two simultaneous first connections can't let the second one's key win.

Targets prefer the server record (server:<id>) so a pin survives the host changing address, falling back to addr:host:port for ad-hoc connections and gateway backends with no server record.

Testing

344 tests pass, 14 new on the verification logic — including the fingerprint format checked against a known SHA-256 value, mismatch on a changed algorithm with identical bytes, rejection of a truncated key (guarding against a prefix comparison creeping in), and that a pin survives an address change.

Not covered by tests: the onAuthenticating wiring itself and the dialog. Those need a live SSH session, so the end-to-end path is unverified — worth connecting to a real host, confirming the prompt appears with a fingerprint matching ssh-keygen -lf on that host, then changing the host key and confirming the connection is refused.

Note on scope

Gateway-mode connections pass serverId as the host, so their target key is server:<backend> rather than an address. That is stable and pins correctly, but the fingerprint dialog shows the backend name rather than an IP. Fine for now; worth revisiting when certificates land.

The browser client accepted whatever host key was presented:

    // Handle server authentication (accept any server for now)
    // For server authentication, just accept
    // In production, you'd verify the host key
    e.authenticationPromise = Promise.resolve({});

So anything in the relay path — a compromised gateway or signal server, or
anyone able to influence which endpoint the gateway dials — could impersonate
a target host. Publickey auth signs over the session id, so a man-in-the-middle
cannot replay the user's credentials to the real host, but it can present a
convincing shell and read everything typed into it.

Trust on first use, as OpenSSH does it. The first connection shows the
fingerprint in OpenSSH's own format so it can be checked against ssh-keygen on
the host, and pins it. Later connections must present the same key; a changed
one aborts rather than prompting, since re-approving is how people are trained
to click through the prompt that matters.

Pins are deployment-wide, not per-user: one operator accepting a key protects
everyone, and a man-in-the-middle cannot work through users one at a time. A
pin is never silently replaced — clearing one is a deliberate admin act.

Verification failures fail closed. A missing key, an unreadable key, an
unreachable pin store or an unexpected error all refuse the connection, since
falling through to "accept" is how verification quietly stops happening.

This is the stopgap ahead of VVK-signed host certificates, which remove the
prompt entirely because the client already trusts the quorum.
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.

1 participant