From 79591aabc4bc85c0dc3700d734117538c0f032d2 Mon Sep 17 00:00:00 2001 From: MathiasDPX Date: Fri, 15 May 2026 23:36:03 +0200 Subject: [PATCH 1/3] feat: add support for security keys --- lib/protocol/constants.js | 5 + lib/protocol/handlers.misc.js | 22 ++- lib/protocol/keyParser.js | 294 +++++++++++++++++++++++++++++++++- 3 files changed, 313 insertions(+), 8 deletions(-) diff --git a/lib/protocol/constants.js b/lib/protocol/constants.js index ad775925..d119bf45 100644 --- a/lib/protocol/constants.js +++ b/lib/protocol/constants.js @@ -73,7 +73,12 @@ if (eddsaSupported) DEFAULT_SERVER_HOST_KEY.unshift('ssh-ed25519'); const SUPPORTED_SERVER_HOST_KEY = DEFAULT_SERVER_HOST_KEY.concat([ 'ssh-dss', + // FIDO/U2F security keys (e.g. YubiKey). Not enabled by default for + // host keys (rare in practice) but recognized for client publickey auth. + 'sk-ecdsa-sha2-nistp256@openssh.com', ]); +if (eddsaSupported) + SUPPORTED_SERVER_HOST_KEY.push('sk-ssh-ed25519@openssh.com'); const canUseCipher = (() => { diff --git a/lib/protocol/handlers.misc.js b/lib/protocol/handlers.misc.js index 24580bec..b10fe6d2 100644 --- a/lib/protocol/handlers.misc.js +++ b/lib/protocol/handlers.misc.js @@ -258,8 +258,15 @@ module.exports = { if (signature !== undefined) { if (signature.length > (4 + keyAlgo.length + 4) && signature.utf8Slice(4, 4 + keyAlgo.length) === keyAlgo) { - // Skip algoLen + algo + sigLen - signature = bufferSlice(signature, 4 + keyAlgo.length + 4); + if (keyAlgo.indexOf('sk-') === 0) { + // FIDO/U2F (SK) signatures: leave the sigblob + // (string raw_sig | byte flags | uint32 counter) intact + // for the SK key's verify() to parse. + signature = bufferSlice(signature, 4 + keyAlgo.length); + } else { + // Skip algoLen + algo + sigLen + signature = bufferSlice(signature, 4 + keyAlgo.length + 4); + } } signature = sigSSHToASN1(signature, realKeyAlgo); @@ -320,8 +327,15 @@ module.exports = { if (signature !== undefined) { if (signature.length > (4 + keyAlgo.length + 4) && signature.utf8Slice(4, 4 + keyAlgo.length) === keyAlgo) { - // Skip algoLen + algo + sigLen - signature = bufferSlice(signature, 4 + keyAlgo.length + 4); + if (keyAlgo.indexOf('sk-') === 0) { + // FIDO/U2F (SK) signatures: leave the sigblob + // (string raw_sig | byte flags | uint32 counter) intact + // for the SK key's verify() to parse. + signature = bufferSlice(signature, 4 + keyAlgo.length); + } else { + // Skip algoLen + algo + sigLen + signature = bufferSlice(signature, 4 + keyAlgo.length + 4); + } } signature = sigSSHToASN1(signature, realKeyAlgo); diff --git a/lib/protocol/keyParser.js b/lib/protocol/keyParser.js index a276c1ae..99132837 100644 --- a/lib/protocol/keyParser.js +++ b/lib/protocol/keyParser.js @@ -35,6 +35,9 @@ const SYM_PRIV_PEM = Symbol('Private key PEM'); const SYM_PUB_PEM = Symbol('Public key PEM'); const SYM_PUB_SSH = Symbol('Public key SSH'); const SYM_DECRYPTED = Symbol('Decrypted Key'); +const SYM_SK_APP = Symbol('SK Application'); +const SYM_SK_HANDLE = Symbol('SK Key Handle'); +const SYM_SK_FLAGS = Symbol('SK Flags'); // Create OpenSSL cipher name -> SSH cipher name conversion table const CIPHER_INFO_OPENSSL = Object.create(null); @@ -258,6 +261,55 @@ function genOpenSSHEdPub(pub) { return publicKey; } +// FIDO/U2F (security key) SSH wire-format public keys. +// See OpenSSH's PROTOCOL.u2f. +function genOpenSSHSKEdPub(pub, application) { + const typeName = 'sk-ssh-ed25519@openssh.com'; + const total = + 4 + typeName.length + 4 + pub.length + 4 + application.length; + const publicKey = Buffer.allocUnsafe(total); + + let i = 0; + writeUInt32BE(publicKey, typeName.length, i); + publicKey.utf8Write(typeName, i += 4, typeName.length); + i += typeName.length; + + writeUInt32BE(publicKey, pub.length, i); + publicKey.set(pub, i += 4); + i += pub.length; + + writeUInt32BE(publicKey, application.length, i); + publicKey.utf8Write(application, i += 4, application.length); + + return publicKey; +} + +function genOpenSSHSKECDSAPub(Q, application) { + const typeName = 'sk-ecdsa-sha2-nistp256@openssh.com'; + const curveName = 'nistp256'; + const total = 4 + typeName.length + 4 + curveName.length + 4 + Q.length + + 4 + application.length; + const publicKey = Buffer.allocUnsafe(total); + + let i = 0; + writeUInt32BE(publicKey, typeName.length, i); + publicKey.utf8Write(typeName, i += 4, typeName.length); + i += typeName.length; + + writeUInt32BE(publicKey, curveName.length, i); + publicKey.utf8Write(curveName, i += 4, curveName.length); + i += curveName.length; + + writeUInt32BE(publicKey, Q.length, i); + publicKey.set(Q, i += 4); + i += Q.length; + + writeUInt32BE(publicKey, application.length, i); + publicKey.utf8Write(application, i += 4, application.length); + + return publicKey; +} + function genOpenSSLEdPriv(priv) { const asnWriter = new Ber.Writer(); asnWriter.startSequence(); @@ -451,6 +503,82 @@ const BaseKey = { }, }; +// Base for FIDO/U2F security key public keys (sk-ssh-ed25519@openssh.com, +// sk-ecdsa-sha2-nistp256@openssh.com). +// +// Signing requires the actual hardware token (typically performed by +// ssh-agent talking to libfido2), so sign() is not implemented locally. +// +// Verifying takes the original message ("data"); the SK signature blob +// includes the device flags and counter that must be folded into the +// signed payload as defined by OpenSSH PROTOCOL.u2f: +// +// sha256(application) || flags || counter || sha256(message) +const SKBaseKey = { + ...BaseKey, + sign: function sign() { + return new Error( + 'Cannot sign locally with a security-key (SK) public key; ' + + 'signing must be performed by the FIDO/U2F hardware token ' + + '(e.g. via ssh-agent)' + ); + }, + verify: function verify(data, signature, algo) { + /* + The SK signature blob is: + string raw_signature + - sk-ssh-ed25519: 64 raw bytes + - sk-ecdsa-sha2-nistp256: SSH ECDSA blob (string r, string s) + byte flags + uint32 counter + */ + const sigParser = makeBufferParser(); + sigParser.init(signature, 0); + const rawSig = sigParser.readString(); + if (rawSig === undefined) { + sigParser.clear(); + return new Error('Malformed SK signature'); + } + const pos = sigParser.pos(); + sigParser.clear(); + if (pos + 5 > signature.length) + return new Error('Malformed SK signature'); + const flags = signature[pos]; + const counter = readUInt32BE(signature, pos + 1); + + const appHash = createHash('sha256').update(this[SYM_SK_APP]).digest(); + const dataHash = createHash('sha256').update(data).digest(); + + const signedData = Buffer.allocUnsafe( + appHash.length + 1 + 4 + dataHash.length + ); + let p = 0; + signedData.set(appHash, p); + p += appHash.length; + signedData[p++] = flags; + writeUInt32BE(signedData, counter, p); + p += 4; + signedData.set(dataHash, p); + + let toVerify = rawSig; + if (this.type === 'sk-ecdsa-sha2-nistp256@openssh.com') { + // Convert SSH ECDSA r,s blob to ASN.1 for OpenSSL + const { sigSSHToASN1 } = require('./utils.js'); + toVerify = sigSSHToASN1(rawSig, 'ecdsa-sha2-nistp256'); + if (!toVerify) + return new Error('Malformed SK ECDSA signature'); + } + + return BaseKey.verify.call(this, signedData, toVerify, algo); + }, + getApplication: function getApplication() { + return this[SYM_SK_APP]; + }, + getKeyHandle: function getKeyHandle() { + return this[SYM_SK_HANDLE]; + }, +}; + function OpenSSH_Private(type, comment, privPEM, pubPEM, pubSSH, algo, decrypted) { @@ -463,6 +591,26 @@ function OpenSSH_Private(type, comment, privPEM, pubPEM, pubSSH, algo, this[SYM_DECRYPTED] = decrypted; } OpenSSH_Private.prototype = BaseKey; + +// FIDO/U2F security key OpenSSH private-key entry. The "private" portion is +// just the device key handle (used by the host to talk to the FIDO token); +// the actual private key never leaves the hardware. Local sign() therefore +// returns an error -- signing has to be performed by ssh-agent (or another +// component talking to the device). +function OpenSSH_SK_Private(type, comment, pubPEM, pubSSH, algo, decrypted, + application, keyHandle, flags) { + this.type = type; + this.comment = comment; + this[SYM_PRIV_PEM] = null; + this[SYM_PUB_PEM] = pubPEM; + this[SYM_PUB_SSH] = pubSSH; + this[SYM_HASH_ALGO] = algo; + this[SYM_DECRYPTED] = decrypted; + this[SYM_SK_APP] = application; + this[SYM_SK_HANDLE] = keyHandle; + this[SYM_SK_FLAGS] = flags; +} +OpenSSH_SK_Private.prototype = SKBaseKey; { const regexp = /^-----BEGIN OPENSSH PRIVATE KEY-----(?:\r\n|\n)([\s\S]+)(?:\r\n|\n)-----END OPENSSH PRIVATE KEY-----$/; OpenSSH_Private.parse = (str, passphrase) => { @@ -771,6 +919,87 @@ OpenSSH_Private.prototype = BaseKey; privPEM = genOpenSSLECDSAPriv(oid, ecpub, ecpriv); break; } + case 'sk-ssh-ed25519@openssh.com': { + if (!eddsaSupported) + return new Error(`Unsupported OpenSSH private key type: ${type}`); + /* + string public key + string application + uint8 flags + string key_handle + string reserved + */ + const edpub = readString(data, data._pos); + if (edpub === undefined || edpub.length !== 32) + return new Error('Malformed OpenSSH SK private key'); + const application = readString(data, data._pos, true); + if (application === undefined) + return new Error('Malformed OpenSSH SK private key'); + if (data._pos >= data.length) + return new Error('Malformed OpenSSH SK private key'); + const flagsByte = data[data._pos]; + data._pos += 1; + const keyHandle = readString(data, data._pos); + if (keyHandle === undefined) + return new Error('Malformed OpenSSH SK private key'); + const reserved = readString(data, data._pos); + if (reserved === undefined) + return new Error('Malformed OpenSSH SK private key'); + + pubPEM = genOpenSSLEdPub(edpub); + pubSSH = genOpenSSHSKEdPub(edpub, application); + algo = null; + const privComment = readString(data, data._pos, true); + if (privComment === undefined) + return new Error('Malformed OpenSSH SK private key'); + const skKey = new OpenSSH_SK_Private( + type, privComment, pubPEM, pubSSH, algo, decrypted, + application, keyHandle, flagsByte + ); + keys.push(skKey); + continue; + } + case 'sk-ecdsa-sha2-nistp256@openssh.com': { + /* + string curve name ("nistp256") + string Q -- public + string application + uint8 flags + string key_handle + string reserved + */ + if (!skipFields(data, 1)) // Skip curve name + return new Error('Malformed OpenSSH SK private key'); + const ecpub = readString(data, data._pos); + if (ecpub === undefined) + return new Error('Malformed OpenSSH SK private key'); + const application = readString(data, data._pos, true); + if (application === undefined) + return new Error('Malformed OpenSSH SK private key'); + if (data._pos >= data.length) + return new Error('Malformed OpenSSH SK private key'); + const flagsByte = data[data._pos]; + data._pos += 1; + const keyHandle = readString(data, data._pos); + if (keyHandle === undefined) + return new Error('Malformed OpenSSH SK private key'); + const reserved = readString(data, data._pos); + if (reserved === undefined) + return new Error('Malformed OpenSSH SK private key'); + + pubPEM = genOpenSSLECDSAPub('1.2.840.10045.3.1.7', ecpub); + pubSSH = genOpenSSHSKECDSAPub(ecpub, application); + algo = 'sha256'; + const privComment = readString(data, data._pos, true); + if (privComment === undefined) + return new Error('Malformed OpenSSH SK private key'); + const skKey = new OpenSSH_SK_Private( + type, privComment, pubPEM, pubSSH, algo, decrypted, + application, keyHandle, flagsByte + ); + keys.push(skKey); + continue; + } default: return new Error(`Unsupported OpenSSH private key type: ${type}`); } @@ -1186,12 +1415,28 @@ function OpenSSH_Public(type, comment, pubPEM, pubSSH, algo) { this[SYM_DECRYPTED] = false; } OpenSSH_Public.prototype = BaseKey; + +// FIDO/U2F security key public key wrapper. See SKBaseKey. +function OpenSSH_SK_Public(type, comment, pubPEM, pubSSH, algo, application) { + this.type = type; + this.comment = comment; + this[SYM_PRIV_PEM] = null; + this[SYM_PUB_PEM] = pubPEM; + this[SYM_PUB_SSH] = pubSSH; + this[SYM_HASH_ALGO] = algo; + this[SYM_DECRYPTED] = false; + this[SYM_SK_APP] = application; + this[SYM_SK_HANDLE] = null; + this[SYM_SK_FLAGS] = 0; +} +OpenSSH_SK_Public.prototype = SKBaseKey; { let regexp; - if (eddsaSupported) - regexp = /^(((?:ssh-(?:rsa|dss|ed25519))|ecdsa-sha2-nistp(?:256|384|521))(?:-cert-v0[01]@openssh.com)?) ([A-Z0-9a-z/+=]+)(?:$|\s+([\S].*)?)$/; - else - regexp = /^(((?:ssh-(?:rsa|dss))|ecdsa-sha2-nistp(?:256|384|521))(?:-cert-v0[01]@openssh.com)?) ([A-Z0-9a-z/+=]+)(?:$|\s+([\S].*)?)$/; + if (eddsaSupported) { + regexp = /^(((?:ssh-(?:rsa|dss|ed25519))|ecdsa-sha2-nistp(?:256|384|521)|sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com)(?:-cert-v0[01]@openssh\.com)?) ([A-Z0-9a-z/+=]+)(?:$|\s+([\S].*)?)$/; + } else { + regexp = /^(((?:ssh-(?:rsa|dss))|ecdsa-sha2-nistp(?:256|384|521)|sk-ecdsa-sha2-nistp256@openssh\.com)(?:-cert-v0[01]@openssh\.com)?) ([A-Z0-9a-z/+=]+)(?:$|\s+([\S].*)?)$/; + } OpenSSH_Public.parse = (str) => { const m = regexp.exec(str); if (m === null) @@ -1383,6 +1628,45 @@ function parseDER(data, baseType, comment, fullType) { pubSSH = genOpenSSHECDSAPub(oid, ecpub); break; } + case 'sk-ssh-ed25519@openssh.com': { + /* + string public key (32 raw bytes) + string application + */ + const edpub = readString(data, data._pos || 0); + if (edpub === undefined || edpub.length !== 32) + return new Error('Malformed OpenSSH SK public key'); + const application = readString(data, data._pos, true); + if (application === undefined) + return new Error('Malformed OpenSSH SK public key'); + pubPEM = genOpenSSLEdPub(edpub); + pubSSH = genOpenSSHSKEdPub(edpub, application); + algo = null; + return new OpenSSH_SK_Public( + fullType, comment, pubPEM, pubSSH, algo, application + ); + } + case 'sk-ecdsa-sha2-nistp256@openssh.com': { + /* + string curve name ("nistp256") + string Q -- public + string application + */ + if (!skipFields(data, 1)) // Skip curve name + return new Error('Malformed OpenSSH SK public key'); + const ecpub = readString(data, data._pos || 0); + if (ecpub === undefined) + return new Error('Malformed OpenSSH SK public key'); + const application = readString(data, data._pos, true); + if (application === undefined) + return new Error('Malformed OpenSSH SK public key'); + pubPEM = genOpenSSLECDSAPub('1.2.840.10045.3.1.7', ecpub); + pubSSH = genOpenSSHSKECDSAPub(ecpub, application); + algo = 'sha256'; + return new OpenSSH_SK_Public( + fullType, comment, pubPEM, pubSSH, algo, application + ); + } default: return new Error(`Unsupported OpenSSH public key type: ${baseType}`); } @@ -1397,8 +1681,10 @@ function isSupportedKeyType(type) { case 'ecdsa-sha2-nistp256': case 'ecdsa-sha2-nistp384': case 'ecdsa-sha2-nistp521': + case 'sk-ecdsa-sha2-nistp256@openssh.com': return true; case 'ssh-ed25519': + case 'sk-ssh-ed25519@openssh.com': if (eddsaSupported) return true; // FALLTHROUGH From d18489ad21514560fba81c86351997f890928f40 Mon Sep 17 00:00:00 2001 From: MathiasDPX Date: Fri, 15 May 2026 23:56:09 +0200 Subject: [PATCH 2/3] fix: ed25519 keygen parsing for Node 22 PKCS#8 output --- .github/workflows/ci.yml | 4 ++++ lib/keygen.js | 14 +++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25de60eb..fe9f5493 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,10 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} + - name: Install Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' - name: Check Node.js version run: node -pe process.versions - name: Check npm version diff --git a/lib/keygen.js b/lib/keygen.js index 570c90ee..39d6f900 100644 --- a/lib/keygen.js +++ b/lib/keygen.js @@ -311,7 +311,19 @@ function parseDERs(keyType, pub, priv) { // - Attributes (absent) reader = new Ber.Reader(reader.readString(Ber.OctetString, true)); - const privBin = reader.readString(Ber.OctetString, true); + let privBin = reader.readString(Ber.OctetString, true); + if (privBin.length !== 32) { + if (privBin.length === 64 + && privBin.subarray(32).equals(pubBin)) { + privBin = privBin.subarray(0, 32); + } else if (privBin.length > 2 + && privBin[0] === 0x04 + && privBin[1] + 2 === privBin.length) { + privBin = privBin.subarray(2); + } + } + if (privBin.length !== 32) + throw new Error('Malformed ED25519 private key'); /* OpenSSH ed25519 private key: From 6c037f38abb5a9b5276b93b7c076aa8b8e69efdc Mon Sep 17 00:00:00 2001 From: MathiasDPX Date: Sat, 16 May 2026 00:14:01 +0200 Subject: [PATCH 3/3] fix: correct SK signature slicing in auth handlers --- lib/protocol/handlers.misc.js | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/lib/protocol/handlers.misc.js b/lib/protocol/handlers.misc.js index b10fe6d2..ebca11c1 100644 --- a/lib/protocol/handlers.misc.js +++ b/lib/protocol/handlers.misc.js @@ -258,15 +258,8 @@ module.exports = { if (signature !== undefined) { if (signature.length > (4 + keyAlgo.length + 4) && signature.utf8Slice(4, 4 + keyAlgo.length) === keyAlgo) { - if (keyAlgo.indexOf('sk-') === 0) { - // FIDO/U2F (SK) signatures: leave the sigblob - // (string raw_sig | byte flags | uint32 counter) intact - // for the SK key's verify() to parse. - signature = bufferSlice(signature, 4 + keyAlgo.length); - } else { - // Skip algoLen + algo + sigLen - signature = bufferSlice(signature, 4 + keyAlgo.length + 4); - } + // Skip algoLen + algo + sigLen (leave the sigblob intact) + signature = bufferSlice(signature, 4 + keyAlgo.length + 4); } signature = sigSSHToASN1(signature, realKeyAlgo); @@ -327,15 +320,8 @@ module.exports = { if (signature !== undefined) { if (signature.length > (4 + keyAlgo.length + 4) && signature.utf8Slice(4, 4 + keyAlgo.length) === keyAlgo) { - if (keyAlgo.indexOf('sk-') === 0) { - // FIDO/U2F (SK) signatures: leave the sigblob - // (string raw_sig | byte flags | uint32 counter) intact - // for the SK key's verify() to parse. - signature = bufferSlice(signature, 4 + keyAlgo.length); - } else { - // Skip algoLen + algo + sigLen - signature = bufferSlice(signature, 4 + keyAlgo.length + 4); - } + // Skip algoLen + algo + sigLen (leave the sigblob intact) + signature = bufferSlice(signature, 4 + keyAlgo.length + 4); } signature = sigSSHToASN1(signature, realKeyAlgo);