Add SSH certificate authentication support - #1495
Conversation
SSH certificates (e.g. ssh-ed25519-cert-v01@openssh.com) are widely used
in enterprise environments where a CA signs user keys instead of managing
authorized_keys files. This change adds support for authenticating with
them.
Three components are needed:
keyParser: Add Cert_Public class
- Parses certificate public key files (id_xxx-cert.pub) and raw certificate
blobs returned by SSH agents into a Cert_Public object.
- Cert_Public stores the full certificate blob and returns it from
getPublicSSH(), which is what the server needs to verify the cert against
its trusted CA.
- The existing OpenSSH_Public.parse already matched cert types via regex;
it now routes them to Cert_Public instead of parseDER (which only handles
plain public keys and would discard the cert fields).
- Binary key parsing (used for SSH agent identity blobs) similarly detects
cert types and creates Cert_Public objects rather than failing.
Protocol: Fix signature algorithm for cert auth
- authPK was writing the certificate type name (e.g.
"ssh-ed25519-cert-v01@openssh.com") as the signature algorithm in the
signed USERAUTH_REQUEST. The SSH protocol requires the underlying key
algorithm ("ssh-ed25519") in that field. The server rejects the
authentication if these don't match.
Client: Support publicKey option and pre-parsed key objects
- Accepts a publicKey config option (cert file path/Buffer/parsed key) to
use for identification when it differs from privateKey (e.g. when you
have id_ed25519 + id_ed25519-cert.pub as separate files).
- Also accepts already-parsed key objects as privateKey/publicKey, so
callers that pre-parse keys (e.g. to auto-detect certs alongside keys)
can pass them directly without the client re-parsing.
- The USERAUTH_PK_OK handler signs challenges with privateKey so the
signing key is always the real private key regardless of whether a
certificate is being used for identification.
Usage:
client.connect({
host: 'example.com',
username: 'user',
privateKey: fs.readFileSync('~/.ssh/id_ed25519'),
publicKey: fs.readFileSync('~/.ssh/id_ed25519-cert.pub'), // optional
});
When publicKey is omitted and privateKey is a cert-less key, behaviour
is identical to before. SSH agents that return cert-type identities are
now handled correctly too.
|
Independent confirmation that this fix is correct, from a production use case. We hit the same bug in a browser terminal that SSHes into devices We ended up applying essentially this diff as a pnpm patch — same split unpatched: Failed publickey ... ED25519-CERT ... CA ED25519 with an interactive shell and window resizing working normally afterwards. I notice this PR has no tests, which may be holding it up. We wrote one that *Opus 5 |
hi @DanikTre , thank you for confirming the fix - yes please if you could contribute the tests that would be great - let me know if I can help at all from my end - thanks! |
The signature-name split in authPK() named the certificate type in the request and the plain key type in the signature blob, but still passed the certificate type to convertSignature(), which matches none of its cases. ed25519 and RSA fall through unchanged, so they worked; an ECDSA certificate sent a DER-encoded signature where the SSH (r, s) encoding belongs and was rejected by the server. Derive the plain name once, before cbSign, and use it for both the signature blob and convertSignature(). For non-certificate keys it equals keyAlgo, so their packets are unchanged.
OpenSSH_Public.parse() sniffs the type with readString(data, data._pos, true), which stamps a _pos cursor on data. The certificate path then handed that same buffer out through getPublicSSH(). The bytes were right, but the stray own property made two identical certificates fail assert.deepStrictEqual(). Delete the cursor before constructing Cert_Public. The binary-blob path uses binaryKeyParser's own cursor and is unaffected. Also wrap a comment on that path to 80 columns for npm run lint.
Authenticate against the in-process server with a user certificate of each type, signed by a test CA (validity to 2200 so the fixtures do not expire under the suite). For each, check the two algorithm names independently -- the request must name the certificate type and the signature blob the plain key type -- and verify the signature against the plain key. Needs no external ssh or sshd. Also capitalize a comment in client.js for npm run lint.
|
Opened a PR against your branch with the tests: labros-mediaalpha/ssh2-cert-fix#1 While writing them, the ECDSA case turned up one gap in the fix: convertSignature(signature, keyType) still gets the certificate name, which matches none of its cases, so an ECDSA certificate sends a DER signature where SSH (r, s) belongs and the server rejects it. ed25519 and RSA pass through unaffected, which is why it was easy to miss. The PR derives the plain name once and uses it for both the signature blob and convertSignature. It also caught Cert_Public handing out the parser's _pos cursor on the blob — bytes fine, but deepStrictEqual on two identical certificates failed. Deleted before construction. Both are small; happy to split or reshape however is easiest for you to merge. *Fable 5.1 |
Certificate auth: tests, plus ECDSA signature and parser-cursor fixes
|
many thanks @DanikTre |
|
@mscdex whenever you get a chance — this PR just needs a workflow approval to run CI. Since the last update, @DanikTre independently verified the fix in production and contributed tests plus a couple of edge-case fixes (ECDSA cert signatures, a parser cursor leak). Happy to help however's useful — let us know. |
Transparency note: 100% Authored by Claude Sonnet 4.6
Problem
SSH certificates (
ssh-ed25519-cert-v01@openssh.comand equivalent types for RSA/ECDSA) are widely used in enterprise environments where a CA signs user keys. This PR adds support for authenticating with them. Currently all three layers silently fail:ssh-ed25519) and rejects the attemptprivateKey, making it impossible to pass a cert key constructed outside the libraryChanges
lib/protocol/keyParser.jsCert_Publicclass that stores the full certificate blob and returns it fromgetPublicSSH(). This is what the server needs to verify the certificate against its trusted CA.OpenSSH_Public.parsealready matched cert types via its regex; it now routes them toCert_Publicinstead ofparseDER(which only handles plain public keys and would silently discard the certificate fields).Cert_Publicobjects rather than returning an error, so agent-held certificates are no longer silently filtered.lib/protocol/Protocol.jsauthPKwas writing the certificate type name (e.g.ssh-ed25519-cert-v01@openssh.com) as the signature algorithm inside the signedSSH_MSG_USERAUTH_REQUEST. Per the SSH certificate protocol the signature algorithm must be the underlying key type (ssh-ed25519). Servers reject the auth attempt when these do not match.lib/client.jspublicKeyconfig option (cert file as string/Buffer, or pre-parsed key) to use for identification when it differs fromprivateKey— the common case ofid_ed25519+id_ed25519-cert.pubas separate files.privateKey/publicKeyso callers that pre-parse keys can pass them directly.USERAUTH_PK_OKsigning callback always usesprivateKey(the real private key) regardless of whether a certificate is being used for identification.Usage
When
publicKeyis omitted and no cert-type keys are involved, behaviour is identical to before this change.Relation to #808
PR #808 proposed a similar
publicKeyoption but targeted the old v0.x stream-based API. This PR targets the current v1.x API, adds the agent fix, and corrects the signature algorithm bug that would have caused #808 to fail against a real server even if merged.