From 7f7c930294a571fea788cce4edaa883a99c59c65 Mon Sep 17 00:00:00 2001 From: Lucas Queiroz Date: Wed, 15 Oct 2025 12:38:56 -0300 Subject: [PATCH 01/14] feat: checks if mlkem is supported and lints files --- lib/protocol/constants.js | 67 +++++++++++++++++++++++++-------------- 1 file changed, 44 insertions(+), 23 deletions(-) diff --git a/lib/protocol/constants.js b/lib/protocol/constants.js index ad775925..8f66c94e 100644 --- a/lib/protocol/constants.js +++ b/lib/protocol/constants.js @@ -5,13 +5,13 @@ const crypto = require('crypto'); let cpuInfo; try { cpuInfo = require('cpu-features')(); -} catch {} +} catch { } const { bindingAvailable, CIPHER_INFO, MAC_INFO } = require('./crypto.js'); const eddsaSupported = (() => { if (typeof crypto.sign === 'function' - && typeof crypto.verify === 'function') { + && typeof crypto.verify === 'function') { const key = '-----BEGIN PRIVATE KEY-----\r\nMC4CAQAwBQYDK2VwBCIEIHKj+sVa9WcD' + '/q2DJUJaf43Kptc8xYuUQA4bOFj9vC8T\r\n-----END PRIVATE KEY-----'; @@ -21,7 +21,7 @@ const eddsaSupported = (() => { try { sig = crypto.sign(null, data, key); verified = crypto.verify(null, data, key, sig); - } catch {} + } catch { } return (Buffer.isBuffer(sig) && sig.length === 64 && verified === true); } @@ -29,8 +29,24 @@ const eddsaSupported = (() => { })(); const curve25519Supported = (typeof crypto.diffieHellman === 'function' - && typeof crypto.generateKeyPairSync === 'function' - && typeof crypto.createPublicKey === 'function'); + && typeof crypto.generateKeyPairSync === 'function' + && typeof crypto.createPublicKey === 'function'); + +const mlkemSupported = (() => { + try { + if (!crypto.subtle || typeof crypto.subtle.generateKey !== 'function') + return false; + + const alg = { name: 'ml-kem-768' }; + const test = crypto.subtle.generateKey(alg, true, [ + 'encapsulateKey', + 'decapsulateKey' + ]); + return test && typeof test.then === 'function'; + } catch { + return false; + } +})(); const DEFAULT_KEX = [ // https://tools.ietf.org/html/rfc5656#section-10.1 @@ -52,6 +68,11 @@ if (curve25519Supported) { DEFAULT_KEX.unshift('curve25519-sha256'); DEFAULT_KEX.unshift('curve25519-sha256@libssh.org'); } + +if (mlkemSupported && curve25519Supported) + DEFAULT_KEX.unshift('mlkem768x25519-sha256'); + + const SUPPORTED_KEX = DEFAULT_KEX.concat([ // https://tools.ietf.org/html/rfc4419#section-4 'diffie-hellman-group-exchange-sha1', @@ -146,8 +167,8 @@ const SUPPORTED_MAC = DEFAULT_MAC.concat([ const DEFAULT_COMPRESSION = [ 'none', 'zlib@openssh.com', // ZLIB (LZ77) compression, except - // compression/decompression does not start until after - // successful user authentication + // compression/decompression does not start until after + // successful user authentication 'zlib', // ZLIB (LZ77) compression ]; const SUPPORTED_COMPRESSION = DEFAULT_COMPRESSION.concat([ @@ -252,16 +273,16 @@ module.exports = { TERMINAL_MODE: { TTY_OP_END: 0, // Indicates end of options. VINTR: 1, // Interrupt character; 255 if none. Similarly for the - // other characters. Not all of these characters are - // supported on all systems. + // other characters. Not all of these characters are + // supported on all systems. VQUIT: 2, // The quit character (sends SIGQUIT signal on POSIX - // systems). + // systems). VERASE: 3, // Erase the character to left of the cursor. VKILL: 4, // Kill the current input line. VEOF: 5, // End-of-file character (sends EOF from the - // terminal). + // terminal). VEOL: 6, // End-of-line character in addition to carriage - // return and/or linefeed. + // return and/or linefeed. VEOL2: 7, // Additional end-of-line character. VSTART: 8, // Continues paused output (normally control-Q). VSTOP: 9, // Pauses output (normally control-S). @@ -270,14 +291,14 @@ module.exports = { VREPRINT: 12, // Reprints the current input line. VWERASE: 13, // Erases a word left of cursor. VLNEXT: 14, // Enter the next character typed literally, even if - // it is a special character + // it is a special character VFLUSH: 15, // Character to flush output. VSWTCH: 16, // Switch to a different shell layer. VSTATUS: 17, // Prints system status line (load, command, pid, - // etc). + // etc). VDISCARD: 18, // Toggles the flushing of terminal output. IGNPAR: 30, // The ignore parity flag. The parameter SHOULD be 0 - // if this flag is FALSE, and 1 if it is TRUE. + // if this flag is FALSE, and 1 if it is TRUE. PARMRK: 31, // Mark parity and framing errors. INPCK: 32, // Enable checking of parity errors. ISTRIP: 33, // Strip 8th bit off characters. @@ -292,7 +313,7 @@ module.exports = { ISIG: 50, // Enable signals INTR, QUIT, [D]SUSP. ICANON: 51, // Canonicalize input lines. XCASE: 52, // Enable input and output of uppercase characters by - // preceding their lowercase equivalents with "\". + // preceding their lowercase equivalents with "\". ECHO: 53, // Enable echoing. ECHOE: 54, // Visually erase chars. ECHOK: 55, // Kill character discards current line. @@ -308,7 +329,7 @@ module.exports = { ONLCR: 72, // Map NL to CR-NL. OCRNL: 73, // Translate carriage return to newline (output). ONOCR: 74, // Translate newline to carriage return-newline - // (output). + // (output). ONLRET: 75, // Newline performs a carriage return (output). CS7: 90, // 7 bit mode. CS8: 91, // 8 bit mode. @@ -328,11 +349,11 @@ module.exports = { COMPAT, COMPAT_CHECKS: [ - [ 'Cisco-1.25', COMPAT.BAD_DHGEX ], - [ /^Cisco-1[.]/, COMPAT.BUG_DHGEX_LARGE ], - [ /^[0-9.]+$/, COMPAT.OLD_EXIT ], // old SSH.com implementations - [ /^OpenSSH_5[.][0-9]+/, COMPAT.DYN_RPORT_BUG ], - [ /^OpenSSH_7[.]4/, COMPAT.IMPLY_RSA_SHA2_SIGALGS ], + ['Cisco-1.25', COMPAT.BAD_DHGEX], + [/^Cisco-1[.]/, COMPAT.BUG_DHGEX_LARGE], + [/^[0-9.]+$/, COMPAT.OLD_EXIT], // old SSH.com implementations + [/^OpenSSH_5[.][0-9]+/, COMPAT.DYN_RPORT_BUG], + [/^OpenSSH_7[.]4/, COMPAT.IMPLY_RSA_SHA2_SIGALGS], ], // KEX proposal-related @@ -353,4 +374,4 @@ module.exports = { module.exports.DISCONNECT_REASON_BY_VALUE = Array.from(Object.entries(module.exports.DISCONNECT_REASON)) - .reduce((obj, [key, value]) => ({ ...obj, [value]: key }), {}); + .reduce((obj, [key, value]) => ({ ...obj, [value]: key }), {}); From 7625b021ecb2af9cadb52f51a8ba6a75623f0761 Mon Sep 17 00:00:00 2001 From: Lucas Queiroz Date: Wed, 15 Oct 2025 18:00:07 -0300 Subject: [PATCH 02/14] feat: adds mlkem class, adds it to factory and adds generateKeys() --- lib/protocol/constants.js | 1 + lib/protocol/kex.js | 251 ++++++++++++++++++++++---------------- 2 files changed, 149 insertions(+), 103 deletions(-) diff --git a/lib/protocol/constants.js b/lib/protocol/constants.js index 8f66c94e..465ab153 100644 --- a/lib/protocol/constants.js +++ b/lib/protocol/constants.js @@ -370,6 +370,7 @@ module.exports = { curve25519Supported, eddsaSupported, + mlkemSupported, }; module.exports.DISCONNECT_REASON_BY_VALUE = diff --git a/lib/protocol/kex.js b/lib/protocol/kex.js index 811e631b..eb02f731 100644 --- a/lib/protocol/kex.js +++ b/lib/protocol/kex.js @@ -16,6 +16,7 @@ const { Ber } = require('asn1'); const { COMPAT, curve25519Supported, + mlkemSupported, DEFAULT_KEX, DEFAULT_SERVER_HOST_KEY, DEFAULT_CIPHER, @@ -172,15 +173,15 @@ function handleKexInit(self, payload) { bufferParser.init(payload, 17); if ((init.kex = bufferParser.readList()) === undefined - || (init.serverHostKey = bufferParser.readList()) === undefined - || (init.cs.cipher = bufferParser.readList()) === undefined - || (init.sc.cipher = bufferParser.readList()) === undefined - || (init.cs.mac = bufferParser.readList()) === undefined - || (init.sc.mac = bufferParser.readList()) === undefined - || (init.cs.compress = bufferParser.readList()) === undefined - || (init.sc.compress = bufferParser.readList()) === undefined - || (init.cs.lang = bufferParser.readList()) === undefined - || (init.sc.lang = bufferParser.readList()) === undefined) { + || (init.serverHostKey = bufferParser.readList()) === undefined + || (init.cs.cipher = bufferParser.readList()) === undefined + || (init.sc.cipher = bufferParser.readList()) === undefined + || (init.cs.mac = bufferParser.readList()) === undefined + || (init.sc.mac = bufferParser.readList()) === undefined + || (init.cs.compress = bufferParser.readList()) === undefined + || (init.sc.compress = bufferParser.readList()) === undefined + || (init.cs.lang = bufferParser.readList()) === undefined + || (init.sc.lang = bufferParser.readList()) === undefined) { bufferParser.clear(); return doFatalError( self, @@ -258,8 +259,8 @@ function handleKexInit(self, payload) { } // Check for agreeable key exchange algorithm for (i = 0; - i < clientList.length && serverList.indexOf(clientList[i]) === -1; - ++i); + i < clientList.length && serverList.indexOf(clientList[i]) === -1; + ++i); if (i === clientList.length) { // No suitable match found! debug && debug('Handshake: no matching key exchange algorithm'); @@ -293,8 +294,8 @@ function handleKexInit(self, payload) { } // Check for agreeable server host key format for (i = 0; - i < clientList.length && serverList.indexOf(clientList[i]) === -1; - ++i); + i < clientList.length && serverList.indexOf(clientList[i]) === -1; + ++i); if (i === clientList.length) { // No suitable match found! debug && debug('Handshake: No matching host key format'); @@ -322,8 +323,8 @@ function handleKexInit(self, payload) { } // Check for agreeable client->server cipher for (i = 0; - i < clientList.length && serverList.indexOf(clientList[i]) === -1; - ++i); + i < clientList.length && serverList.indexOf(clientList[i]) === -1; + ++i); if (i === clientList.length) { // No suitable match found! debug && debug('Handshake: No matching C->S cipher'); @@ -351,8 +352,8 @@ function handleKexInit(self, payload) { } // Check for agreeable server->client cipher for (i = 0; - i < clientList.length && serverList.indexOf(clientList[i]) === -1; - ++i); + i < clientList.length && serverList.indexOf(clientList[i]) === -1; + ++i); if (i === clientList.length) { // No suitable match found! debug && debug('Handshake: No matching S->C cipher'); @@ -384,8 +385,8 @@ function handleKexInit(self, payload) { } // Check for agreeable client->server hmac algorithm for (i = 0; - i < clientList.length && serverList.indexOf(clientList[i]) === -1; - ++i); + i < clientList.length && serverList.indexOf(clientList[i]) === -1; + ++i); if (i === clientList.length) { // No suitable match found! debug && debug('Handshake: No matching C->S MAC'); @@ -418,8 +419,8 @@ function handleKexInit(self, payload) { } // Check for agreeable server->client hmac algorithm for (i = 0; - i < clientList.length && serverList.indexOf(clientList[i]) === -1; - ++i); + i < clientList.length && serverList.indexOf(clientList[i]) === -1; + ++i); if (i === clientList.length) { // No suitable match found! debug && debug('Handshake: No matching S->C MAC'); @@ -448,8 +449,8 @@ function handleKexInit(self, payload) { } // Check for agreeable client->server compression algorithm for (i = 0; - i < clientList.length && serverList.indexOf(clientList[i]) === -1; - ++i); + i < clientList.length && serverList.indexOf(clientList[i]) === -1; + ++i); if (i === clientList.length) { // No suitable match found! debug && debug('Handshake: No matching C->S compression'); @@ -477,8 +478,8 @@ function handleKexInit(self, payload) { } // Check for agreeable server->client compression algorithm for (i = 0; - i < clientList.length && serverList.indexOf(clientList[i]) === -1; - ++i); + i < clientList.length && serverList.indexOf(clientList[i]) === -1; + ++i); if (i === clientList.length) { // No suitable match found! debug && debug('Handshake: No matching S->C compression'); @@ -588,8 +589,8 @@ const createKeyExchange = (() => { hashString(hash, (isServer ? this._kexinit : this._remoteKexinit)); // "K_S" const serverPublicHostKey = (isServer - ? this._hostKey.getPublicSSH() - : this._hostKey); + ? this._hostKey.getPublicSSH() + : this._hostKey); hashString(hash, serverPublicHostKey); if (this.type === 'groupex') { @@ -639,7 +640,7 @@ const createKeyExchange = (() => { return doFatalError( this._protocol, `Wrong signature type: ${sigType}, ` - + `expected: ${negotiated.serverHostKey}`, + + `expected: ${negotiated.serverHostKey}`, 'handshake', DISCONNECT_REASON.KEY_EXCHANGE_FAILED ); @@ -733,7 +734,7 @@ const createKeyExchange = (() => { return doFatalError( this._protocol, 'Handshake failed: signature generation failed for ' - + `${this._hostKey.type} host key: ${signature.message}`, + + `${this._hostKey.type} host key: ${signature.message}`, 'handshake', DISCONNECT_REASON.KEY_EXCHANGE_FAILED ); @@ -744,7 +745,7 @@ const createKeyExchange = (() => { return doFatalError( this._protocol, 'Handshake failed: signature conversion failed for ' - + `${this._hostKey.type} host key`, + + `${this._hostKey.type} host key`, 'handshake', DISCONNECT_REASON.KEY_EXCHANGE_FAILED ); @@ -765,9 +766,9 @@ const createKeyExchange = (() => { let p = this._protocol._packetRW.write.allocStartKEX; const packet = this._protocol._packetRW.write.alloc( 1 - + 4 + serverPublicHostKey.length - + 4 + serverPublicKey.length - + 4 + sigLen, + + 4 + serverPublicHostKey.length + + 4 + serverPublicKey.length + + 4 + sigLen, true ); @@ -777,8 +778,8 @@ const createKeyExchange = (() => { packet.set(serverPublicHostKey, p += 4); writeUInt32BE(packet, - serverPublicKey.length, - p += serverPublicHostKey.length); + serverPublicKey.length, + p += serverPublicHostKey.length); packet.set(serverPublicKey, p += 4); writeUInt32BE(packet, sigLen, p += serverPublicKey.length); @@ -843,50 +844,50 @@ const createKeyExchange = (() => { const scCipherInfo = CIPHER_INFO[negotiated.sc.cipher]; const csIV = generateKEXVal(csCipherInfo.ivLen, - this.hashName, - secret, - exchangeHash, - this.sessionID, - 'A'); + this.hashName, + secret, + exchangeHash, + this.sessionID, + 'A'); const scIV = generateKEXVal(scCipherInfo.ivLen, - this.hashName, - secret, - exchangeHash, - this.sessionID, - 'B'); + this.hashName, + secret, + exchangeHash, + this.sessionID, + 'B'); const csKey = generateKEXVal(csCipherInfo.keyLen, - this.hashName, - secret, - exchangeHash, - this.sessionID, - 'C'); + this.hashName, + secret, + exchangeHash, + this.sessionID, + 'C'); const scKey = generateKEXVal(scCipherInfo.keyLen, - this.hashName, - secret, - exchangeHash, - this.sessionID, - 'D'); + this.hashName, + secret, + exchangeHash, + this.sessionID, + 'D'); let csMacInfo; let csMacKey; if (!csCipherInfo.authLen) { csMacInfo = MAC_INFO[negotiated.cs.mac]; csMacKey = generateKEXVal(csMacInfo.len, - this.hashName, - secret, - exchangeHash, - this.sessionID, - 'E'); + this.hashName, + secret, + exchangeHash, + this.sessionID, + 'E'); } let scMacInfo; let scMacKey; if (!scCipherInfo.authLen) { scMacInfo = MAC_INFO[negotiated.sc.mac]; scMacKey = generateKEXVal(scMacInfo.len, - this.hashName, - secret, - exchangeHash, - this.sessionID, - 'F'); + this.hashName, + secret, + exchangeHash, + this.sessionID, + 'F'); } const config = { @@ -1142,8 +1143,8 @@ const createKeyExchange = (() => { let dhData; let sig; if ((hostPubKey = bufferParser.readString()) === undefined - || (dhData = bufferParser.readString()) === undefined - || (sig = bufferParser.readString()) === undefined) { + || (dhData = bufferParser.readString()) === undefined + || (sig = bufferParser.readString()) === undefined) { bufferParser.clear(); return doFatalError( this._protocol, @@ -1319,22 +1320,22 @@ const createKeyExchange = (() => { try { const asnWriter = new Ber.Writer(); asnWriter.startSequence(); - // algorithm - asnWriter.startSequence(); - asnWriter.writeOID('1.3.101.110'); // id-X25519 - asnWriter.endSequence(); - - // PublicKey - asnWriter.startSequence(Ber.BitString); - asnWriter.writeByte(0x00); - // XXX: hack to write a raw buffer without a tag -- yuck - asnWriter._ensure(otherPublicKey.length); - otherPublicKey.copy(asnWriter._buf, - asnWriter._offset, - 0, - otherPublicKey.length); - asnWriter._offset += otherPublicKey.length; - asnWriter.endSequence(); + // algorithm + asnWriter.startSequence(); + asnWriter.writeOID('1.3.101.110'); // id-X25519 + asnWriter.endSequence(); + + // PublicKey + asnWriter.startSequence(Ber.BitString); + asnWriter.writeByte(0x00); + // XXX: hack to write a raw buffer without a tag -- yuck + asnWriter._ensure(otherPublicKey.length); + otherPublicKey.copy(asnWriter._buf, + asnWriter._offset, + 0, + otherPublicKey.length); + asnWriter._offset += otherPublicKey.length; + asnWriter.endSequence(); asnWriter.endSequence(); return convertToMpint(diffieHellman({ @@ -1431,7 +1432,7 @@ const createKeyExchange = (() => { return doFatalError( this._protocol, `Received packet ${type} instead of ` - + MESSAGE.KEXDH_GEX_REQUEST, + + MESSAGE.KEXDH_GEX_REQUEST, 'handshake', DISCONNECT_REASON.KEY_EXCHANGE_FAILED ); @@ -1468,7 +1469,7 @@ const createKeyExchange = (() => { let prime; let gen; if ((prime = bufferParser.readString()) === undefined - || (gen = bufferParser.readString()) === undefined) { + || (gen = bufferParser.readString()) === undefined) { bufferParser.clear(); return doFatalError( this._protocol, @@ -1580,6 +1581,46 @@ const createKeyExchange = (() => { } } + class MLKEMx25519Exchange extends KeyExchange { + constructor(hashName, ...args) { + super(...args); + + this.type = 'mlkem768x25519'; + this.hashName = hashName; + this._x25519Keys = null; + this._mlkemKeys = null; + } + + async generateKeys() { + if (!this._x25519Keys) + this._x25519Keys = generateKeyPairSync('x25519'); + + + if (!this._mlkemKeys) { + const mlkemKeyPair = await crypto.subtle.generateKey( + { name: 'ml-kem-768' }, + true, + ['encapsulateKey', 'decapsulateKey'] + ); + + const spkiExport = await crypto.subtle.exportKey( + 'spki', + mlkemKeyPair.publicKey + ); + + const mlKemPublicKeySize = 1184; + + this._mlkemKeys = { + // HACK: avoids parsing header. + // Similar to what happens on Curve25519Exchange + publicKey: Buffer.from(spkiExport).slice(-mlKemPublicKeySize), + privateKey: mlkemKeyPair.privateKey, + }; + } + } + + } + return (negotiated, ...args) => { if (typeof negotiated !== 'object' || negotiated === null) throw new Error('Invalid negotiated argument'); @@ -1587,12 +1628,16 @@ const createKeyExchange = (() => { if (typeof kexType === 'string') { args = [negotiated, ...args]; switch (kexType) { + case 'mlkem768x25519-sha256': + if (!mlkemSupported || !curve25519Supported) + break; + return new MLKEMx25519Exchange('sha256', ...args); + case 'curve25519-sha256': case 'curve25519-sha256@libssh.org': if (!curve25519Supported) break; return new Curve25519Exchange('sha256', ...args); - case 'ecdh-sha2-nistp256': return new ECDHExchange('prime256v1', 'sha256', ...args); case 'ecdh-sha2-nistp384': @@ -1630,14 +1675,14 @@ const KexInit = (() => { const KEX_PROPERTY_NAMES = [ 'kex', 'serverHostKey', - ['cs', 'cipher' ], - ['sc', 'cipher' ], - ['cs', 'mac' ], - ['sc', 'mac' ], - ['cs', 'compress' ], - ['sc', 'compress' ], - ['cs', 'lang' ], - ['sc', 'lang' ], + ['cs', 'cipher'], + ['sc', 'cipher'], + ['cs', 'mac'], + ['sc', 'mac'], + ['cs', 'compress'], + ['sc', 'compress'], + ['cs', 'lang'], + ['sc', 'lang'], ]; return class KexInit { constructor(obj) { @@ -1741,17 +1786,17 @@ function generateKEXVal(len, hashName, secret, exchangeHash, sessionID, char) { let ret; if (len) { let digest = createHash(hashName) - .update(secret) - .update(exchangeHash) - .update(char) - .update(sessionID) - .digest(); + .update(secret) + .update(exchangeHash) + .update(char) + .update(sessionID) + .digest(); while (digest.length < len) { const chunk = createHash(hashName) - .update(secret) - .update(exchangeHash) - .update(digest) - .digest(); + .update(secret) + .update(exchangeHash) + .update(digest) + .digest(); const extended = Buffer.allocUnsafe(digest.length + chunk.length); extended.set(digest, 0); extended.set(chunk, digest.length); From 15162a60f9fd5b2cba33102a4082db0d70ba899e Mon Sep 17 00:00:00 2001 From: Lucas Queiroz Date: Wed, 15 Oct 2025 18:31:32 -0300 Subject: [PATCH 03/14] feat: adds getPublicKey() and start() --- lib/protocol/kex.js | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/lib/protocol/kex.js b/lib/protocol/kex.js index eb02f731..623ee55c 100644 --- a/lib/protocol/kex.js +++ b/lib/protocol/kex.js @@ -1619,6 +1619,40 @@ const createKeyExchange = (() => { } } + async getPublicKey() { + await this.generateKeys(); + + const x25519Pub = this._x25519Keys.publicKey + .export({ type: 'spki', format: 'der' }) + .slice(-32); // HACK: avoids parsing DER/BER header + + return Buffer.concat([this._mlkemKeys.publicKey, x25519Pub]); + } + + async start() { + if (this._protocol._server) return; + + const pubKey = await this.getPublicKey(); + + let p = this._protocol._packetRW.write.allocStartKEX; + const packet = this._protocol._packetRW.write.alloc( + 1 + 4 + pubKey.length, + true, + ); + + // NOTE: Check 2.2 on the following draft + // https://datatracker.ietf.org/doc/html/draft-ietf-sshm-mlkem-hybrid-kex-03.html + const SSH_MSG_KEX_HYBRID_INIT = MESSAGE.KEXDH_INIT; + + packet[p] = SSH_MSG_KEX_HYBRID_INIT; + + writeUInt32BE(packet, pubKey.length, ++p); + packet.set(pubKey, p += 4); + this._protocol._cipher.encrypt( + this._protocol._packetRW.write.finalize(packet, true) + ); + } + } return (negotiated, ...args) => { From 287389ffcb6767124e4344f000ff06d58e95b049 Mon Sep 17 00:00:00 2001 From: Lucas Queiroz Date: Wed, 22 Oct 2025 12:23:49 -0300 Subject: [PATCH 04/14] fix: changes key pair function used to make it sync --- lib/protocol/constants.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/protocol/constants.js b/lib/protocol/constants.js index 465ab153..3013ec91 100644 --- a/lib/protocol/constants.js +++ b/lib/protocol/constants.js @@ -34,15 +34,12 @@ const curve25519Supported = (typeof crypto.diffieHellman === 'function' const mlkemSupported = (() => { try { - if (!crypto.subtle || typeof crypto.subtle.generateKey !== 'function') + if (!crypto.generateKeyPairSync || typeof crypto.generateKeyPairSync !== 'function') return false; - const alg = { name: 'ml-kem-768' }; - const test = crypto.subtle.generateKey(alg, true, [ - 'encapsulateKey', - 'decapsulateKey' - ]); - return test && typeof test.then === 'function'; + const algoName = 'ml-kem-768'; + const test = crypto.generateKeyPairSync(algoName); + return !!test.publicKey && !!test.privateKey; } catch { return false; } From 01f4e2dc84342b16bbea8b99bdc7f79b70ac18b8 Mon Sep 17 00:00:00 2001 From: Lucas Queiroz Date: Tue, 28 Oct 2025 17:33:38 -0300 Subject: [PATCH 05/14] feat: adds more context to support check --- lib/protocol/constants.js | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/lib/protocol/constants.js b/lib/protocol/constants.js index 3013ec91..c24ccaf5 100644 --- a/lib/protocol/constants.js +++ b/lib/protocol/constants.js @@ -34,12 +34,32 @@ const curve25519Supported = (typeof crypto.diffieHellman === 'function' const mlkemSupported = (() => { try { - if (!crypto.generateKeyPairSync || typeof crypto.generateKeyPairSync !== 'function') + if ( + !curve25519Supported + || typeof crypto.encapsulate !== 'function' + || typeof crypto.decapsulate !== 'function' + ) return false; - const algoName = 'ml-kem-768'; - const test = crypto.generateKeyPairSync(algoName); - return !!test.publicKey && !!test.privateKey; + const algorithm = 'ml-kem-768'; + const test_clientKeys = crypto.generateKeyPairSync(algorithm); + const test_serverKeys = crypto.generateKeyPairSync(algorithm); + + const { ciphertext, sharedKey } = crypto.encapsulate( + test_clientKeys.publicKey + ); + + const sharedSecret = crypto.decapsulate( + test_clientKeys.privateKey, + ciphertext + ); + + for (let i = 0; i < sharedSecret.length && i < sharedKey.length; i++) { + if (sharedSecret[i] !== sharedKey[i]) + return false; + } + + return true; } catch { return false; } @@ -66,7 +86,7 @@ if (curve25519Supported) { DEFAULT_KEX.unshift('curve25519-sha256@libssh.org'); } -if (mlkemSupported && curve25519Supported) +if (mlkemSupported) DEFAULT_KEX.unshift('mlkem768x25519-sha256'); @@ -207,6 +227,9 @@ module.exports = { KEXECDH_INIT: 30, KEXECDH_REPLY: 31, + KEX_HYBRID_INIT: 30, + KEX_HYBRID_INIT: 31, + // User auth protocol -- generic (50-59) USERAUTH_REQUEST: 50, USERAUTH_FAILURE: 51, From a7ec2c66ec772c29039c091fc683c5274aa9ba8f Mon Sep 17 00:00:00 2001 From: Lucas Queiroz Date: Tue, 28 Oct 2025 18:18:43 -0300 Subject: [PATCH 06/14] fix: changes constant name to correct one --- lib/protocol/constants.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/protocol/constants.js b/lib/protocol/constants.js index c24ccaf5..c9486be0 100644 --- a/lib/protocol/constants.js +++ b/lib/protocol/constants.js @@ -228,7 +228,7 @@ module.exports = { KEXECDH_REPLY: 31, KEX_HYBRID_INIT: 30, - KEX_HYBRID_INIT: 31, + KEX_HYBRID_REPLY: 31, // User auth protocol -- generic (50-59) USERAUTH_REQUEST: 50, From 08876dc3d8ff8b9909ca3dddf806134da8d7609f Mon Sep 17 00:00:00 2001 From: Lucas Queiroz Date: Tue, 28 Oct 2025 23:15:25 -0300 Subject: [PATCH 07/14] feat: removes async logic and finishes implementation --- lib/protocol/constants.js | 1 - lib/protocol/kex.js | 212 ++++++++++++++++++++++++++++++-------- 2 files changed, 169 insertions(+), 44 deletions(-) diff --git a/lib/protocol/constants.js b/lib/protocol/constants.js index c9486be0..244d9e63 100644 --- a/lib/protocol/constants.js +++ b/lib/protocol/constants.js @@ -43,7 +43,6 @@ const mlkemSupported = (() => { const algorithm = 'ml-kem-768'; const test_clientKeys = crypto.generateKeyPairSync(algorithm); - const test_serverKeys = crypto.generateKeyPairSync(algorithm); const { ciphertext, sharedKey } = crypto.encapsulate( test_clientKeys.publicKey diff --git a/lib/protocol/kex.js b/lib/protocol/kex.js index 623ee55c..46168f46 100644 --- a/lib/protocol/kex.js +++ b/lib/protocol/kex.js @@ -9,6 +9,8 @@ const { diffieHellman, generateKeyPairSync, randomFillSync, + encapsulate, + decapsulate, } = require('crypto'); const { Ber } = require('asn1'); @@ -1582,77 +1584,201 @@ const createKeyExchange = (() => { } class MLKEMx25519Exchange extends KeyExchange { + // NOTE: based on the following draft + // https://datatracker.ietf.org/doc/html/draft-ietf-sshm-mlkem-hybrid-kex-03.html constructor(hashName, ...args) { super(...args); this.type = 'mlkem768x25519'; this.hashName = hashName; + this._x25519Keys = null; this._mlkemKeys = null; + + this._curve25519KeySize = 32; + this._encapsulationKeySize = 1184; + this._decapsulationKeySize = 2400; + this._ciphertextSize = 1088; + + this.K_PQ = null; + this.K_CL = null; + + this._S_CT2 = null; // needed for server } - async generateKeys() { + generateKeys() { if (!this._x25519Keys) this._x25519Keys = generateKeyPairSync('x25519'); + if (!this._mlkemKeys) + this._mlkemKeys = generateKeyPairSync('ml-kem-768'); - if (!this._mlkemKeys) { - const mlkemKeyPair = await crypto.subtle.generateKey( - { name: 'ml-kem-768' }, - true, - ['encapsulateKey', 'decapsulateKey'] - ); + } - const spkiExport = await crypto.subtle.exportKey( - 'spki', - mlkemKeyPair.publicKey - ); + convertPublicKey(key) { + // We need to override this because the key + // is not an mpint (compare section 2.1 on the draft + // to section 8 on rfc4253) + return key; + } + + getPublicKey() { + this.generateKeys(); - const mlKemPublicKeySize = 1184; + const isServer = this._protocol._server; - this._mlkemKeys = { - // HACK: avoids parsing header. - // Similar to what happens on Curve25519Exchange - publicKey: Buffer.from(spkiExport).slice(-mlKemPublicKeySize), - privateKey: mlkemKeyPair.privateKey, - }; + if (!isServer) { + // client + const C_PK1 = this._x25519Keys.publicKey + .export({ type: 'spki', format: 'der' }) + .slice(-this._curve25519KeySize); + + const C_PK2 = this._mlkemKeys.publicKey + .export({ type: 'spki', format: 'der' }) + .slice(-this._encapsulationKeySize); + + return Buffer.concat([C_PK2, C_PK1]); } - } - async getPublicKey() { - await this.generateKeys(); + // server - const x25519Pub = this._x25519Keys.publicKey + // NOTE: S_CT2 should already have been + // calculated by now, since client is the + // one that initiates the kex + const S_PK1 = this._x25519Keys.publicKey .export({ type: 'spki', format: 'der' }) - .slice(-32); // HACK: avoids parsing DER/BER header + .slice(-this._curve25519KeySize); - return Buffer.concat([this._mlkemKeys.publicKey, x25519Pub]); + return Buffer.concat([this._S_CT2, S_PK1]); } - async start() { - if (this._protocol._server) return; + buildX25519SPKI(key) { + const asnWriter = new Ber.Writer(); + asnWriter.startSequence(); + // algorithm + asnWriter.startSequence(); + asnWriter.writeOID('1.3.101.110'); // id-X25519 + asnWriter.endSequence(); + + // PublicKey + asnWriter.startSequence(Ber.BitString); + asnWriter.writeByte(0x00); + // XXX: hack to write a raw buffer without a tag -- yuck + asnWriter._ensure(key.length); + key.copy(asnWriter._buf, + asnWriter._offset, + 0, + key.length); + asnWriter._offset += key.length; + asnWriter.endSequence(); + asnWriter.endSequence(); + + return asnWriter; + } - const pubKey = await this.getPublicKey(); + computeSecret(otherPublicKey) { + try { + this.generateKeys(); + const isServer = this._protocol._server; + + if (isServer && !this._S_CT2) { + // server + const C_PK2 = otherPublicKey.slice( + 0, + this._encapsulationKeySize + ); - let p = this._protocol._packetRW.write.allocStartKEX; - const packet = this._protocol._packetRW.write.alloc( - 1 + 4 + pubKey.length, - true, - ); + const C_PK1 = otherPublicKey.slice( + this._encapsulationKeySize + ); - // NOTE: Check 2.2 on the following draft - // https://datatracker.ietf.org/doc/html/draft-ietf-sshm-mlkem-hybrid-kex-03.html - const SSH_MSG_KEX_HYBRID_INIT = MESSAGE.KEXDH_INIT; + if (C_PK1.length !== 32) { + const expected = this._curve25519KeySize; + const got = C_PK1.length; + throw new Error( + `Invalid C_PK1 length: expected ${expected}, got ${got}` + ); + } - packet[p] = SSH_MSG_KEX_HYBRID_INIT; + const x25519SPKI = this.buildX25519SPKI(C_PK1); - writeUInt32BE(packet, pubKey.length, ++p); - packet.set(pubKey, p += 4); - this._protocol._cipher.encrypt( - this._protocol._packetRW.write.finalize(packet, true) - ); - } + const K_CL = diffieHellman({ + privateKey: this._x25519Keys.privateKey, + publicKey: createPublicKey({ + key: x25519SPKI.buffer, + type: 'spki', + format: 'der', + }), + }); + + const templateDER = this._mlkemKeys.publicKey + .export({ type: 'spki', format: 'der' }); + + const C_PK2_DER = Buffer.allocUnsafe(templateDER.length); + + templateDER.copy( + C_PK2_DER, + 0, + 0, + templateDER.length - this._encapsulationKeySize + ); + + C_PK2.copy( + C_PK2_DER, + templateDER.length - this._encapsulationKeySize + ); + + const { sharedKey: K_PQ, ciphertext: S_CT2 } = encapsulate( + createPublicKey({ + key: C_PK2_DER, + type: 'spki', + format: 'der', + }) + ); + + this._S_CT2 = S_CT2; + + return Buffer.concat([K_PQ, K_CL]); + } + // client + const S_CT2 = otherPublicKey.slice(0, this._ciphertextSize); + const S_PK1 = otherPublicKey.slice(this._ciphertextSize); + + if (S_PK1.length !== 32) { + const expected = this._curve25519KeySize; + const got = S_PK1.length; + throw new Error( + `Invalid S_PK1 length: expected ${expected}, got ${got}` + ); + } + + const x25519SPKI = this.buildX25519SPKI(S_PK1); + + const K_CL = diffieHellman({ + privateKey: this._x25519Keys.privateKey, + publicKey: createPublicKey({ + key: x25519SPKI.buffer, + type: 'spki', + format: 'der', + }), + }); + + this.K_CL = K_CL; + const K_PQ = decapsulate( + this._mlkemKeys.privateKey, + S_CT2 + ); + this.K_PQ = K_PQ; + + return Buffer.concat([K_PQ, K_CL]); + + + } catch (error) { + return error; + } + + } } return (negotiated, ...args) => { @@ -1663,7 +1789,7 @@ const createKeyExchange = (() => { args = [negotiated, ...args]; switch (kexType) { case 'mlkem768x25519-sha256': - if (!mlkemSupported || !curve25519Supported) + if (!mlkemSupported) break; return new MLKEMx25519Exchange('sha256', ...args); From ddb8f2a0cd0290bc263e0ad850c7ec8e1a999cf5 Mon Sep 17 00:00:00 2001 From: Lucas Queiroz Date: Tue, 28 Oct 2025 23:19:32 -0300 Subject: [PATCH 08/14] docs: adds mlkem768x25519-sha256 as kex option --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e08c929e..6c8524c5 100644 --- a/README.md +++ b/README.md @@ -848,6 +848,7 @@ You can find more examples in the `examples` directory of this repository. * **kex** - _mixed_ - Key exchange algorithms. * Default list (in order from most to least preferable): + * `mlkem768x25519-sha256` (node v24.7.0+) * `curve25519-sha256` (node v14.0.0+) * `curve25519-sha256@libssh.org` (node v14.0.0+) * `ecdh-sha2-nistp256` From a3eef8df7f11553cc10b9bfaae58c4849252d748 Mon Sep 17 00:00:00 2001 From: Lucas Queiroz Date: Wed, 29 Oct 2025 00:47:34 -0300 Subject: [PATCH 09/14] style: reverts style changes --- lib/protocol/constants.js | 46 ++++----- lib/protocol/kex.js | 205 +++++++++++++++++++------------------- 2 files changed, 126 insertions(+), 125 deletions(-) diff --git a/lib/protocol/constants.js b/lib/protocol/constants.js index 244d9e63..ba9b91e4 100644 --- a/lib/protocol/constants.js +++ b/lib/protocol/constants.js @@ -5,13 +5,13 @@ const crypto = require('crypto'); let cpuInfo; try { cpuInfo = require('cpu-features')(); -} catch { } +} catch {} const { bindingAvailable, CIPHER_INFO, MAC_INFO } = require('./crypto.js'); const eddsaSupported = (() => { if (typeof crypto.sign === 'function' - && typeof crypto.verify === 'function') { + && typeof crypto.verify === 'function') { const key = '-----BEGIN PRIVATE KEY-----\r\nMC4CAQAwBQYDK2VwBCIEIHKj+sVa9WcD' + '/q2DJUJaf43Kptc8xYuUQA4bOFj9vC8T\r\n-----END PRIVATE KEY-----'; @@ -21,7 +21,7 @@ const eddsaSupported = (() => { try { sig = crypto.sign(null, data, key); verified = crypto.verify(null, data, key, sig); - } catch { } + } catch {} return (Buffer.isBuffer(sig) && sig.length === 64 && verified === true); } @@ -29,8 +29,8 @@ const eddsaSupported = (() => { })(); const curve25519Supported = (typeof crypto.diffieHellman === 'function' - && typeof crypto.generateKeyPairSync === 'function' - && typeof crypto.createPublicKey === 'function'); + && typeof crypto.generateKeyPairSync === 'function' + && typeof crypto.createPublicKey === 'function'); const mlkemSupported = (() => { try { @@ -183,8 +183,8 @@ const SUPPORTED_MAC = DEFAULT_MAC.concat([ const DEFAULT_COMPRESSION = [ 'none', 'zlib@openssh.com', // ZLIB (LZ77) compression, except - // compression/decompression does not start until after - // successful user authentication + // compression/decompression does not start until after + // successful user authentication 'zlib', // ZLIB (LZ77) compression ]; const SUPPORTED_COMPRESSION = DEFAULT_COMPRESSION.concat([ @@ -292,16 +292,16 @@ module.exports = { TERMINAL_MODE: { TTY_OP_END: 0, // Indicates end of options. VINTR: 1, // Interrupt character; 255 if none. Similarly for the - // other characters. Not all of these characters are - // supported on all systems. + // other characters. Not all of these characters are + // supported on all systems. VQUIT: 2, // The quit character (sends SIGQUIT signal on POSIX - // systems). + // systems). VERASE: 3, // Erase the character to left of the cursor. VKILL: 4, // Kill the current input line. VEOF: 5, // End-of-file character (sends EOF from the - // terminal). + // terminal). VEOL: 6, // End-of-line character in addition to carriage - // return and/or linefeed. + // return and/or linefeed. VEOL2: 7, // Additional end-of-line character. VSTART: 8, // Continues paused output (normally control-Q). VSTOP: 9, // Pauses output (normally control-S). @@ -310,14 +310,14 @@ module.exports = { VREPRINT: 12, // Reprints the current input line. VWERASE: 13, // Erases a word left of cursor. VLNEXT: 14, // Enter the next character typed literally, even if - // it is a special character + // it is a special character VFLUSH: 15, // Character to flush output. VSWTCH: 16, // Switch to a different shell layer. VSTATUS: 17, // Prints system status line (load, command, pid, - // etc). + // etc). VDISCARD: 18, // Toggles the flushing of terminal output. IGNPAR: 30, // The ignore parity flag. The parameter SHOULD be 0 - // if this flag is FALSE, and 1 if it is TRUE. + // if this flag is FALSE, and 1 if it is TRUE. PARMRK: 31, // Mark parity and framing errors. INPCK: 32, // Enable checking of parity errors. ISTRIP: 33, // Strip 8th bit off characters. @@ -332,7 +332,7 @@ module.exports = { ISIG: 50, // Enable signals INTR, QUIT, [D]SUSP. ICANON: 51, // Canonicalize input lines. XCASE: 52, // Enable input and output of uppercase characters by - // preceding their lowercase equivalents with "\". + // preceding their lowercase equivalents with "\". ECHO: 53, // Enable echoing. ECHOE: 54, // Visually erase chars. ECHOK: 55, // Kill character discards current line. @@ -348,7 +348,7 @@ module.exports = { ONLCR: 72, // Map NL to CR-NL. OCRNL: 73, // Translate carriage return to newline (output). ONOCR: 74, // Translate newline to carriage return-newline - // (output). + // (output). ONLRET: 75, // Newline performs a carriage return (output). CS7: 90, // 7 bit mode. CS8: 91, // 8 bit mode. @@ -368,11 +368,11 @@ module.exports = { COMPAT, COMPAT_CHECKS: [ - ['Cisco-1.25', COMPAT.BAD_DHGEX], - [/^Cisco-1[.]/, COMPAT.BUG_DHGEX_LARGE], - [/^[0-9.]+$/, COMPAT.OLD_EXIT], // old SSH.com implementations - [/^OpenSSH_5[.][0-9]+/, COMPAT.DYN_RPORT_BUG], - [/^OpenSSH_7[.]4/, COMPAT.IMPLY_RSA_SHA2_SIGALGS], + [ 'Cisco-1.25', COMPAT.BAD_DHGEX ], + [ /^Cisco-1[.]/, COMPAT.BUG_DHGEX_LARGE ], + [ /^[0-9.]+$/, COMPAT.OLD_EXIT ], // old SSH.com implementations + [ /^OpenSSH_5[.][0-9]+/, COMPAT.DYN_RPORT_BUG ], + [ /^OpenSSH_7[.]4/, COMPAT.IMPLY_RSA_SHA2_SIGALGS ], ], // KEX proposal-related @@ -394,4 +394,4 @@ module.exports = { module.exports.DISCONNECT_REASON_BY_VALUE = Array.from(Object.entries(module.exports.DISCONNECT_REASON)) - .reduce((obj, [key, value]) => ({ ...obj, [value]: key }), {}); + .reduce((obj, [key, value]) => ({ ...obj, [value]: key }), {}); diff --git a/lib/protocol/kex.js b/lib/protocol/kex.js index 46168f46..2bde6353 100644 --- a/lib/protocol/kex.js +++ b/lib/protocol/kex.js @@ -175,15 +175,15 @@ function handleKexInit(self, payload) { bufferParser.init(payload, 17); if ((init.kex = bufferParser.readList()) === undefined - || (init.serverHostKey = bufferParser.readList()) === undefined - || (init.cs.cipher = bufferParser.readList()) === undefined - || (init.sc.cipher = bufferParser.readList()) === undefined - || (init.cs.mac = bufferParser.readList()) === undefined - || (init.sc.mac = bufferParser.readList()) === undefined - || (init.cs.compress = bufferParser.readList()) === undefined - || (init.sc.compress = bufferParser.readList()) === undefined - || (init.cs.lang = bufferParser.readList()) === undefined - || (init.sc.lang = bufferParser.readList()) === undefined) { + || (init.serverHostKey = bufferParser.readList()) === undefined + || (init.cs.cipher = bufferParser.readList()) === undefined + || (init.sc.cipher = bufferParser.readList()) === undefined + || (init.cs.mac = bufferParser.readList()) === undefined + || (init.sc.mac = bufferParser.readList()) === undefined + || (init.cs.compress = bufferParser.readList()) === undefined + || (init.sc.compress = bufferParser.readList()) === undefined + || (init.cs.lang = bufferParser.readList()) === undefined + || (init.sc.lang = bufferParser.readList()) === undefined) { bufferParser.clear(); return doFatalError( self, @@ -261,8 +261,8 @@ function handleKexInit(self, payload) { } // Check for agreeable key exchange algorithm for (i = 0; - i < clientList.length && serverList.indexOf(clientList[i]) === -1; - ++i); + i < clientList.length && serverList.indexOf(clientList[i]) === -1; + ++i); if (i === clientList.length) { // No suitable match found! debug && debug('Handshake: no matching key exchange algorithm'); @@ -296,8 +296,8 @@ function handleKexInit(self, payload) { } // Check for agreeable server host key format for (i = 0; - i < clientList.length && serverList.indexOf(clientList[i]) === -1; - ++i); + i < clientList.length && serverList.indexOf(clientList[i]) === -1; + ++i); if (i === clientList.length) { // No suitable match found! debug && debug('Handshake: No matching host key format'); @@ -325,8 +325,8 @@ function handleKexInit(self, payload) { } // Check for agreeable client->server cipher for (i = 0; - i < clientList.length && serverList.indexOf(clientList[i]) === -1; - ++i); + i < clientList.length && serverList.indexOf(clientList[i]) === -1; + ++i); if (i === clientList.length) { // No suitable match found! debug && debug('Handshake: No matching C->S cipher'); @@ -354,8 +354,8 @@ function handleKexInit(self, payload) { } // Check for agreeable server->client cipher for (i = 0; - i < clientList.length && serverList.indexOf(clientList[i]) === -1; - ++i); + i < clientList.length && serverList.indexOf(clientList[i]) === -1; + ++i); if (i === clientList.length) { // No suitable match found! debug && debug('Handshake: No matching S->C cipher'); @@ -387,8 +387,8 @@ function handleKexInit(self, payload) { } // Check for agreeable client->server hmac algorithm for (i = 0; - i < clientList.length && serverList.indexOf(clientList[i]) === -1; - ++i); + i < clientList.length && serverList.indexOf(clientList[i]) === -1; + ++i); if (i === clientList.length) { // No suitable match found! debug && debug('Handshake: No matching C->S MAC'); @@ -421,8 +421,8 @@ function handleKexInit(self, payload) { } // Check for agreeable server->client hmac algorithm for (i = 0; - i < clientList.length && serverList.indexOf(clientList[i]) === -1; - ++i); + i < clientList.length && serverList.indexOf(clientList[i]) === -1; + ++i); if (i === clientList.length) { // No suitable match found! debug && debug('Handshake: No matching S->C MAC'); @@ -451,8 +451,8 @@ function handleKexInit(self, payload) { } // Check for agreeable client->server compression algorithm for (i = 0; - i < clientList.length && serverList.indexOf(clientList[i]) === -1; - ++i); + i < clientList.length && serverList.indexOf(clientList[i]) === -1; + ++i); if (i === clientList.length) { // No suitable match found! debug && debug('Handshake: No matching C->S compression'); @@ -480,8 +480,8 @@ function handleKexInit(self, payload) { } // Check for agreeable server->client compression algorithm for (i = 0; - i < clientList.length && serverList.indexOf(clientList[i]) === -1; - ++i); + i < clientList.length && serverList.indexOf(clientList[i]) === -1; + ++i); if (i === clientList.length) { // No suitable match found! debug && debug('Handshake: No matching S->C compression'); @@ -591,8 +591,8 @@ const createKeyExchange = (() => { hashString(hash, (isServer ? this._kexinit : this._remoteKexinit)); // "K_S" const serverPublicHostKey = (isServer - ? this._hostKey.getPublicSSH() - : this._hostKey); + ? this._hostKey.getPublicSSH() + : this._hostKey); hashString(hash, serverPublicHostKey); if (this.type === 'groupex') { @@ -642,7 +642,7 @@ const createKeyExchange = (() => { return doFatalError( this._protocol, `Wrong signature type: ${sigType}, ` - + `expected: ${negotiated.serverHostKey}`, + + `expected: ${negotiated.serverHostKey}`, 'handshake', DISCONNECT_REASON.KEY_EXCHANGE_FAILED ); @@ -736,7 +736,7 @@ const createKeyExchange = (() => { return doFatalError( this._protocol, 'Handshake failed: signature generation failed for ' - + `${this._hostKey.type} host key: ${signature.message}`, + + `${this._hostKey.type} host key: ${signature.message}`, 'handshake', DISCONNECT_REASON.KEY_EXCHANGE_FAILED ); @@ -747,7 +747,7 @@ const createKeyExchange = (() => { return doFatalError( this._protocol, 'Handshake failed: signature conversion failed for ' - + `${this._hostKey.type} host key`, + + `${this._hostKey.type} host key`, 'handshake', DISCONNECT_REASON.KEY_EXCHANGE_FAILED ); @@ -768,9 +768,9 @@ const createKeyExchange = (() => { let p = this._protocol._packetRW.write.allocStartKEX; const packet = this._protocol._packetRW.write.alloc( 1 - + 4 + serverPublicHostKey.length - + 4 + serverPublicKey.length - + 4 + sigLen, + + 4 + serverPublicHostKey.length + + 4 + serverPublicKey.length + + 4 + sigLen, true ); @@ -780,8 +780,8 @@ const createKeyExchange = (() => { packet.set(serverPublicHostKey, p += 4); writeUInt32BE(packet, - serverPublicKey.length, - p += serverPublicHostKey.length); + serverPublicKey.length, + p += serverPublicHostKey.length); packet.set(serverPublicKey, p += 4); writeUInt32BE(packet, sigLen, p += serverPublicKey.length); @@ -846,50 +846,50 @@ const createKeyExchange = (() => { const scCipherInfo = CIPHER_INFO[negotiated.sc.cipher]; const csIV = generateKEXVal(csCipherInfo.ivLen, - this.hashName, - secret, - exchangeHash, - this.sessionID, - 'A'); + this.hashName, + secret, + exchangeHash, + this.sessionID, + 'A'); const scIV = generateKEXVal(scCipherInfo.ivLen, - this.hashName, - secret, - exchangeHash, - this.sessionID, - 'B'); + this.hashName, + secret, + exchangeHash, + this.sessionID, + 'B'); const csKey = generateKEXVal(csCipherInfo.keyLen, - this.hashName, - secret, - exchangeHash, - this.sessionID, - 'C'); + this.hashName, + secret, + exchangeHash, + this.sessionID, + 'C'); const scKey = generateKEXVal(scCipherInfo.keyLen, - this.hashName, - secret, - exchangeHash, - this.sessionID, - 'D'); + this.hashName, + secret, + exchangeHash, + this.sessionID, + 'D'); let csMacInfo; let csMacKey; if (!csCipherInfo.authLen) { csMacInfo = MAC_INFO[negotiated.cs.mac]; csMacKey = generateKEXVal(csMacInfo.len, - this.hashName, - secret, - exchangeHash, - this.sessionID, - 'E'); + this.hashName, + secret, + exchangeHash, + this.sessionID, + 'E'); } let scMacInfo; let scMacKey; if (!scCipherInfo.authLen) { scMacInfo = MAC_INFO[negotiated.sc.mac]; scMacKey = generateKEXVal(scMacInfo.len, - this.hashName, - secret, - exchangeHash, - this.sessionID, - 'F'); + this.hashName, + secret, + exchangeHash, + this.sessionID, + 'F'); } const config = { @@ -1145,8 +1145,8 @@ const createKeyExchange = (() => { let dhData; let sig; if ((hostPubKey = bufferParser.readString()) === undefined - || (dhData = bufferParser.readString()) === undefined - || (sig = bufferParser.readString()) === undefined) { + || (dhData = bufferParser.readString()) === undefined + || (sig = bufferParser.readString()) === undefined) { bufferParser.clear(); return doFatalError( this._protocol, @@ -1322,22 +1322,22 @@ const createKeyExchange = (() => { try { const asnWriter = new Ber.Writer(); asnWriter.startSequence(); - // algorithm - asnWriter.startSequence(); - asnWriter.writeOID('1.3.101.110'); // id-X25519 - asnWriter.endSequence(); - - // PublicKey - asnWriter.startSequence(Ber.BitString); - asnWriter.writeByte(0x00); - // XXX: hack to write a raw buffer without a tag -- yuck - asnWriter._ensure(otherPublicKey.length); - otherPublicKey.copy(asnWriter._buf, - asnWriter._offset, - 0, - otherPublicKey.length); - asnWriter._offset += otherPublicKey.length; - asnWriter.endSequence(); + // algorithm + asnWriter.startSequence(); + asnWriter.writeOID('1.3.101.110'); // id-X25519 + asnWriter.endSequence(); + + // PublicKey + asnWriter.startSequence(Ber.BitString); + asnWriter.writeByte(0x00); + // XXX: hack to write a raw buffer without a tag -- yuck + asnWriter._ensure(otherPublicKey.length); + otherPublicKey.copy(asnWriter._buf, + asnWriter._offset, + 0, + otherPublicKey.length); + asnWriter._offset += otherPublicKey.length; + asnWriter.endSequence(); asnWriter.endSequence(); return convertToMpint(diffieHellman({ @@ -1434,7 +1434,7 @@ const createKeyExchange = (() => { return doFatalError( this._protocol, `Received packet ${type} instead of ` - + MESSAGE.KEXDH_GEX_REQUEST, + + MESSAGE.KEXDH_GEX_REQUEST, 'handshake', DISCONNECT_REASON.KEY_EXCHANGE_FAILED ); @@ -1471,7 +1471,7 @@ const createKeyExchange = (() => { let prime; let gen; if ((prime = bufferParser.readString()) === undefined - || (gen = bufferParser.readString()) === undefined) { + || (gen = bufferParser.readString()) === undefined) { bufferParser.clear(); return doFatalError( this._protocol, @@ -1798,6 +1798,7 @@ const createKeyExchange = (() => { if (!curve25519Supported) break; return new Curve25519Exchange('sha256', ...args); + case 'ecdh-sha2-nistp256': return new ECDHExchange('prime256v1', 'sha256', ...args); case 'ecdh-sha2-nistp384': @@ -1835,14 +1836,14 @@ const KexInit = (() => { const KEX_PROPERTY_NAMES = [ 'kex', 'serverHostKey', - ['cs', 'cipher'], - ['sc', 'cipher'], - ['cs', 'mac'], - ['sc', 'mac'], - ['cs', 'compress'], - ['sc', 'compress'], - ['cs', 'lang'], - ['sc', 'lang'], + ['cs', 'cipher' ], + ['sc', 'cipher' ], + ['cs', 'mac' ], + ['sc', 'mac' ], + ['cs', 'compress' ], + ['sc', 'compress' ], + ['cs', 'lang' ], + ['sc', 'lang' ], ]; return class KexInit { constructor(obj) { @@ -1946,17 +1947,17 @@ function generateKEXVal(len, hashName, secret, exchangeHash, sessionID, char) { let ret; if (len) { let digest = createHash(hashName) - .update(secret) - .update(exchangeHash) - .update(char) - .update(sessionID) - .digest(); + .update(secret) + .update(exchangeHash) + .update(char) + .update(sessionID) + .digest(); while (digest.length < len) { const chunk = createHash(hashName) - .update(secret) - .update(exchangeHash) - .update(digest) - .digest(); + .update(secret) + .update(exchangeHash) + .update(digest) + .digest(); const extended = Buffer.allocUnsafe(digest.length + chunk.length); extended.set(digest, 0); extended.set(chunk, digest.length); From a9e041831ad67b15d8d2a0db8be469ab2cc2df6d Mon Sep 17 00:00:00 2001 From: Lucas Queiroz Date: Wed, 29 Oct 2025 21:15:42 -0300 Subject: [PATCH 10/14] fix: hashes key concatenation --- lib/protocol/kex.js | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/lib/protocol/kex.js b/lib/protocol/kex.js index 2bde6353..0aa274d8 100644 --- a/lib/protocol/kex.js +++ b/lib/protocol/kex.js @@ -624,7 +624,6 @@ const createKeyExchange = (() => { // "H" const exchangeHash = hash.digest(); - if (!isServer) { bufferParser.init(this._sig, 0); const sigType = bufferParser.readString(true); @@ -1680,14 +1679,12 @@ const createKeyExchange = (() => { try { this.generateKeys(); const isServer = this._protocol._server; - if (isServer && !this._S_CT2) { // server const C_PK2 = otherPublicKey.slice( 0, this._encapsulationKeySize ); - const C_PK1 = otherPublicKey.slice( this._encapsulationKeySize ); @@ -1711,6 +1708,8 @@ const createKeyExchange = (() => { }), }); + this.K_CL = K_CL; + const templateDER = this._mlkemKeys.publicKey .export({ type: 'spki', format: 'der' }); @@ -1736,9 +1735,17 @@ const createKeyExchange = (() => { }) ); - this._S_CT2 = S_CT2; + this.K_PQ = K_PQ; - return Buffer.concat([K_PQ, K_CL]); + this._S_CT2 = S_CT2; + + // K = HASH(K_PQ || K_CL) from 2.4 on draft + const K = createHash('sha256') + .update(K_PQ) + .update(K_CL) + .digest(); + + return K; } // client const S_CT2 = otherPublicKey.slice(0, this._ciphertextSize); @@ -1771,7 +1778,13 @@ const createKeyExchange = (() => { ); this.K_PQ = K_PQ; - return Buffer.concat([K_PQ, K_CL]); + // K = HASH(K_PQ || K_CL) from 2.4 on draft + const K = createHash('sha256') + .update(K_PQ) + .update(K_CL) + .digest(); + + return K; } catch (error) { From 79a9c9765fe7d06fae0c811cc088c5317a3906ad Mon Sep 17 00:00:00 2001 From: Lucas Queiroz Date: Wed, 29 Oct 2025 21:17:12 -0300 Subject: [PATCH 11/14] test: adds integration tests for mlkem --- test/test-misc-client-server.js | 233 ++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) diff --git a/test/test-misc-client-server.js b/test/test-misc-client-server.js index 2dd5a29d..b570cd25 100644 --- a/test/test-misc-client-server.js +++ b/test/test-misc-client-server.js @@ -25,6 +25,8 @@ const { setupSimple, } = require('./common.js'); +const { mlkemSupported } = require('../lib/protocol/constants.js'); + const KEY_RSA_BAD = fixture('bad_rsa_private_key'); const HOST_RSA_MD5 = '64254520742d3d0792e918f3ce945a64'; const clientCfg = { username: 'foo', password: 'bar' }; @@ -1458,3 +1460,234 @@ const setup = setupSimple.bind(undefined, debug); })); })); } + +// NOTE: Hybrid PQ/T KEX tests +if (mlkemSupported) { + { + const { client, server } = setup_( + 'should work if client and server use ' + + 'mlkem768x25519-sha256 as kex algorithm', + { + client: { + ...clientCfg, + algorithms: { kex: ['mlkem768x25519-sha256'] } + }, + server: { + ...serverCfg, + algorithms: { kex: ['mlkem768x25519-sha256'] } + } + }, + ); + + const execCommand = 'echo "hello, world!"'; + const successfulExit = 0; + + server.on('connection', mustCall((conn) => { + conn.on('authentication', mustCall((ctx) => { + ctx.accept(); + })).on('ready', mustCall(() => { + conn.on('session', mustCall((accept, reject) => { + + const session = accept(); + session.on('exec', mustCall((accept, reject, info) => { + + assert( + info.command === execCommand, + `Wrong exec command: ${info.command}` + ); + + const stream = accept(); + stream.exit(successfulExit); + stream.end(); + + })); + + })); + })); + })); + + let handshakeComplete = false; + + client.on('handshake', mustCall((info) => { + + assert.strictEqual( + info.kex, + 'mlkem768x25519-sha256', + `Wrong KEX algorithm: ${info.kex}` + ); + handshakeComplete = true; + + })).on('ready', mustCall(() => { + + assert(handshakeComplete, 'handshake should complete before ready'); + client.exec(execCommand, mustCall((err, stream) => { + + assert(!err, `Unexpected exec error: ${err}`); + + stream.on('exit', mustCall((code, _) => { + assert.strictEqual(code, successfulExit, `Wrong exit code: ${code}`); + client.end(); + })).resume(); + + })); + + })); + } + + { + const { client, server } = setup_( + 'should fail if server accepts mlkem768x25519-sha256 and client' + + 'uses something different', + { + client: { + ...clientCfg, + algorithms: { kex: ['curve25519-sha256'] } + }, + server: { + ...serverCfg, + algorithms: { kex: ['mlkem768x25519-sha256'] } + }, + + noForceClientReady: true, + noForceServerReady: true, + }, + ); + + client.removeAllListeners('error'); + + function onError(err) { + assert.strictEqual(err.level, 'handshake'); + assert( + /no matching key exchange/i.test(err.message), + 'Wrong error message' + ); + } + + server.on('connection', mustCall((conn) => { + conn.removeAllListeners('error'); + + conn.on('authentication', mustNotCall()) + .on('ready', mustNotCall()) + .on('handshake', mustNotCall()) + .on('error', mustCall(onError)) + .on('close', mustCall(() => { })); + })); + + client.on('ready', mustNotCall()) + .on('error', mustCall(onError)) + .on('close', mustCall(() => { })); + } + + { + const { client, server } = setup_( + 'should fail if client uses mlkem768x25519-sha256 and server' + + 'accepts something different', + { + client: { + ...clientCfg, + algorithms: { kex: ['mlkem768x25519-sha256'] } + }, + server: { + ...serverCfg, + algorithms: { kex: ['curve25519-sha256'] } + }, + + noForceClientReady: true, + noForceServerReady: true, + }, + ); + + client.removeAllListeners('error'); + + function onError(err) { + assert.strictEqual(err.level, 'handshake'); + assert( + /no matching key exchange/i.test(err.message), + 'Wrong error message' + ); + } + + server.on('connection', mustCall((conn) => { + conn.removeAllListeners('error'); + + conn.on('authentication', mustNotCall()) + .on('ready', mustNotCall()) + .on('handshake', mustNotCall()) + .on('error', mustCall(onError)) + .on('close', mustCall(() => { })); + })); + + client.on('ready', mustNotCall()) + .on('error', mustCall(onError)) + .on('close', mustCall(() => { })); + } + + { + const { client, server } = setup_( + 'both sides should compute same secret when using mlkem768x25519-sha256', + { + client: { + ...clientCfg, + algorithms: { kex: ['mlkem768x25519-sha256'] } + }, + server: { + ...serverCfg, + algorithms: { kex: ['mlkem768x25519-sha256'] } + } + }, + ); + + let clientK_PQ = null; + let clientK_CL = null; + let serverK_PQ = null; + let serverK_CL = null; + + server.on('connection', mustCall((conn) => { + conn.on('handshake', mustCall((_) => { + + const kex = conn._protocol._kex; + serverK_PQ = kex.K_PQ; + serverK_CL = kex.K_CL; + + })).on('authentication', mustCall((ctx) => { + ctx.accept(); + })).on('ready', mustCall(() => { + + assert(serverK_PQ !== null, 'server did not generate K_PQ'); + assert(serverK_CL !== null, 'server did not generate K_CL'); + + assert( + clientK_PQ.equals(serverK_PQ), + 'K_PQ mismatch' + ); + assert( + clientK_CL.equals(serverK_CL), + 'K_CL mismatch' + ); + + conn.end(); + })); + })); + + client.on('handshake', mustCall(() => { + + const kex = client._protocol._kex; + clientK_PQ = kex.K_PQ; + clientK_CL = kex.K_CL; + + })).on('ready', mustCall(() => { + + assert(clientK_PQ !== null, 'client did not generate K_PQ'); + assert(clientK_CL !== null, 'client did not generate K_CL'); + assert( + clientK_PQ.equals(serverK_PQ), + 'K_PQ mismatch between client and server' + ); + assert( + clientK_CL.equals(serverK_CL), + 'K_CL mismatch between client and server' + ); + + })); + } +} From 5e0c4ee498b19146220d15c7daf16533eca8ed90 Mon Sep 17 00:00:00 2001 From: Lucas Queiroz Date: Thu, 30 Oct 2025 12:02:50 -0300 Subject: [PATCH 12/14] refactor: applies suggestions --- lib/protocol/constants.js | 27 +++++-------- lib/protocol/kex.js | 69 ++++++++++++--------------------- test/test-misc-client-server.js | 69 --------------------------------- 3 files changed, 34 insertions(+), 131 deletions(-) diff --git a/lib/protocol/constants.js b/lib/protocol/constants.js index ba9b91e4..6fb70b87 100644 --- a/lib/protocol/constants.js +++ b/lib/protocol/constants.js @@ -34,30 +34,21 @@ const curve25519Supported = (typeof crypto.diffieHellman === 'function' const mlkemSupported = (() => { try { - if ( - !curve25519Supported - || typeof crypto.encapsulate !== 'function' - || typeof crypto.decapsulate !== 'function' - ) + if (!curve25519Supported + || typeof crypto.encapsulate !== 'function' + || typeof crypto.decapsulate !== 'function') { return false; - - const algorithm = 'ml-kem-768'; - const test_clientKeys = crypto.generateKeyPairSync(algorithm); - - const { ciphertext, sharedKey } = crypto.encapsulate( - test_clientKeys.publicKey + } + const keys = crypto.generateKeyPairSync('ml-kem-768'); + const { ciphertext } = crypto.encapsulate( + keys.publicKey ); - const sharedSecret = crypto.decapsulate( - test_clientKeys.privateKey, + crypto.decapsulate( + keys.privateKey, ciphertext ); - for (let i = 0; i < sharedSecret.length && i < sharedKey.length; i++) { - if (sharedSecret[i] !== sharedKey[i]) - return false; - } - return true; } catch { return false; diff --git a/lib/protocol/kex.js b/lib/protocol/kex.js index 0aa274d8..d8a9a54d 100644 --- a/lib/protocol/kex.js +++ b/lib/protocol/kex.js @@ -624,6 +624,7 @@ const createKeyExchange = (() => { // "H" const exchangeHash = hash.digest(); + if (!isServer) { bufferParser.init(this._sig, 0); const sigType = bufferParser.readString(true); @@ -1594,14 +1595,6 @@ const createKeyExchange = (() => { this._x25519Keys = null; this._mlkemKeys = null; - this._curve25519KeySize = 32; - this._encapsulationKeySize = 1184; - this._decapsulationKeySize = 2400; - this._ciphertextSize = 1088; - - this.K_PQ = null; - this.K_CL = null; - this._S_CT2 = null; // needed for server } @@ -1611,7 +1604,6 @@ const createKeyExchange = (() => { if (!this._mlkemKeys) this._mlkemKeys = generateKeyPairSync('ml-kem-768'); - } convertPublicKey(key) { @@ -1630,11 +1622,11 @@ const createKeyExchange = (() => { // client const C_PK1 = this._x25519Keys.publicKey .export({ type: 'spki', format: 'der' }) - .slice(-this._curve25519KeySize); + .slice(-32); const C_PK2 = this._mlkemKeys.publicKey .export({ type: 'spki', format: 'der' }) - .slice(-this._encapsulationKeySize); + .slice(-1184); return Buffer.concat([C_PK2, C_PK1]); } @@ -1646,7 +1638,7 @@ const createKeyExchange = (() => { // one that initiates the kex const S_PK1 = this._x25519Keys.publicKey .export({ type: 'spki', format: 'der' }) - .slice(-this._curve25519KeySize); + .slice(-32); return Buffer.concat([this._S_CT2, S_PK1]); } @@ -1664,10 +1656,12 @@ const createKeyExchange = (() => { asnWriter.writeByte(0x00); // XXX: hack to write a raw buffer without a tag -- yuck asnWriter._ensure(key.length); - key.copy(asnWriter._buf, + key.copy( + asnWriter._buf, asnWriter._offset, 0, - key.length); + key.length + ); asnWriter._offset += key.length; asnWriter.endSequence(); asnWriter.endSequence(); @@ -1683,17 +1677,14 @@ const createKeyExchange = (() => { // server const C_PK2 = otherPublicKey.slice( 0, - this._encapsulationKeySize - ); - const C_PK1 = otherPublicKey.slice( - this._encapsulationKeySize + 1184 ); + const C_PK1 = otherPublicKey.slice(1184); if (C_PK1.length !== 32) { - const expected = this._curve25519KeySize; const got = C_PK1.length; throw new Error( - `Invalid C_PK1 length: expected ${expected}, got ${got}` + `Invalid C_PK1 length: expected 32, got ${got}` ); } @@ -1708,8 +1699,6 @@ const createKeyExchange = (() => { }), }); - this.K_CL = K_CL; - const templateDER = this._mlkemKeys.publicKey .export({ type: 'spki', format: 'der' }); @@ -1719,12 +1708,12 @@ const createKeyExchange = (() => { C_PK2_DER, 0, 0, - templateDER.length - this._encapsulationKeySize + templateDER.length - 1184 ); C_PK2.copy( C_PK2_DER, - templateDER.length - this._encapsulationKeySize + templateDER.length - 1184 ); const { sharedKey: K_PQ, ciphertext: S_CT2 } = encapsulate( @@ -1735,27 +1724,24 @@ const createKeyExchange = (() => { }) ); - this.K_PQ = K_PQ; - this._S_CT2 = S_CT2; - + // K = HASH(K_PQ || K_CL) from 2.4 on draft const K = createHash('sha256') - .update(K_PQ) - .update(K_CL) - .digest(); - + .update(K_PQ) + .update(K_CL) + .digest(); + return K; } // client - const S_CT2 = otherPublicKey.slice(0, this._ciphertextSize); - const S_PK1 = otherPublicKey.slice(this._ciphertextSize); + const S_CT2 = otherPublicKey.slice(0, 1088); + const S_PK1 = otherPublicKey.slice(1088); if (S_PK1.length !== 32) { - const expected = this._curve25519KeySize; const got = S_PK1.length; throw new Error( - `Invalid S_PK1 length: expected ${expected}, got ${got}` + `Invalid S_PK1 length: expected 32, got ${got}` ); } @@ -1770,27 +1756,22 @@ const createKeyExchange = (() => { }), }); - this.K_CL = K_CL; - const K_PQ = decapsulate( this._mlkemKeys.privateKey, S_CT2 ); - this.K_PQ = K_PQ; // K = HASH(K_PQ || K_CL) from 2.4 on draft const K = createHash('sha256') - .update(K_PQ) - .update(K_CL) - .digest(); - - return K; + .update(K_PQ) + .update(K_CL) + .digest(); + return K; } catch (error) { return error; } - } } diff --git a/test/test-misc-client-server.js b/test/test-misc-client-server.js index b570cd25..65bac20d 100644 --- a/test/test-misc-client-server.js +++ b/test/test-misc-client-server.js @@ -1621,73 +1621,4 @@ if (mlkemSupported) { .on('error', mustCall(onError)) .on('close', mustCall(() => { })); } - - { - const { client, server } = setup_( - 'both sides should compute same secret when using mlkem768x25519-sha256', - { - client: { - ...clientCfg, - algorithms: { kex: ['mlkem768x25519-sha256'] } - }, - server: { - ...serverCfg, - algorithms: { kex: ['mlkem768x25519-sha256'] } - } - }, - ); - - let clientK_PQ = null; - let clientK_CL = null; - let serverK_PQ = null; - let serverK_CL = null; - - server.on('connection', mustCall((conn) => { - conn.on('handshake', mustCall((_) => { - - const kex = conn._protocol._kex; - serverK_PQ = kex.K_PQ; - serverK_CL = kex.K_CL; - - })).on('authentication', mustCall((ctx) => { - ctx.accept(); - })).on('ready', mustCall(() => { - - assert(serverK_PQ !== null, 'server did not generate K_PQ'); - assert(serverK_CL !== null, 'server did not generate K_CL'); - - assert( - clientK_PQ.equals(serverK_PQ), - 'K_PQ mismatch' - ); - assert( - clientK_CL.equals(serverK_CL), - 'K_CL mismatch' - ); - - conn.end(); - })); - })); - - client.on('handshake', mustCall(() => { - - const kex = client._protocol._kex; - clientK_PQ = kex.K_PQ; - clientK_CL = kex.K_CL; - - })).on('ready', mustCall(() => { - - assert(clientK_PQ !== null, 'client did not generate K_PQ'); - assert(clientK_CL !== null, 'client did not generate K_CL'); - assert( - clientK_PQ.equals(serverK_PQ), - 'K_PQ mismatch between client and server' - ); - assert( - clientK_CL.equals(serverK_CL), - 'K_CL mismatch between client and server' - ); - - })); - } } From 722d0f2ae6e2282bdc03d383ae1d061fe28593a4 Mon Sep 17 00:00:00 2001 From: Lucas Queiroz <86672557+lucasqueiroz23@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:34:28 -0300 Subject: [PATCH 13/14] refactor: apply suggestions from code review Co-authored-by: mscdex --- lib/protocol/kex.js | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/lib/protocol/kex.js b/lib/protocol/kex.js index d8a9a54d..df04d8bf 100644 --- a/lib/protocol/kex.js +++ b/lib/protocol/kex.js @@ -1675,10 +1675,7 @@ const createKeyExchange = (() => { const isServer = this._protocol._server; if (isServer && !this._S_CT2) { // server - const C_PK2 = otherPublicKey.slice( - 0, - 1184 - ); + const C_PK2 = otherPublicKey.slice(0, 1184); const C_PK1 = otherPublicKey.slice(1184); if (C_PK1.length !== 32) { @@ -1704,17 +1701,9 @@ const createKeyExchange = (() => { const C_PK2_DER = Buffer.allocUnsafe(templateDER.length); - templateDER.copy( - C_PK2_DER, - 0, - 0, - templateDER.length - 1184 - ); + templateDER.copy(C_PK2_DER, 0, 0, templateDER.length - 1184); - C_PK2.copy( - C_PK2_DER, - templateDER.length - 1184 - ); + C_PK2.copy(C_PK2_DER, templateDER.length - 1184); const { sharedKey: K_PQ, ciphertext: S_CT2 } = encapsulate( createPublicKey({ @@ -1756,10 +1745,7 @@ const createKeyExchange = (() => { }), }); - const K_PQ = decapsulate( - this._mlkemKeys.privateKey, - S_CT2 - ); + const K_PQ = decapsulate(this._mlkemKeys.privateKey, S_CT2); // K = HASH(K_PQ || K_CL) from 2.4 on draft const K = createHash('sha256') From c93bccd9b9d0557d49de21bf6b8cece5c4cadde6 Mon Sep 17 00:00:00 2001 From: Lucas Queiroz Date: Thu, 30 Oct 2025 15:38:32 -0300 Subject: [PATCH 14/14] refactor: imports in alphabetic order --- lib/protocol/kex.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/protocol/kex.js b/lib/protocol/kex.js index df04d8bf..75252c56 100644 --- a/lib/protocol/kex.js +++ b/lib/protocol/kex.js @@ -6,11 +6,11 @@ const { createECDH, createHash, createPublicKey, + decapsulate, diffieHellman, + encapsulate, generateKeyPairSync, randomFillSync, - encapsulate, - decapsulate, } = require('crypto'); const { Ber } = require('asn1'); @@ -18,7 +18,6 @@ const { Ber } = require('asn1'); const { COMPAT, curve25519Supported, - mlkemSupported, DEFAULT_KEX, DEFAULT_SERVER_HOST_KEY, DEFAULT_CIPHER, @@ -26,6 +25,7 @@ const { DEFAULT_COMPRESSION, DISCONNECT_REASON, MESSAGE, + mlkemSupported, } = require('./constants.js'); const { CIPHER_INFO,