Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+)
* `[email protected]` (node v14.0.0+)
* `ecdh-sha2-nistp256`
Expand Down
32 changes: 32 additions & 0 deletions lib/protocol/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -52,6 +75,11 @@ if (curve25519Supported) {
DEFAULT_KEX.unshift('curve25519-sha256');
DEFAULT_KEX.unshift('[email protected]');
}

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',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -349,6 +380,7 @@ module.exports = {

curve25519Supported,
eddsaSupported,
mlkemSupported,
};

module.exports.DISCONNECT_REASON_BY_VALUE =
Expand Down
200 changes: 200 additions & 0 deletions lib/protocol/kex.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ const {
diffieHellman,
generateKeyPairSync,
randomFillSync,
encapsulate,
decapsulate,
} = require('crypto');

const { Ber } = require('asn1');

const {
COMPAT,
curve25519Supported,
mlkemSupported,
DEFAULT_KEX,
DEFAULT_SERVER_HOST_KEY,
DEFAULT_CIPHER,
Expand Down Expand Up @@ -1580,13 +1583,210 @@ 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');
const kexType = negotiated.kex;
if (typeof kexType === 'string') {
args = [negotiated, ...args];
switch (kexType) {
case 'mlkem768x25519-sha256':
if (!mlkemSupported)
break;
return new MLKEMx25519Exchange('sha256', ...args);

case 'curve25519-sha256':
case '[email protected]':
if (!curve25519Supported)
Expand Down
Loading