Skip to content

Add SSH certificate authentication support - #1495

Open
labros-mediaalpha wants to merge 5 commits into
mscdex:masterfrom
labros-mediaalpha:cert-auth-support
Open

Add SSH certificate authentication support#1495
labros-mediaalpha wants to merge 5 commits into
mscdex:masterfrom
labros-mediaalpha:cert-auth-support

Conversation

@labros-mediaalpha

@labros-mediaalpha labros-mediaalpha commented May 5, 2026

Copy link
Copy Markdown

Transparency note: 100% Authored by Claude Sonnet 4.6

Problem

SSH certificates (ssh-ed25519-cert-v01@openssh.com and 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:

  • keyParser discards cert-type key blobs from agents and misparses cert pub files (only reads the inner public key, losing the cert fields the server needs)
  • Protocol.authPK sends the cert type name as the signature algorithm; the server requires the underlying key type (e.g. ssh-ed25519) and rejects the attempt
  • Client rejects pre-parsed key objects as privateKey, making it impossible to pass a cert key constructed outside the library

Changes

lib/protocol/keyParser.js

  • Adds a Cert_Public class that stores the full certificate blob and returns it from getPublicSSH(). This is what the server needs to verify the certificate against its trusted CA.
  • OpenSSH_Public.parse already matched cert types via its regex; it now routes them to Cert_Public instead of parseDER (which only handles plain public keys and would silently discard the certificate fields).
  • The binary key parsing path (used for SSH agent identity blobs) now detects cert types and creates Cert_Public objects rather than returning an error, so agent-held certificates are no longer silently filtered.

lib/protocol/Protocol.js

  • authPK was writing the certificate type name (e.g. ssh-ed25519-cert-v01@openssh.com) as the signature algorithm inside the signed SSH_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.js

  • Accepts a publicKey config option (cert file as string/Buffer, or pre-parsed key) to use for identification when it differs from privateKey — the common case of id_ed25519 + id_ed25519-cert.pub as separate files.
  • Also accepts already-parsed key objects as privateKey/publicKey so callers that pre-parse keys can pass them directly.
  • The USERAUTH_PK_OK signing callback always uses privateKey (the real private key) regardless of whether a certificate is being used for identification.

Usage

// Explicit cert file
client.connect({
  host: 'example.com',
  username: 'user',
  privateKey: fs.readFileSync('/home/user/.ssh/id_ed25519'),
  publicKey: fs.readFileSync('/home/user/.ssh/id_ed25519-cert.pub'),
});

// Agent with cert identity — works automatically now
client.connect({
  host: 'example.com',
  username: 'user',
  agent: process.env.SSH_AUTH_SOCK,
});

When publicKey is omitted and no cert-type keys are involved, behaviour is identical to before this change.

Relation to #808

PR #808 proposed a similar publicKey option 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.

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.
@DanikTre

DanikTre commented Sep 2, 2026

Copy link
Copy Markdown

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
authenticating with OpenBao-signed user certificates. Diagnosis matched yours
exactly: authPK() writes the outer algorithm name into the signature blob,
where the plain name belongs, so sshd rejects every certificate.

We ended up applying essentially this diff as a pnpm patch — same split
between the outer certificate name and the inner plain name, same three write
sites. Verified against sshd with TrustedUserCAKeys and
PasswordAuthentication no, so only a CA-signed certificate can authenticate:

unpatched: Failed publickey ... ED25519-CERT ... CA ED25519
patched: Accepted publickey ... pkalg ssh-ed25519-cert-v01@openssh.com

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
needs no server or network: it drives Protocol.prototype.authPK against a
stand-in protocol object and asserts the outer and inner algorithm names
separately, and it fails when the fix is reverted. Happy to contribute it here
if that would help.

*Opus 5

@labros-mediaalpha

Copy link
Copy Markdown
Author

I notice this PR has no tests, which may be holding it up. We wrote one that needs no server or network: it drives Protocol.prototype.authPK against a stand-in protocol object and asserts the outer and inner algorithm names separately, and it fails when the fix is reverted. Happy to contribute it here if that would help.

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!

Danik added 3 commits September 2, 2026 17:49
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.
@DanikTre

DanikTre commented Sep 2, 2026

Copy link
Copy Markdown

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
@labros-mediaalpha

Copy link
Copy Markdown
Author

many thanks @DanikTre

@labros-mediaalpha

Copy link
Copy Markdown
Author

@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.

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.

2 participants