From 0f6871c472c1567c5d5b21b6d6fe70dd6f9814b2 Mon Sep 17 00:00:00 2001 From: Labros Chaidas Date: Tue, 5 May 2026 10:58:07 -0400 Subject: [PATCH 1/4] Add SSH certificate authentication support 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. --- lib/client.js | 33 ++++++++++++++++++++++++++---- lib/protocol/Protocol.js | 19 +++++++++++++----- lib/protocol/keyParser.js | 42 +++++++++++++++++++++++++++++++-------- 3 files changed, 77 insertions(+), 17 deletions(-) diff --git a/lib/client.js b/lib/client.js index 7291c2ce..4b80e401 100644 --- a/lib/client.js +++ b/lib/client.js @@ -33,7 +33,7 @@ const { } = require('./protocol/constants.js'); const { init: cryptoInit } = require('./protocol/crypto.js'); const Protocol = require('./protocol/Protocol.js'); -const { parseKey } = require('./protocol/keyParser.js'); +const { isParsedKey, parseKey } = require('./protocol/keyParser.js'); const { SFTP } = require('./protocol/SFTP.js'); const { bufferCopy, @@ -207,8 +207,16 @@ class Client extends EventEmitter { : undefined); this.config.privateKey = (typeof cfg.privateKey === 'string' || Buffer.isBuffer(cfg.privateKey) + || isParsedKey(cfg.privateKey) ? cfg.privateKey : undefined); + // publicKey may be a certificate file (e.g. id_ed25519-cert.pub) to use + // for identification while privateKey is used for signing. + this.config.publicKey = (typeof cfg.publicKey === 'string' + || Buffer.isBuffer(cfg.publicKey) + || isParsedKey(cfg.publicKey) + ? cfg.publicKey + : undefined); this.config.localHostname = (typeof cfg.localHostname === 'string' ? cfg.localHostname : undefined); @@ -254,6 +262,7 @@ class Client extends EventEmitter { this._agent = (this.config.agent ? this.config.agent : undefined); this._remoteVer = undefined; let privateKey; + let authKey; // key object used for publickey auth (may be a certificate) if (this.config.privateKey) { privateKey = parseKey(this.config.privateKey, cfg.passphrase); @@ -268,6 +277,18 @@ class Client extends EventEmitter { 'privateKey value does not contain a (valid) private key' ); } + + if (this.config.publicKey) { + // A separate certificate was provided for identification. Use it as + // the key sent to the server while privateKey is used for signing. + authKey = parseKey(this.config.publicKey); + if (authKey instanceof Error) + throw new Error(`Cannot parse publicKey: ${authKey.message}`); + if (Array.isArray(authKey)) + authKey = authKey[0]; + } else { + authKey = privateKey; + } } let hostVerifier; @@ -462,7 +483,10 @@ class Client extends EventEmitter { }); } else if (curAuth.type === 'publickey') { proto.authPK(curAuth.username, curAuth.key, keyAlgo, (buf, cb) => { - const signature = curAuth.key.sign(buf, hashAlgo); + // Sign with privateKey (may differ from curAuth.key when a + // certificate is used for identification). + const signingKey = curAuth.privateKey || curAuth.key; + const signature = signingKey.sign(buf, hashAlgo); if (signature instanceof Error) { signature.message = `Error signing data with key: ${signature.message}`; @@ -882,13 +906,14 @@ class Client extends EventEmitter { nextAuth = { type, username, password: this.config.password }; break; case 'publickey': - nextAuth = { type, username, key: privateKey }; + nextAuth = { type, username, key: authKey, privateKey }; break; case 'hostbased': nextAuth = { type, username, - key: privateKey, + key: authKey, + privateKey, localHostname: this.config.localHostname, localUsername: this.config.localUsername, }; diff --git a/lib/protocol/Protocol.js b/lib/protocol/Protocol.js index 73024881..79c0ce1c 100644 --- a/lib/protocol/Protocol.js +++ b/lib/protocol/Protocol.js @@ -701,11 +701,20 @@ class Protocol { if (signature === false) throw new Error('Error while converting handshake signature'); + // For certificate key types the signature algorithm must be the + // underlying key algorithm (e.g. "ssh-ed25519"), not the certificate + // type name (e.g. "ssh-ed25519-cert-v01@openssh.com"). + const certSuffix = '-cert-v01@openssh.com'; + const sigAlgo = keyType.endsWith(certSuffix) + ? keyType.slice(0, -certSuffix.length) + : keyAlgo; + const sigAlgoLen = Buffer.byteLength(sigAlgo); + const sigLen = signature.length; p = this._packetRW.write.allocStart; packet = this._packetRW.write.alloc( 1 + 4 + userLen + 4 + 14 + 4 + 9 + 1 + 4 + algoLen + 4 + pubKeyLen + 4 - + 4 + algoLen + 4 + sigLen + + 4 + sigAlgoLen + 4 + sigLen ); // TODO: simply copy from original "packet" to new `packet` to avoid @@ -729,12 +738,12 @@ class Protocol { writeUInt32BE(packet, pubKeyLen, p += algoLen); packet.set(pubKey, p += 4); - writeUInt32BE(packet, 4 + algoLen + 4 + sigLen, p += pubKeyLen); + writeUInt32BE(packet, 4 + sigAlgoLen + 4 + sigLen, p += pubKeyLen); - writeUInt32BE(packet, algoLen, p += 4); - packet.utf8Write(keyAlgo, p += 4, algoLen); + writeUInt32BE(packet, sigAlgoLen, p += 4); + packet.utf8Write(sigAlgo, p += 4, sigAlgoLen); - writeUInt32BE(packet, sigLen, p += algoLen); + writeUInt32BE(packet, sigLen, p += sigAlgoLen); packet.set(signature, p += 4); // Servers shouldn't send packet type 60 in response to signed publickey diff --git a/lib/protocol/keyParser.js b/lib/protocol/keyParser.js index a276c1ae..d20fcad4 100644 --- a/lib/protocol/keyParser.js +++ b/lib/protocol/keyParser.js @@ -1186,6 +1186,21 @@ function OpenSSH_Public(type, comment, pubPEM, pubSSH, algo) { this[SYM_DECRYPTED] = false; } OpenSSH_Public.prototype = BaseKey; + +// Represents an SSH certificate (e.g. ssh-ed25519-cert-v01@openssh.com). +// The full certificate blob is stored as-is and returned from getPublicSSH() +// so it can be used directly in publickey auth requests. +function Cert_Public(type, comment, certBlob) { + this.type = type; + this.comment = comment; + this[SYM_PRIV_PEM] = null; + this[SYM_PUB_PEM] = null; + this[SYM_PUB_SSH] = certBlob; + this[SYM_HASH_ALGO] = null; + this[SYM_DECRYPTED] = false; +} +Cert_Public.prototype = BaseKey; + { let regexp; if (eddsaSupported) @@ -1198,7 +1213,7 @@ OpenSSH_Public.prototype = BaseKey; return null; // m[1] = full type // m[2] = base type - // m[3] = base64-encoded public key + // m[3] = base64-encoded public key / certificate blob // m[4] = comment const fullType = m[1]; @@ -1210,6 +1225,11 @@ OpenSSH_Public.prototype = BaseKey; if (type === undefined || type.indexOf(baseType) !== 0) return new Error('Malformed OpenSSH public key'); + // Certificate: preserve the full blob so getPublicSSH() returns it intact + // for use in SSH_MSG_USERAUTH_REQUEST publickey auth. + if (fullType !== baseType) + return new Cert_Public(fullType, comment, data); + return parseDER(data, baseType, comment, fullType); }; } @@ -1458,13 +1478,18 @@ function parseKey(data, passphrase) { binaryKeyParser.init(origBuffer, 0); const type = binaryKeyParser.readString(true); if (type !== undefined) { - data = binaryKeyParser.readRaw(); - if (data !== undefined) { - ret = parseDER(data, type, '', type); - // Ignore potentially useless errors in case the data was not actually - // in the binary format - if (ret instanceof Error) - ret = null; + // Certificate blob: preserve it whole rather than trying to parse internals + if (type.indexOf('-cert-v0') !== -1) { + ret = new Cert_Public(type, '', origBuffer); + } else { + data = binaryKeyParser.readRaw(); + if (data !== undefined) { + ret = parseDER(data, type, '', type); + // Ignore potentially useless errors in case the data was not actually + // in the binary format + if (ret instanceof Error) + ret = null; + } } } binaryKeyParser.clear(); @@ -1478,6 +1503,7 @@ function parseKey(data, passphrase) { module.exports = { isParsedKey, + Cert_Public, isSupportedKeyType, parseDERKey: (data, type) => parseDER(data, type, '', type), parseKey, From 85be0dcd1269dc0acb1271e7499ebe3efc352f99 Mon Sep 17 00:00:00 2001 From: Danik Date: Wed, 2 Sep 2026 17:49:22 +0300 Subject: [PATCH 2/4] protocol: convert certificate signatures with the plain key algorithm 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. --- lib/protocol/Protocol.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/protocol/Protocol.js b/lib/protocol/Protocol.js index 79c0ce1c..376ceebe 100644 --- a/lib/protocol/Protocol.js +++ b/lib/protocol/Protocol.js @@ -648,6 +648,15 @@ class Protocol { const userLen = Buffer.byteLength(username); const algoLen = Buffer.byteLength(keyAlgo); + // OpenSSH certificates: the request's algorithm field names the certificate + // ("ssh-ed25519-cert-v01@openssh.com"), but the signature blob must name + // the plain algorithm the key actually signs with ("ssh-ed25519"). For + // plain keys the two are identical and nothing below changes. + const certSuffix = '-cert-v01@openssh.com'; + const sigAlgo = keyAlgo.endsWith(certSuffix) + ? keyAlgo.slice(0, -certSuffix.length) + : keyAlgo; + const sigAlgoLen = Buffer.byteLength(sigAlgo); const pubKeyLen = pubKey.length; const sessionID = this._kex.sessionID; const sesLen = sessionID.length; @@ -697,19 +706,10 @@ class Protocol { } cbSign(packet, (signature) => { - signature = convertSignature(signature, keyType); + signature = convertSignature(signature, sigAlgo); if (signature === false) throw new Error('Error while converting handshake signature'); - // For certificate key types the signature algorithm must be the - // underlying key algorithm (e.g. "ssh-ed25519"), not the certificate - // type name (e.g. "ssh-ed25519-cert-v01@openssh.com"). - const certSuffix = '-cert-v01@openssh.com'; - const sigAlgo = keyType.endsWith(certSuffix) - ? keyType.slice(0, -certSuffix.length) - : keyAlgo; - const sigAlgoLen = Buffer.byteLength(sigAlgo); - const sigLen = signature.length; p = this._packetRW.write.allocStart; packet = this._packetRW.write.alloc( From b6ab4be761e0adff7eb06fced84fcad45be1f697 Mon Sep 17 00:00:00 2001 From: Danik Date: Wed, 2 Sep 2026 17:49:22 +0300 Subject: [PATCH 3/4] keyParser: do not leak the parser cursor on certificate blobs 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. --- lib/protocol/keyParser.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/protocol/keyParser.js b/lib/protocol/keyParser.js index d20fcad4..90746625 100644 --- a/lib/protocol/keyParser.js +++ b/lib/protocol/keyParser.js @@ -1226,9 +1226,13 @@ Cert_Public.prototype = BaseKey; return new Error('Malformed OpenSSH public key'); // Certificate: preserve the full blob so getPublicSSH() returns it intact - // for use in SSH_MSG_USERAUTH_REQUEST publickey auth. - if (fullType !== baseType) + // for use in SSH_MSG_USERAUTH_REQUEST publickey auth. The type sniff above + // left the parser's `_pos` cursor on `data`; it must not escape with the + // blob, where it would make two identical certificates compare unequal. + if (fullType !== baseType) { + delete data._pos; return new Cert_Public(fullType, comment, data); + } return parseDER(data, baseType, comment, fullType); }; @@ -1478,7 +1482,7 @@ function parseKey(data, passphrase) { binaryKeyParser.init(origBuffer, 0); const type = binaryKeyParser.readString(true); if (type !== undefined) { - // Certificate blob: preserve it whole rather than trying to parse internals + // Certificate blob: preserve it whole rather than parsing its internals if (type.indexOf('-cert-v0') !== -1) { ret = new Cert_Public(type, '', origBuffer); } else { From ecd28689474ebc4458e813bf21b2f3e91ef51a80 Mon Sep 17 00:00:00 2001 From: Danik Date: Wed, 2 Sep 2026 17:49:22 +0300 Subject: [PATCH 4/4] test: cover certificate authentication for ed25519, RSA and ECDSA 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. --- lib/client.js | 2 +- test/fixtures/id_ecdsa_cert_key | 9 ++ test/fixtures/id_ecdsa_cert_key-cert.pub | 1 + test/fixtures/id_ecdsa_cert_key.pub | 1 + test/fixtures/id_ed25519_cert_key | 7 ++ test/fixtures/id_ed25519_cert_key-cert.pub | 1 + test/fixtures/id_ed25519_cert_key.pub | 1 + test/fixtures/id_rsa_cert_key | 27 +++++ test/fixtures/id_rsa_cert_key-cert.pub | 1 + test/fixtures/id_rsa_cert_key.pub | 1 + test/fixtures/ssh_user_ca | 7 ++ test/fixtures/ssh_user_ca.pub | 1 + test/test-userauth-cert.js | 122 +++++++++++++++++++++ 13 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/id_ecdsa_cert_key create mode 100644 test/fixtures/id_ecdsa_cert_key-cert.pub create mode 100644 test/fixtures/id_ecdsa_cert_key.pub create mode 100644 test/fixtures/id_ed25519_cert_key create mode 100644 test/fixtures/id_ed25519_cert_key-cert.pub create mode 100644 test/fixtures/id_ed25519_cert_key.pub create mode 100644 test/fixtures/id_rsa_cert_key create mode 100644 test/fixtures/id_rsa_cert_key-cert.pub create mode 100644 test/fixtures/id_rsa_cert_key.pub create mode 100644 test/fixtures/ssh_user_ca create mode 100644 test/fixtures/ssh_user_ca.pub create mode 100644 test/test-userauth-cert.js diff --git a/lib/client.js b/lib/client.js index 4b80e401..80a542ce 100644 --- a/lib/client.js +++ b/lib/client.js @@ -262,7 +262,7 @@ class Client extends EventEmitter { this._agent = (this.config.agent ? this.config.agent : undefined); this._remoteVer = undefined; let privateKey; - let authKey; // key object used for publickey auth (may be a certificate) + let authKey; // Key object used for publickey auth (may be a certificate) if (this.config.privateKey) { privateKey = parseKey(this.config.privateKey, cfg.passphrase); diff --git a/test/fixtures/id_ecdsa_cert_key b/test/fixtures/id_ecdsa_cert_key new file mode 100644 index 00000000..cd91098e --- /dev/null +++ b/test/fixtures/id_ecdsa_cert_key @@ -0,0 +1,9 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS +1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQRmVDua5cBz9KzJqMt7Iur/wiJcvxGE +OPSz26h0HA/QrZL58SnuNIPwNuNkRe3gD6OvVNIgY+0ugEoOtA2td1wZAAAAsEcVnTVHFZ +01AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGZUO5rlwHP0rMmo +y3si6v/CIly/EYQ49LPbqHQcD9CtkvnxKe40g/A242RF7eAPo69U0iBj7S6ASg60Da13XB +kAAAAgVNtyZE0ka1Uca8Doi9MK+zmnWLgrfVFWi0YMuHW8pSUAAAAWc3NoMiBjZXJ0IHRl +c3QgKGVjZHNhKQEC +-----END OPENSSH PRIVATE KEY----- diff --git a/test/fixtures/id_ecdsa_cert_key-cert.pub b/test/fixtures/id_ecdsa_cert_key-cert.pub new file mode 100644 index 00000000..3619917f --- /dev/null +++ b/test/fixtures/id_ecdsa_cert_key-cert.pub @@ -0,0 +1 @@ +ecdsa-sha2-nistp256-cert-v01@openssh.com AAAAKGVjZHNhLXNoYTItbmlzdHAyNTYtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgTak0HL5RJH9zw9YlAF1gSZcxg1n4REuqJdW6pkkmVg0AAAAIbmlzdHAyNTYAAABBBGZUO5rlwHP0rMmoy3si6v/CIly/EYQ49LPbqHQcD9CtkvnxKe40g/A242RF7eAPo69U0iBj7S6ASg60Da13XBkAAAAAAAAAAAAAAAEAAAAPc3NoMi10ZXN0LWVjZHNhAAAADQAAAAlDZXJ0IFVzZXIAAAAAXgu20AAAAAGwne7QAAAAAAAAAIIAAAAVcGVybWl0LVgxMS1mb3J3YXJkaW5nAAAAAAAAABdwZXJtaXQtYWdlbnQtZm9yd2FyZGluZwAAAAAAAAAWcGVybWl0LXBvcnQtZm9yd2FyZGluZwAAAAAAAAAKcGVybWl0LXB0eQAAAAAAAAAOcGVybWl0LXVzZXItcmMAAAAAAAAAAAAAADMAAAALc3NoLWVkMjU1MTkAAAAg8pFFhI+jq1LhsqtKalxN4zigUFvWZFVIO5rW6WF+q80AAABTAAAAC3NzaC1lZDI1NTE5AAAAQPays/Qg1YOopt5YdWxA6vbkdxsjqzcGI+I0o421mvteSo2KiBmBCqFFMGaIoJz84AbsqSXEA4ceIw9RqB4+TwQ= ssh2 cert test (ecdsa) diff --git a/test/fixtures/id_ecdsa_cert_key.pub b/test/fixtures/id_ecdsa_cert_key.pub new file mode 100644 index 00000000..441ff8eb --- /dev/null +++ b/test/fixtures/id_ecdsa_cert_key.pub @@ -0,0 +1 @@ +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGZUO5rlwHP0rMmoy3si6v/CIly/EYQ49LPbqHQcD9CtkvnxKe40g/A242RF7eAPo69U0iBj7S6ASg60Da13XBk= ssh2 cert test (ecdsa) diff --git a/test/fixtures/id_ed25519_cert_key b/test/fixtures/id_ed25519_cert_key new file mode 100644 index 00000000..86c0a904 --- /dev/null +++ b/test/fixtures/id_ed25519_cert_key @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACDi3+ww7F3/p3bj95kkS5a3l0bLSvqLwBusCn4ZCs1jxgAAAKCREaswkRGr +MAAAAAtzc2gtZWQyNTUxOQAAACDi3+ww7F3/p3bj95kkS5a3l0bLSvqLwBusCn4ZCs1jxg +AAAEAbGT1n6QEvc47JS9PWzdz11zGRKyiB8cDVivKsr+bz7uLf7DDsXf+nduP3mSRLlreX +RstK+ovAG6wKfhkKzWPGAAAAGHNzaDIgY2VydCB0ZXN0IChlZDI1NTE5KQECAwQF +-----END OPENSSH PRIVATE KEY----- diff --git a/test/fixtures/id_ed25519_cert_key-cert.pub b/test/fixtures/id_ed25519_cert_key-cert.pub new file mode 100644 index 00000000..0fa90505 --- /dev/null +++ b/test/fixtures/id_ed25519_cert_key-cert.pub @@ -0,0 +1 @@ +ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAILi3YOlmrVL3wgZpeVUeLdd7jxRkM7vGbxgyl0xtiiiKAAAAIOLf7DDsXf+nduP3mSRLlreXRstK+ovAG6wKfhkKzWPGAAAAAAAAAAAAAAABAAAAEXNzaDItdGVzdC1lZDI1NTE5AAAADQAAAAlDZXJ0IFVzZXIAAAAAXgu20AAAAAGwne7QAAAAAAAAAIIAAAAVcGVybWl0LVgxMS1mb3J3YXJkaW5nAAAAAAAAABdwZXJtaXQtYWdlbnQtZm9yd2FyZGluZwAAAAAAAAAWcGVybWl0LXBvcnQtZm9yd2FyZGluZwAAAAAAAAAKcGVybWl0LXB0eQAAAAAAAAAOcGVybWl0LXVzZXItcmMAAAAAAAAAAAAAADMAAAALc3NoLWVkMjU1MTkAAAAg8pFFhI+jq1LhsqtKalxN4zigUFvWZFVIO5rW6WF+q80AAABTAAAAC3NzaC1lZDI1NTE5AAAAQCss4EFFBJrt9gkvhsRKlt3B96jkNymbyM5QBmVPK7CAukvFx+R+Bx+HIg2Cu1/3kJy9lhixISAkIzn+3MEStwE= ssh2 cert test (ed25519) diff --git a/test/fixtures/id_ed25519_cert_key.pub b/test/fixtures/id_ed25519_cert_key.pub new file mode 100644 index 00000000..1dd2deba --- /dev/null +++ b/test/fixtures/id_ed25519_cert_key.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOLf7DDsXf+nduP3mSRLlreXRstK+ovAG6wKfhkKzWPG ssh2 cert test (ed25519) diff --git a/test/fixtures/id_rsa_cert_key b/test/fixtures/id_rsa_cert_key new file mode 100644 index 00000000..8a790584 --- /dev/null +++ b/test/fixtures/id_rsa_cert_key @@ -0,0 +1,27 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn +NhAAAAAwEAAQAAAQEA2ee/j51zvZRGtIqTz4giqpdfc0mhOIuE3mvEYR4XOCxCBPUdN3Pn +WNpkhlw26uLdqkOP96eRHn5Vv4roj33soHIT5fJ2sJ0s7NmiIpkRBhDe1cmafm4vFZidex +/9Xsyba4JLp+IeyC2GA/3qLn5DoFnJInDgy+jvY6nbCGyaF9ZFSaaNaF9+TLN1qyP51svf +rTh5umYM9AVT6JqERsOQGadW/fHwaBauUJ2Lnos8r9/v81ZDOH8KJW4dLqWUTvE22AcAGf +A6LuMUBmM01FzovYQ01N158nizCzS1JyBH24vN98pZe/ft0npbmXw86g0OCy9ZUTmybYd7 +uRxmJrXrGQAAA9D/VUcP/1VHDwAAAAdzc2gtcnNhAAABAQDZ57+PnXO9lEa0ipPPiCKql1 +9zSaE4i4Tea8RhHhc4LEIE9R03c+dY2mSGXDbq4t2qQ4/3p5EeflW/iuiPfeygchPl8naw +nSzs2aIimREGEN7VyZp+bi8VmJ17H/1ezJtrgkun4h7ILYYD/eoufkOgWckicODL6O9jqd +sIbJoX1kVJpo1oX35Ms3WrI/nWy9+tOHm6Zgz0BVPomoRGw5AZp1b98fBoFq5QnYueizyv +3+/zVkM4fwolbh0upZRO8TbYBwAZ8Dou4xQGYzTUXOi9hDTU3XnyeLMLNLUnIEfbi833yl +l79+3SeluZfDzqDQ4LL1lRObJth3u5HGYmtesZAAAAAwEAAQAAAQEAucGxr4AN9oK8c5Pe +xX/L7Zj1KZaO9WEad3FvC0tXh+9SqF879NW9ViV2pINf3YRgapEF3ZzfPRt9hbeo4Qn7+h +rFk7TKMW0Lqy0r5kqOFJm1HJKsPTM4uDRNq3Rtza6mn1OHoypEC2mjYSvHwrKoe72OlOmc ++Lctu8xEiD3sbnwK1cTKw/nFuIuGRmhxh4W1wrjA4cHNH8AmWPuPjCZXNbzi3BEt2RRX1k +hMsWq+JfPlEGiPJKkqVRLDJkG/XABcpnCjgeMieqJJwcjNSL0PBvStjdJ8OlYPUJPA+BAa +d/ZB1nKJ1ARf7dh5VqS20nrvxOitcECDDJFkgfpwE8lwwQAAAIEA2V59XkQO/iuuLexvyU +PABCFIShx0QM0PF1GRABYeikXxrtaPSiYkf7iEDsAd45Ah/X6iFYOwxBHLGW3PNeLWuKmW +le58f1reH468jB659oPMIwpanzo9pw/gywTtx0QJUQAc4BWGYj/8XQSax2+yuCWGSeGTMu +DnRRljTd5nr+AAAACBAP9FfcJ/iNvDoNTUqOZr1LM/3DwsZsK2NFB046uXEmUtkVMOXW+F +eWbIiFf+lV1OvFdYgD1TXoGOiUyREtwQv0W7KNH9qR2MA43S1YLBVxvfaBsrQd2dOCInWD +dGVJY1cKRT7aPSOurXI8GksT67KJbPetVKJYy2gupveWkgSwVtAAAAgQDahvTKgLWZMgXl +JA37f65BVqhYC/pYqMKGJnxbPpgPWuunjcIrm9Yc+mZP0z9RjA5WzXitB+VH4Mp+WcU1Xd +BRrMyJ7UCDA3HXmYrNzITWzURPwg72p3ca8yIwgzi9SZQR/hf77KWpLaGIo7JHX83kfhvX +6jOjvVbcBHyLEGGs3QAAABRzc2gyIGNlcnQgdGVzdCAocnNhKQECAwQF +-----END OPENSSH PRIVATE KEY----- diff --git a/test/fixtures/id_rsa_cert_key-cert.pub b/test/fixtures/id_rsa_cert_key-cert.pub new file mode 100644 index 00000000..5339ba32 --- /dev/null +++ b/test/fixtures/id_rsa_cert_key-cert.pub @@ -0,0 +1 @@ +ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgoRYzrqRIH7VnxQSKjgCnQ0cBR7NlIPO88eOqY8x/8bMAAAADAQABAAABAQDZ57+PnXO9lEa0ipPPiCKql19zSaE4i4Tea8RhHhc4LEIE9R03c+dY2mSGXDbq4t2qQ4/3p5EeflW/iuiPfeygchPl8nawnSzs2aIimREGEN7VyZp+bi8VmJ17H/1ezJtrgkun4h7ILYYD/eoufkOgWckicODL6O9jqdsIbJoX1kVJpo1oX35Ms3WrI/nWy9+tOHm6Zgz0BVPomoRGw5AZp1b98fBoFq5QnYueizyv3+/zVkM4fwolbh0upZRO8TbYBwAZ8Dou4xQGYzTUXOi9hDTU3XnyeLMLNLUnIEfbi833yll79+3SeluZfDzqDQ4LL1lRObJth3u5HGYmtesZAAAAAAAAAAAAAAABAAAADXNzaDItdGVzdC1yc2EAAAANAAAACUNlcnQgVXNlcgAAAABeC7bQAAAAAbCd7tAAAAAAAAAAggAAABVwZXJtaXQtWDExLWZvcndhcmRpbmcAAAAAAAAAF3Blcm1pdC1hZ2VudC1mb3J3YXJkaW5nAAAAAAAAABZwZXJtaXQtcG9ydC1mb3J3YXJkaW5nAAAAAAAAAApwZXJtaXQtcHR5AAAAAAAAAA5wZXJtaXQtdXNlci1yYwAAAAAAAAAAAAAAMwAAAAtzc2gtZWQyNTUxOQAAACDykUWEj6OrUuGyq0pqXE3jOKBQW9ZkVUg7mtbpYX6rzQAAAFMAAAALc3NoLWVkMjU1MTkAAABAbL5jRF146WtpsOR7wN6NM3N7IqUqOLRI8MfTFeeVm2YwGFa4nnCUqO8Qi84geZYD6vhIS43Wct+ltAr5tYsqDQ== ssh2 cert test (rsa) diff --git a/test/fixtures/id_rsa_cert_key.pub b/test/fixtures/id_rsa_cert_key.pub new file mode 100644 index 00000000..50c33f88 --- /dev/null +++ b/test/fixtures/id_rsa_cert_key.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDZ57+PnXO9lEa0ipPPiCKql19zSaE4i4Tea8RhHhc4LEIE9R03c+dY2mSGXDbq4t2qQ4/3p5EeflW/iuiPfeygchPl8nawnSzs2aIimREGEN7VyZp+bi8VmJ17H/1ezJtrgkun4h7ILYYD/eoufkOgWckicODL6O9jqdsIbJoX1kVJpo1oX35Ms3WrI/nWy9+tOHm6Zgz0BVPomoRGw5AZp1b98fBoFq5QnYueizyv3+/zVkM4fwolbh0upZRO8TbYBwAZ8Dou4xQGYzTUXOi9hDTU3XnyeLMLNLUnIEfbi833yll79+3SeluZfDzqDQ4LL1lRObJth3u5HGYmtesZ ssh2 cert test (rsa) diff --git a/test/fixtures/ssh_user_ca b/test/fixtures/ssh_user_ca new file mode 100644 index 00000000..40d39d2c --- /dev/null +++ b/test/fixtures/ssh_user_ca @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACDykUWEj6OrUuGyq0pqXE3jOKBQW9ZkVUg7mtbpYX6rzQAAAJCLILSGiyC0 +hgAAAAtzc2gtZWQyNTUxOQAAACDykUWEj6OrUuGyq0pqXE3jOKBQW9ZkVUg7mtbpYX6rzQ +AAAECxVlsIUGiJCdmvEtE2z2+cPi/ChWjf8gSfOHcCm8JYU/KRRYSPo6tS4bKrSmpcTeM4 +oFBb1mRVSDua1ulhfqvNAAAADHNzaDIgdGVzdCBDQQE= +-----END OPENSSH PRIVATE KEY----- diff --git a/test/fixtures/ssh_user_ca.pub b/test/fixtures/ssh_user_ca.pub new file mode 100644 index 00000000..86a5e76f --- /dev/null +++ b/test/fixtures/ssh_user_ca.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPKRRYSPo6tS4bKrSmpcTeM4oFBb1mRVSDua1ulhfqvN ssh2 test CA diff --git a/test/test-userauth-cert.js b/test/test-userauth-cert.js new file mode 100644 index 00000000..7ed2d8f5 --- /dev/null +++ b/test/test-userauth-cert.js @@ -0,0 +1,122 @@ +'use strict'; + +const assert = require('assert'); + +const { sigSSHToASN1 } = require('../lib/protocol/utils.js'); + +const { + fixtureKey, + mustCall, + setup, +} = require('./common.js'); + +// Fixtures were generated with: +// ssh-keygen -t ed25519 -N '' -f ssh_user_ca +// ssh-keygen -t -N '' -f id__cert_key +// ssh-keygen -s ssh_user_ca -I ssh2-test- -n 'Cert User' \ +// -V 20200101:22000101 id__cert_key.pub +// The far-future validity keeps them from expiring under the test suite. + +const serverCfg = { hostKeys: [ fixtureKey('ssh_host_rsa_key').raw ] }; + +const debug = false; + +const CERT_SUFFIX = '-cert-v01@openssh.com'; + +// An SSH signature blob is `string algorithm, string signature`. +function parseSignatureBlob(blob) { + const algoLen = blob.readUInt32BE(0); + const algo = blob.utf8Slice(4, 4 + algoLen); + const sigLen = blob.readUInt32BE(4 + algoLen); + const signature = blob.slice(8 + algoLen, 8 + algoLen + sigLen); + return { algo, signature }; +} + +// Certificates ================================================================ +// +// A publickey request names the algorithm twice: the request's own algorithm +// field says what is offered (the certificate type), while the signature blob +// says what actually signed (the plain key type). These tests check both names +// independently, and that the signature verifies against the plain key. +[ + { desc: 'ed25519 certificate', + keyFile: 'id_ed25519_cert_key', + certFile: 'id_ed25519_cert_key-cert.pub' }, + { desc: 'RSA certificate', + keyFile: 'id_rsa_cert_key', + certFile: 'id_rsa_cert_key-cert.pub' }, + { desc: 'ECDSA certificate', + keyFile: 'id_ecdsa_cert_key', + certFile: 'id_ecdsa_cert_key-cert.pub' }, +].forEach((test) => { + const { desc, keyFile, certFile } = test; + const clientKey = fixtureKey(keyFile); + const clientCert = fixtureKey(certFile); + const plainType = clientKey.key.type; + const certType = clientCert.key.type; + assert(certType === `${plainType}${CERT_SUFFIX}`, + `Fixture mismatch: ${certType} is not a certificate for ${plainType}`); + + const username = 'Cert User'; + const { server } = setup( + desc, + { + client: { + username, + privateKey: clientKey.raw, + publicKey: clientCert.raw, + }, + server: serverCfg, + + debug, + } + ); + + server.on('connection', mustCall((conn) => { + let authAttempt = 0; + conn.on('authentication', mustCall((ctx) => { + assert(ctx.username === username, + `Wrong username: ${ctx.username}`); + switch (++authAttempt) { + case 1: + assert(ctx.method === 'none', `Wrong auth method: ${ctx.method}`); + return ctx.reject(); + case 2: + assert(ctx.method === 'publickey', + `Wrong auth method: ${ctx.method}`); + assert(!ctx.signature, 'Unexpected signature on the check request'); + assert(ctx.key.algo === certType, + `Wrong key algo: ${ctx.key.algo}`); + assert.deepStrictEqual(ctx.key.data, + clientCert.key.getPublicSSH(), + 'Certificate blob mismatch'); + break; + case 3: { + assert(ctx.method === 'publickey', + `Wrong auth method: ${ctx.method}`); + assert(ctx.signature, 'Missing publickey signature'); + assert(ctx.key.algo === certType, + `Wrong key algo: ${ctx.key.algo}`); + + // The server only unwraps the signature blob when its algorithm + // matches the request's, which a certificate's never does — so the + // blob arrives intact and the inner name can be checked directly. + const { algo, signature } = parseSignatureBlob(ctx.signature); + assert(algo === plainType, + `Signature blob names ${algo}, expected ${plainType}`); + + // What signed is the plain key, so the signature verifies against it. + const verifiable = sigSSHToASN1(signature, plainType); + assert(verifiable, 'Malformed signature for the plain key type'); + const result = + clientKey.key.verify(ctx.blob, verifiable, ctx.hashAlgo); + assert(result === true, 'Could not verify certificate signature'); + break; + } + } + ctx.accept(); + }, 3)).on('ready', mustCall(() => { + conn.end(); + })); + })); +});