Skip to content

client: verify certificate host keys - #748

Closed
biao29 wants to merge 2 commits into
Eugeny:mainfrom
frog-hq:feat/host-certificates
Closed

client: verify certificate host keys#748
biao29 wants to merge 2 commits into
Eugeny:mainfrom
frog-hq:feat/host-certificates

Conversation

@biao29

@biao29 biao29 commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Lets a client accept a server that proves its identity with an OpenSSH host
certificate instead of a bare key, so a fleet can be trusted through its CA
rather than through a fingerprint on every machine and every device.

Off by default. Preferred::host_key_certificates is empty unless a caller
fills it in, so no existing client changes what it advertises and no server
starts sending certificates to a client that has no idea who is allowed to have
signed them.

What changes

  • Preferred::host_key_certificates: the certificate algorithms to advertise.
    A parallel list because Algorithm cannot represent one — it maps
    ssh-ed25519-cert-v01@openssh.com back to Ed25519, so a certificate placed
    in key would be advertised under the plain name and never negotiated.
  • client::Handler::check_server_certificate, called instead of
    check_server_key when the server presented a certificate. It defaults to
    refusing: a client that has not been told which authorities it trusts cannot
    answer, and answering wrongly accepts any host whose operator can obtain a
    certificate from anyone at all.
  • The client key exchange decodes a certificate when the blob is one, verifies
    the exchange signature with the key the certificate contains, and carries the
    certificate to the handler.

The part worth reviewing closely

The exchange hash now uses the blob the server actually sent, rather than
re-encoding the parsed key:

-    server_host_key.to_bytes()?.encode(&mut pubkey_vec)?;
+    server_host_key_blob.encode(&mut pubkey_vec)?;

For a plain key the two are identical. For a certificate the parsed form is
only the key inside it, so re-encoding hashes something the peer never sent —
and both sides of that comparison are computed locally, so the failure surfaces
as a bad signature with nothing on the wire to compare against.

The certificate's own signature is deliberately not checked here. That is a
question about trusted authorities, which only the handler can answer; verifying
the exchange with the contained key and vouching for the certificate are two
separate proofs, and collapsing them would accept a certificate nobody vouched
for.

Compatibility

Adding a field to Preferred breaks code that builds it literally rather than
with ..Default::default(). Two such literals in this crate are updated in the
second commit. Everything else is additive: a new defaulted trait method and a
new field.

Testing

cargo test -p russh --lib passes (152 tests). Exercised end to end against
OpenSSH sshd with HostCertificate configured and AuthorizedKeysFile none,
including the refusal path when the presenting authority is not one the client
was told about.

biao29 added 2 commits August 14, 2026 05:04
Five coupled pieces. The exchange hash now uses the blob the server actually
sent: for a certificate the parsed form is only the key inside it, and
re-encoding that hashes something the server never sent — a failure that looks
like a bad signature and is computed entirely locally, so there is nothing on
the wire to compare against.

The key exchange is verified with the key the certificate contains; the
certificate's own signature is a separate proof, left to the client and its
trusted authorities. Collapsing them would accept a certificate nobody vouched
for.

check_server_certificate replaces check_server_key rather than adding to it —
the key inside a certificate is not something a client was ever told to trust,
and asking about it as well invites answering the wrong question. It defaults
to refusing: a client that has not been taught which authorities it trusts
cannot answer, and answering wrongly accepts any machine whose operator can get
a certificate from anyone at all.

Certificate algorithms are a parallel list because Algorithm cannot represent
one — it maps ssh-ed25519-cert-v01@openssh.com back to Ed25519, so a
certificate placed in `key` would be advertised under the plain name. Empty by
default: advertising it makes servers present certificates to clients that may
have no idea who is allowed to have signed them.
Two literals build it field-by-field, so a new field is a breaking change for
them. Noted in the pull request: downstream code that does the same needs the
one-line addition, which is why the field carries an empty default and nothing
else changes.
@Eugeny

Eugeny commented Aug 17, 2026

Copy link
Copy Markdown
Owner

@all-contributors add @biao29 for code

@allcontributors

Copy link
Copy Markdown
Contributor

@Eugeny

I've put up a pull request to add @biao29! 🎉

@Eugeny

Eugeny commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Had to add a few fixes so it's getting merged via #752

@Eugeny Eugeny closed this Aug 17, 2026
@oni303

oni303 commented Aug 17, 2026

Copy link
Copy Markdown

Would this also add support for servers to authenticate users that provide a signed certificate.
Please have a look at #641 we worked and tested quite a bit

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.

3 participants