Skip to content

Verify VVK-signed SSH host certificates - #59

Open
sashyo wants to merge 3 commits into
feat/ssh-host-key-verificationfrom
feat/ssh-host-certificates
Open

sashyo wants to merge 3 commits into
feat/ssh-host-key-verificationfrom
feat/ssh-host-certificates

Conversation

@sashyo

@sashyo sashyo commented Aug 24, 2026 •

Copy link
Copy Markdown
Owner

UV's step 5, complete, plus the format work. Stacked on #58 — review that first.

Why certificates beat the prompt

Trust-on-first-use (#58) still asks an operator to compare a fingerprint, and people click through prompts. With VVK-signed certificates there is no prompt: the client already trusts the quorum, so there is nothing to distribute and nothing to compare.

Two signatures, both required

Check Proves Done by
Key exchange signature Server holds the private key the certificate vouches for The SSH library, via our algorithm's verifier
CA signature The VVK vouched for that key and identity verifyCertificate

The first alone is satisfied by any self-signed certificate — and the handshake would succeed and look correct. Nothing in dev-tunnels-ssh checks the second. That is the failure mode this design invites, so it is worth stating in review.

Feasibility, read rather than assumed

  • getPublicKeyAlgorithms() returns the configured list unfiltered for client sessions, so a custom algorithm is advertised in KEX and sshd sends the certificate.
  • handleDhReplyMessage does setPublicKeyBytes(message.hostKey) — the whole certificate blob arrives at our KeyPair.
  • The exchange hash is computed over the full blob sent, which is correct for certificates.

Points UV raised, and what I did

  • readSignatureData must be overridden. Correct, and it was already a bug in waiting: OpenSSH signs the exchange with the host's own key, so the blob is named ssh-ed25519, not the negotiated certificate name. The inherited implementation expects the negotiated name. Overridden, and it now checks both the name and the 64-byte length rather than skipping past them.
  • uint64 serials must not be JS numbers. Agreed — the parser uses getBigUint64 throughout, with a test that a serial survives exactly.
  • Replace the algorithm list, don't prepend. Adopted. With an authority configured the client advertises only the certificate algorithm, so a server cannot offer a plain host key and downgrade us to first-use trust.
  • Reject empty principals. Adopted, and stricter than OpenSSH, which reads empty as "any host". One certificate authenticating every server is not a property worth having.
  • Reject unsupported critical options. Adopted — that is what makes them critical.
  • Revocation by serial. Supported via policy.
  • Ed25519 verification. createVerifier previously threw ("not implemented by this client"), which is why ed25519 host keys were pushed to the end of the list and avoided. Implemented with WebCrypto, so no dependency is added.

Server side

docs/SSH-HOST-CERTIFICATES.md covers issuing and installing. The one thing that catches people: placing the file is not enough, sshd does not load host certificates without HostCertificate in sshd_config.

Testing

364 pass, 20 new. Certificates are built in real wire format and signed with real Ed25519 keys, so the parser meets bytes an sshd would send rather than a fixture shaped to fit it.

The adversarial cases are the substance: signed by a different authority; claiming our CA but signed by an impostor; edited after signing; a user certificate presented as a host key; not-yet-valid, expired, and the exact validBefore boundary; wrong host; empty principals; critical options; revoked serial; and a length prefix that overruns the buffer.

Not tested: the handshake integration. The algorithm registration, readSignatureData and the onAuthenticating branch need a live sshd presenting a real certificate. That is the acceptance test — issue one, install it, connect, then confirm a certificate from a different CA is refused.

Still open — issuance

Steps 1-3 of UV's plan are not built. The signing pattern exists (BasicCustomRequest with an arbitrary draft, as tideSsh.ts uses for SSH auth), but which flow certifies a host key is a decision, not a guess: quorum approval implies the explicit multi-admin path rather than the implicit Policy:1 flow used for user auth.

Also worth confirming: the CA the client trusts is the realm VVK from authConfig.jwk, the same key that already signs user SSH auth. One trust anchor in both directions — nice, but I would rather you confirmed it than assumed it.

sashyo added 2 commits August 24, 2026 11:29
The format half of VVK-signed host certificates, which removes the
trust-on-first-use prompt entirely because the client already trusts the
quorum — no known_hosts to distribute, nothing for an operator to compare.

Two checks are required and neither is sufficient alone. The key exchange
signature proves the server holds the private key for the key inside the
certificate, and the SSH library already does that. The CA signature proves
the VVK vouched for that key, and nothing in the library does it — so without
this, any self-signed certificate would be accepted.

Verification covers the certificate type (a user certificate presented as a
host key is refused), the issuing authority, the validity window with
validBefore exclusive as OpenSSH treats it, principals, and the signature over
the certificate's own signed bytes.

Every read is bounds-checked. This parses bytes supplied by whatever is at the
other end of the connection, so a truncated field or an overrunning length
prefix has to be an error rather than a short read.

Verification uses WebCrypto's Ed25519, which is available in the browser and
in Node, so no dependency is added. Note the existing Ed25519PublicKeyAlgorithm
still throws from createVerifier — wiring certificates into the handshake needs
that implemented, and it is not done here.
Completes the client half. Registering a certificate algorithm makes the client
advertise ssh-ed25519-cert-v01@openssh.com, which is what causes sshd to send
its certificate as the host key; the library then verifies the exchange
signature against the key inside it, and the CA signature is checked here.

Both are required. The exchange signature alone proves only that the server
holds the key its certificate names, which any self-signed certificate would
satisfy. Nothing in the SSH library checks who signed the certificate.

Implements Ed25519 verification, which previously threw. That gap is why
ed25519 host keys were pushed to the end of the algorithm list and effectively
avoided; certificates need it for both signatures.

Overrides readSignatureData: OpenSSH signs the exchange with the host's own
key, so the blob is named ssh-ed25519 rather than after the certificate, and
the inherited implementation expects the negotiated name.

When an authority is configured the client advertises only the certificate
algorithm rather than preferring it, so a server cannot offer a plain host key
and pull the connection back to first-use trust.

Policy is stricter than OpenSSH in two places, both deliberate: a certificate
naming no principals is refused rather than treated as valid for every host,
and any critical option is refused rather than ignored.
@sashyo sashyo changed the title Parse and verify OpenSSH host certificates Verify VVK-signed SSH host certificates Aug 24, 2026
Issuance is split so nothing here needs a private key: the caller gets the
exact bytes the authority must sign, takes them to the quorum, and returns with
a signature to assemble. That is what lets the VVK certify a host without any
key ever existing outside the enclave.

Refuses at issue time what the client would refuse at connection time — a
certificate naming no principals, an empty validity window, keys that are not
ed25519 sized — so a bad certificate cannot be signed and then fail in the
field.

Tests issue a certificate, sign it with a real CA key, and run the result
through the same parser and verifier a handshake uses, including a serial past
2^53 to prove uint64 stays exact.
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