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` diff --git a/lib/protocol/constants.js b/lib/protocol/constants.js index ad775925..6fb70b87 100644 --- a/lib/protocol/constants.js +++ b/lib/protocol/constants.js @@ -32,6 +32,29 @@ const curve25519Supported = (typeof crypto.diffieHellman === 'function' && typeof crypto.generateKeyPairSync === 'function' && typeof crypto.createPublicKey === 'function'); +const mlkemSupported = (() => { + try { + if (!curve25519Supported + || typeof crypto.encapsulate !== 'function' + || typeof crypto.decapsulate !== 'function') { + return false; + } + const keys = crypto.generateKeyPairSync('ml-kem-768'); + const { ciphertext } = crypto.encapsulate( + keys.publicKey + ); + + crypto.decapsulate( + keys.privateKey, + ciphertext + ); + + return true; + } catch { + return false; + } +})(); + const DEFAULT_KEX = [ // https://tools.ietf.org/html/rfc5656#section-10.1 'ecdh-sha2-nistp256', @@ -52,6 +75,11 @@ if (curve25519Supported) { DEFAULT_KEX.unshift('curve25519-sha256'); DEFAULT_KEX.unshift('curve25519-sha256@libssh.org'); } + +if (mlkemSupported) + DEFAULT_KEX.unshift('mlkem768x25519-sha256'); + + const SUPPORTED_KEX = DEFAULT_KEX.concat([ // https://tools.ietf.org/html/rfc4419#section-4 'diffie-hellman-group-exchange-sha1', @@ -189,6 +217,9 @@ module.exports = { KEXECDH_INIT: 30, KEXECDH_REPLY: 31, + KEX_HYBRID_INIT: 30, + KEX_HYBRID_REPLY: 31, + // User auth protocol -- generic (50-59) USERAUTH_REQUEST: 50, USERAUTH_FAILURE: 51, @@ -349,6 +380,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..75252c56 100644 --- a/lib/protocol/kex.js +++ b/lib/protocol/kex.js @@ -6,7 +6,9 @@ const { createECDH, createHash, createPublicKey, + decapsulate, diffieHellman, + encapsulate, generateKeyPairSync, randomFillSync, } = require('crypto'); @@ -23,6 +25,7 @@ const { DEFAULT_COMPRESSION, DISCONNECT_REASON, MESSAGE, + mlkemSupported, } = require('./constants.js'); const { CIPHER_INFO, @@ -1580,6 +1583,184 @@ 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._S_CT2 = null; // needed for server + } + + generateKeys() { + if (!this._x25519Keys) + this._x25519Keys = generateKeyPairSync('x25519'); + + if (!this._mlkemKeys) + this._mlkemKeys = generateKeyPairSync('ml-kem-768'); + } + + 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 isServer = this._protocol._server; + + if (!isServer) { + // client + const C_PK1 = this._x25519Keys.publicKey + .export({ type: 'spki', format: 'der' }) + .slice(-32); + + const C_PK2 = this._mlkemKeys.publicKey + .export({ type: 'spki', format: 'der' }) + .slice(-1184); + + return Buffer.concat([C_PK2, C_PK1]); + } + + // server + + // 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); + + return Buffer.concat([this._S_CT2, S_PK1]); + } + + 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; + } + + computeSecret(otherPublicKey) { + try { + this.generateKeys(); + const isServer = this._protocol._server; + if (isServer && !this._S_CT2) { + // server + const C_PK2 = otherPublicKey.slice(0, 1184); + const C_PK1 = otherPublicKey.slice(1184); + + if (C_PK1.length !== 32) { + const got = C_PK1.length; + throw new Error( + `Invalid C_PK1 length: expected 32, got ${got}` + ); + } + + const x25519SPKI = this.buildX25519SPKI(C_PK1); + + 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 - 1184); + + C_PK2.copy(C_PK2_DER, templateDER.length - 1184); + + const { sharedKey: K_PQ, ciphertext: S_CT2 } = encapsulate( + createPublicKey({ + key: C_PK2_DER, + type: 'spki', + format: 'der', + }) + ); + + 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, 1088); + const S_PK1 = otherPublicKey.slice(1088); + + if (S_PK1.length !== 32) { + const got = S_PK1.length; + throw new Error( + `Invalid S_PK1 length: expected 32, 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', + }), + }); + + const K_PQ = decapsulate(this._mlkemKeys.privateKey, 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; + + } catch (error) { + return error; + } + } + } + return (negotiated, ...args) => { if (typeof negotiated !== 'object' || negotiated === null) throw new Error('Invalid negotiated argument'); @@ -1587,6 +1768,11 @@ const createKeyExchange = (() => { if (typeof kexType === 'string') { args = [negotiated, ...args]; switch (kexType) { + case 'mlkem768x25519-sha256': + if (!mlkemSupported) + break; + return new MLKEMx25519Exchange('sha256', ...args); + case 'curve25519-sha256': case 'curve25519-sha256@libssh.org': if (!curve25519Supported) diff --git a/test/test-misc-client-server.js b/test/test-misc-client-server.js index 2dd5a29d..65bac20d 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,165 @@ 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(() => { })); + } +}