lemon-tls 0.6.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +253 -20
- package/index.d.ts +94 -6
- package/index.js +126 -0
- package/package.json +2 -7
- package/src/compat.js +316 -45
- package/src/crypto.js +33 -3
- package/src/dtls_session.js +477 -41
- package/src/dtls_socket.js +32 -0
- package/src/node_options.js +138 -0
- package/src/record.js +210 -27
- package/src/secure_context.js +49 -4
- package/src/session/cert_compression.js +157 -0
- package/src/session/ecdh.js +237 -10
- package/src/session/ech.js +715 -0
- package/src/session/hpke.js +414 -0
- package/src/session/message.js +44 -5
- package/src/session/signing.js +56 -3
- package/src/session/ticket.js +44 -28
- package/src/tls_session.js +3003 -217
- package/src/tls_socket.js +839 -149
- package/src/utils.js +75 -2
- package/src/wire.js +220 -13
package/src/secure_context.js
CHANGED
|
@@ -60,17 +60,28 @@ function makeX509FromDerOrPem(buf) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function makePrivateKeyFromDerOrPem(buf, passphrase) {
|
|
63
|
+
// node:crypto rejects an explicit `passphrase: null` with ERR_INVALID_ARG_VALUE
|
|
64
|
+
// — for an unencrypted key the property must be ABSENT, not present-and-null.
|
|
65
|
+
// Callers that forward an optional option straight through would otherwise
|
|
66
|
+
// break every unencrypted key, which is the overwhelmingly common case.
|
|
67
|
+
let pass = (passphrase === null || passphrase === undefined) ? undefined : passphrase;
|
|
68
|
+
|
|
63
69
|
if (isPEM(buf)) {
|
|
64
|
-
|
|
70
|
+
let opts = { key: buf, format: 'pem' };
|
|
71
|
+
if (pass !== undefined) opts.passphrase = pass;
|
|
72
|
+
return crypto.createPrivateKey(opts);
|
|
65
73
|
} else {
|
|
66
74
|
let der = Buffer.from(buf), keyObj = null;
|
|
67
75
|
try {
|
|
68
|
-
keyObj = crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8'
|
|
76
|
+
keyObj = crypto.createPrivateKey(Object.assign({ key: der, format: 'der', type: 'pkcs8' },
|
|
77
|
+
pass !== undefined ? { passphrase: pass } : {}));
|
|
69
78
|
} catch (e1) {
|
|
70
79
|
try {
|
|
71
|
-
keyObj = crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs1'
|
|
80
|
+
keyObj = crypto.createPrivateKey(Object.assign({ key: der, format: 'der', type: 'pkcs1' },
|
|
81
|
+
pass !== undefined ? { passphrase: pass } : {}));
|
|
72
82
|
} catch (e2) {
|
|
73
|
-
keyObj = crypto.createPrivateKey({ key: der, format: 'der', type: 'sec1'
|
|
83
|
+
keyObj = crypto.createPrivateKey(Object.assign({ key: der, format: 'der', type: 'sec1' },
|
|
84
|
+
pass !== undefined ? { passphrase: pass } : {}));
|
|
74
85
|
}
|
|
75
86
|
}
|
|
76
87
|
return keyObj;
|
|
@@ -106,6 +117,40 @@ function dedupeDerArray(arr) {
|
|
|
106
117
|
function createSecureContext(options) {
|
|
107
118
|
if (!options) options = {};
|
|
108
119
|
|
|
120
|
+
// Several key/certificate pairs — node:tls's arrays: `key` an array of
|
|
121
|
+
// keys (or { pem, passphrase }), `cert` an array of chains, one per key —
|
|
122
|
+
// for a server that holds certificates of more than one kind: an ML-DSA one
|
|
123
|
+
// for clients that ask for post-quantum authentication, an ECDSA or RSA one
|
|
124
|
+
// for everyone else. Each key is paired with the chain whose leaf carries
|
|
125
|
+
// its public key, in whatever order they were given; the first key's pair
|
|
126
|
+
// is the default, the others `alternates`. Which one a handshake presents
|
|
127
|
+
// is the client's signature_algorithms' to decide (tls_session).
|
|
128
|
+
if (Array.isArray(options.key) && options.key.length > 1) {
|
|
129
|
+
let certs = Array.isArray(options.cert) ? options.cert : [options.cert];
|
|
130
|
+
let contexts = options.key.map(function (k, i) {
|
|
131
|
+
let keyIn = (k && typeof k === 'object' && !Buffer.isBuffer(k) && !(k instanceof Uint8Array) && k.pem) ? k.pem : k;
|
|
132
|
+
let pass = (k && typeof k === 'object' && k.passphrase != null) ? k.passphrase : options.passphrase;
|
|
133
|
+
let pub = crypto.createPublicKey(makePrivateKeyFromDerOrPem(readMaybeFile(keyIn), pass)).export({ type: 'spki', format: 'der' });
|
|
134
|
+
let cert = null;
|
|
135
|
+
for (let j = 0; j < certs.length && cert === null; j++) {
|
|
136
|
+
let raw = readMaybeFile(certs[j]);
|
|
137
|
+
let first = isPEM(raw) ? splitPEMBlocks(raw.toString('utf8')).filter(function (b) { return b.type.indexOf('CERTIFICATE') !== -1; })[0] : { der: new Uint8Array(raw) };
|
|
138
|
+
if (!first) continue;
|
|
139
|
+
let leafPub = makeX509FromDerOrPem(first.der).publicKey.export({ type: 'spki', format: 'der' });
|
|
140
|
+
if (Buffer.compare(Buffer.from(leafPub), Buffer.from(pub)) === 0) cert = certs[j];
|
|
141
|
+
}
|
|
142
|
+
if (cert === null) throw new Error('createSecureContext: no certificate in `cert` for key ' + i);
|
|
143
|
+
return createSecureContext(Object.assign({}, options, { key: keyIn, passphrase: pass, cert: cert }));
|
|
144
|
+
});
|
|
145
|
+
let primary = contexts[0];
|
|
146
|
+
primary.alternates = contexts.slice(1).map(function (c) {
|
|
147
|
+
return { certificateChain: c.certificateChain, privateKey: c.privateKey, keyObj: c.keyObj, certObjs: c.certObjs };
|
|
148
|
+
});
|
|
149
|
+
return primary;
|
|
150
|
+
}
|
|
151
|
+
if (Array.isArray(options.key)) options = Object.assign({}, options, { key: options.key[0] && options.key[0].pem ? options.key[0].pem : options.key[0] });
|
|
152
|
+
if (Array.isArray(options.cert) && options.cert.length === 1) options = Object.assign({}, options, { cert: options.cert[0] });
|
|
153
|
+
|
|
109
154
|
// --- תעודות (כולל שרשרת בתוך cert אם קיימת) ---
|
|
110
155
|
let certBlocksDer = [];
|
|
111
156
|
let certObjs = [];
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TLS Certificate Compression (RFC 8879), TLS 1.3 and DTLS 1.3.
|
|
3
|
+
*
|
|
4
|
+
* A certificate chain is most of what a server sends in its handshake —
|
|
5
|
+
* 3-5 KB for a public CA's chain, against a few hundred bytes for the rest.
|
|
6
|
+
* Compressed it is often half that or less: the certificates repeat names,
|
|
7
|
+
* extensions and OIDs. Over QUIC it decides how many round trips the
|
|
8
|
+
* handshake takes: before the client's address is validated a server may
|
|
9
|
+
* send three times what it received (RFC 9000 §8.1), ~3.6 KB for a 1200-byte
|
|
10
|
+
* Initial, and a chain that does not fit waits a round trip for the
|
|
11
|
+
* client's next packet. Browsers offer it (Chrome: brotli; Firefox: zlib,
|
|
12
|
+
* brotli, zstd).
|
|
13
|
+
*
|
|
14
|
+
* The client lists the algorithms it can decompress (compress_certificate,
|
|
15
|
+
* extension 27); the server, holding one it can compress with, sends
|
|
16
|
+
* CompressedCertificate (handshake type 25) in place of Certificate. The
|
|
17
|
+
* same works the other way for a client certificate, when the server's
|
|
18
|
+
* CertificateRequest carries the extension.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import zlib from 'node:zlib';
|
|
22
|
+
import crypto from 'node:crypto';
|
|
23
|
+
|
|
24
|
+
function brotliOptions(b) {
|
|
25
|
+
return { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11, [zlib.constants.BROTLI_PARAM_SIZE_HINT]: b.length } };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// RFC 8879 §3, and the IANA registry: zlib 1, brotli 2, zstd 3.
|
|
29
|
+
const ALGORITHMS = {
|
|
30
|
+
1: { name: 'zlib',
|
|
31
|
+
compress: (b) => zlib.deflateSync(b, { level: 9 }),
|
|
32
|
+
compressAsync: (b, cb) => zlib.deflate(b, { level: 9 }, cb),
|
|
33
|
+
decompress: (b, max) => zlib.inflateSync(b, { maxOutputLength: max }) },
|
|
34
|
+
2: { name: 'brotli',
|
|
35
|
+
compress: (b) => zlib.brotliCompressSync(b, brotliOptions(b)),
|
|
36
|
+
compressAsync: (b, cb) => zlib.brotliCompress(b, brotliOptions(b), cb),
|
|
37
|
+
decompress: (b, max) => zlib.brotliDecompressSync(b, { maxOutputLength: max }) },
|
|
38
|
+
};
|
|
39
|
+
// zstd where node:zlib has it (Node 22.15+, 23.8+).
|
|
40
|
+
if (typeof zlib.zstdCompressSync === 'function' && typeof zlib.zstdDecompressSync === 'function') {
|
|
41
|
+
ALGORITHMS[3] = { name: 'zstd',
|
|
42
|
+
compress: (b) => zlib.zstdCompressSync(b),
|
|
43
|
+
compressAsync: (b, cb) => zlib.zstdCompress(b, cb),
|
|
44
|
+
decompress: (b, max) => zlib.zstdDecompressSync(b, { maxOutputLength: max }) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const BY_NAME = {};
|
|
48
|
+
for (const code in ALGORITHMS) BY_NAME[ALGORITHMS[code].name] = Number(code);
|
|
49
|
+
|
|
50
|
+
/** The algorithms this build can use, in its preference (best ratio first). */
|
|
51
|
+
function supported_cert_compression() {
|
|
52
|
+
return [2, 3, 1].filter((c) => !!ALGORITHMS[c]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* An option naming algorithms — ['brotli', 'zlib'], codes, or true (all) /
|
|
57
|
+
* false (none) — as codes this build supports. Throws on a name it does not
|
|
58
|
+
* know; one it knows but cannot run here (zstd on an older Node) is left out.
|
|
59
|
+
*/
|
|
60
|
+
function parse_cert_compression(value, what) {
|
|
61
|
+
if (value === undefined || value === null) return null;
|
|
62
|
+
if (value === false) return [];
|
|
63
|
+
if (value === true) return supported_cert_compression();
|
|
64
|
+
if (!Array.isArray(value)) throw new TypeError((what || 'certificateCompression') + ' must be an array of algorithms (brotli, zlib, zstd), true or false');
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const v of value) {
|
|
67
|
+
let code = typeof v === 'number' ? v : BY_NAME[String(v).toLowerCase()];
|
|
68
|
+
if (code === undefined && String(v).toLowerCase() === 'zstd') continue; // not in this Node
|
|
69
|
+
if (code === undefined || (code !== 1 && code !== 2 && code !== 3)) throw new TypeError((what || 'certificateCompression') + ': unknown algorithm ' + JSON.stringify(v));
|
|
70
|
+
if (ALGORITHMS[code] && out.indexOf(code) < 0) out.push(code);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A chain is compressed once per algorithm: the server sends the same bytes
|
|
76
|
+
// on every handshake, and brotli at quality 11 is not cheap. Keyed by a hash
|
|
77
|
+
// of the uncompressed message; bounded.
|
|
78
|
+
const cache = new Map();
|
|
79
|
+
const CACHE_MAX = 64;
|
|
80
|
+
|
|
81
|
+
function cache_key(body, alg) {
|
|
82
|
+
return alg + ':' + crypto.createHash('sha256').update(body).digest('hex');
|
|
83
|
+
}
|
|
84
|
+
function cache_put(key, out) {
|
|
85
|
+
cache.set(key, out);
|
|
86
|
+
if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `body` (an encoded Certificate message body) compressed with `alg`. */
|
|
90
|
+
function compress_certificate_body(body, alg) {
|
|
91
|
+
const a = ALGORITHMS[alg];
|
|
92
|
+
if (!a) return null;
|
|
93
|
+
const key = cache_key(body, alg);
|
|
94
|
+
let out = cache.get(key);
|
|
95
|
+
if (out) return out;
|
|
96
|
+
out = new Uint8Array(a.compress(Buffer.from(body)));
|
|
97
|
+
cache_put(key, out);
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The same, ahead of the first handshake and off the event loop (zlib's
|
|
102
|
+
// thread pool): what that handshake would have done synchronously — brotli
|
|
103
|
+
// at quality 11 is 4 ms for a 400-byte chain and 18 ms for a 9 KB one, the
|
|
104
|
+
// first time a server sends it, while that client waits — is in the cache
|
|
105
|
+
// when it comes. A chain already there, or on its way, is not done twice.
|
|
106
|
+
// callback() when every algorithm's is in; a failure leaves that one to
|
|
107
|
+
// the handshake, as before.
|
|
108
|
+
const pending = new Map(); // key → callbacks waiting on it
|
|
109
|
+
function precompress_certificate_body(body, algs, callback) {
|
|
110
|
+
let left = 1;
|
|
111
|
+
const done = () => { if (--left === 0 && callback) callback(); };
|
|
112
|
+
for (const alg of algs) {
|
|
113
|
+
const a = ALGORITHMS[alg];
|
|
114
|
+
if (!a || !a.compressAsync) continue;
|
|
115
|
+
const key = cache_key(body, alg);
|
|
116
|
+
if (cache.has(key)) continue;
|
|
117
|
+
left++;
|
|
118
|
+
if (pending.has(key)) { pending.get(key).push(done); continue; }
|
|
119
|
+
pending.set(key, [done]);
|
|
120
|
+
a.compressAsync(Buffer.from(body), (err, out) => {
|
|
121
|
+
if (!err && !cache.has(key)) cache_put(key, new Uint8Array(out));
|
|
122
|
+
const waiters = pending.get(key) || []; pending.delete(key);
|
|
123
|
+
for (const w of waiters) w();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
done();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A CompressedCertificate's contents back to the Certificate message body,
|
|
131
|
+
* or null: an algorithm not ours, data that does not decompress, or a
|
|
132
|
+
* length other than the one declared (RFC 8879 §4: bad_certificate). Never
|
|
133
|
+
* more than the declared length is produced — nor more than `limit`.
|
|
134
|
+
*/
|
|
135
|
+
function decompress_certificate_body(alg, data, uncompressedLength, limit) {
|
|
136
|
+
const a = ALGORITHMS[alg];
|
|
137
|
+
if (!a) return null;
|
|
138
|
+
if (!(uncompressedLength > 0) || (limit && uncompressedLength > limit)) return null;
|
|
139
|
+
let out;
|
|
140
|
+
try { out = a.decompress(Buffer.from(data), uncompressedLength); }
|
|
141
|
+
catch (e) { return null; }
|
|
142
|
+
if (out.length !== uncompressedLength) return null;
|
|
143
|
+
return new Uint8Array(out);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function cert_compression_name(alg) {
|
|
147
|
+
return ALGORITHMS[alg] ? ALGORITHMS[alg].name : String(alg);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export {
|
|
151
|
+
supported_cert_compression,
|
|
152
|
+
parse_cert_compression,
|
|
153
|
+
compress_certificate_body,
|
|
154
|
+
precompress_certificate_body,
|
|
155
|
+
decompress_certificate_body,
|
|
156
|
+
cert_compression_name,
|
|
157
|
+
};
|
package/src/session/ecdh.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ECDH key generation and shared secret helpers using node:crypto.
|
|
3
|
-
*
|
|
3
|
+
* X25519, P-256 and P-384; ML-KEM alone and in hybrids with them (below).
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import * as crypto from 'crypto';
|
|
@@ -106,16 +106,153 @@ const NAMED_GROUP = {
|
|
|
106
106
|
X25519: 0x001d,
|
|
107
107
|
SECP256R1: 0x0017,
|
|
108
108
|
SECP384R1: 0x0018,
|
|
109
|
+
// Post-quantum. KEMs, not Diffie-Hellman: the server does not make a key
|
|
110
|
+
// pair of its own, it ENCAPSULATES to the client's — see kem_server_exchange.
|
|
111
|
+
// Hybrids (draft-ietf-tls-ecdhe-mlkem): ML-KEM (FIPS 203) and an ECDH
|
|
112
|
+
// group together, safe if either one holds. X25519MLKEM768 is what Chrome,
|
|
113
|
+
// Firefox, Safari and Cloudflare negotiate; the two NIST-curve hybrids are
|
|
114
|
+
// for deployments that must use approved curves.
|
|
115
|
+
X25519MLKEM768: 0x11ec,
|
|
116
|
+
SECP256R1MLKEM768: 0x11eb,
|
|
117
|
+
SECP384R1MLKEM1024: 0x11ed,
|
|
118
|
+
// ML-KEM alone (draft-ietf-tls-mlkem): no classical half. MLKEM1024 is
|
|
119
|
+
// CNSA 2.0's key exchange.
|
|
120
|
+
MLKEM512: 0x0200,
|
|
121
|
+
MLKEM768: 0x0201,
|
|
122
|
+
MLKEM1024: 0x0202,
|
|
109
123
|
};
|
|
110
124
|
|
|
111
|
-
|
|
112
|
-
|
|
125
|
+
// Each group's name — the IANA registry's, as OpenSSL and node:tls spell them.
|
|
126
|
+
const GROUP_NAMES = {
|
|
127
|
+
0x001d: 'X25519', 0x0017: 'P-256', 0x0018: 'P-384',
|
|
128
|
+
0x11ec: 'X25519MLKEM768', 0x11eb: 'SecP256r1MLKEM768', 0x11ed: 'SecP384r1MLKEM1024',
|
|
129
|
+
0x0200: 'MLKEM512', 0x0201: 'MLKEM768', 0x0202: 'MLKEM1024',
|
|
130
|
+
};
|
|
131
|
+
function group_name(group) { return GROUP_NAMES[group] || null; }
|
|
132
|
+
|
|
133
|
+
// ML-KEM's three parameter sets (FIPS 203 Table 3): encapsulation key and
|
|
134
|
+
// ciphertext sizes. The shared secret is 32 bytes for each.
|
|
135
|
+
const MLKEM = {
|
|
136
|
+
512: { type: 'ml-kem-512', ek: 800, ct: 768 },
|
|
137
|
+
768: { type: 'ml-kem-768', ek: 1184, ct: 1088 },
|
|
138
|
+
1024: { type: 'ml-kem-1024', ek: 1568, ct: 1568 },
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// The classical halves of the hybrids: a share's size, a key pair, a secret.
|
|
142
|
+
const ECDH_PART = {
|
|
143
|
+
x25519: { len: 32,
|
|
144
|
+
generate: function () { let priv = new Uint8Array(crypto.randomBytes(32)); return { private_key: priv, public_key: x25519_get_public_key(priv) }; },
|
|
145
|
+
shared: function (priv, pub) { return x25519_get_shared_secret(priv, pub); } },
|
|
146
|
+
p256: { len: 65, generate: function () { return p256_generate_keypair(); },
|
|
147
|
+
shared: function (priv, pub) { check_point(pub, 65); return p256_get_shared_secret(priv, pub); } },
|
|
148
|
+
p384: { len: 97, generate: function () { return p384_generate_keypair(); },
|
|
149
|
+
shared: function (priv, pub) { check_point(pub, 97); return p384_get_shared_secret(priv, pub); } },
|
|
150
|
+
};
|
|
151
|
+
function check_point(pub, len) {
|
|
152
|
+
if (pub.length !== len || pub[0] !== 0x04) throw new Error('the ECDH part must be a ' + len + '-byte uncompressed point');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The KEM groups: which ML-KEM, which ECDH half (none for ML-KEM alone), and
|
|
156
|
+
// which comes first — in X25519MLKEM768 the ML-KEM part leads, in both shares
|
|
157
|
+
// and in the shared secret (the draft's one exception, kept for the Kyber
|
|
158
|
+
// deployments); in the two NIST-curve hybrids the ECDH part leads.
|
|
159
|
+
const KEM_GROUPS = {
|
|
160
|
+
0x11ec: { kem: 768, ecdh: 'x25519', kemFirst: true },
|
|
161
|
+
0x11eb: { kem: 768, ecdh: 'p256', kemFirst: false },
|
|
162
|
+
0x11ed: { kem: 1024, ecdh: 'p384', kemFirst: false },
|
|
163
|
+
0x0200: { kem: 512 },
|
|
164
|
+
0x0201: { kem: 768 },
|
|
165
|
+
0x0202: { kem: 1024 },
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// ML-KEM comes from node:crypto (crypto.encapsulate / decapsulate) and from
|
|
169
|
+
// nothing else: Node 24.7+ on OpenSSL 3.5, and Electron once its Node has the
|
|
170
|
+
// BoringSSL wiring (Node 24.18+). A parameter set node:crypto lacks makes its
|
|
171
|
+
// groups simply not implemented — not offered, not accepted.
|
|
172
|
+
//
|
|
173
|
+
// Decided once per parameter set, by a real round trip rather than by the
|
|
174
|
+
// functions existing (on BoringSSL, crypto.encapsulate can be defined and
|
|
175
|
+
// still not know ML-KEM) — and the raw key's SPKI wrapping taken from the key
|
|
176
|
+
// made: a fixed DER prefix, as for X25519 above.
|
|
177
|
+
const MLKEM_SPKI_PREFIX = {};
|
|
178
|
+
function probe_mlkem(bits) {
|
|
179
|
+
if (typeof crypto.encapsulate !== 'function' || typeof crypto.decapsulate !== 'function') return false;
|
|
180
|
+
try {
|
|
181
|
+
let p = MLKEM[bits];
|
|
182
|
+
let kp = crypto.generateKeyPairSync(p.type);
|
|
183
|
+
let enc = crypto.encapsulate(kp.publicKey);
|
|
184
|
+
let dec = crypto.decapsulate(kp.privateKey, enc.ciphertext);
|
|
185
|
+
if (enc.ciphertext.length !== p.ct || Buffer.compare(Buffer.from(enc.sharedKey), Buffer.from(dec)) !== 0) return false;
|
|
186
|
+
let spki = kp.publicKey.export({ format: 'der', type: 'spki' });
|
|
187
|
+
MLKEM_SPKI_PREFIX[bits] = Buffer.from(spki.subarray(0, spki.length - p.ek));
|
|
188
|
+
return true;
|
|
189
|
+
} catch (e) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const MLKEM_SETS = { 512: probe_mlkem(512), 768: probe_mlkem(768), 1024: probe_mlkem(1024) };
|
|
194
|
+
const MLKEM_AVAILABLE = MLKEM_SETS[768];
|
|
195
|
+
|
|
196
|
+
// Import checks the key (FIPS 203 §7.2: every coefficient below q) and throws
|
|
197
|
+
// on a bad one — before any secret exists.
|
|
198
|
+
function mlkem_public_key(bits, raw) {
|
|
199
|
+
return crypto.createPublicKey({ key: Buffer.concat([MLKEM_SPKI_PREFIX[bits], Buffer.from(raw)]), format: 'der', type: 'spki' });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function kem_group_available(group) {
|
|
203
|
+
let d = KEM_GROUPS[group];
|
|
204
|
+
return !!(d && MLKEM_SETS[d.kem]);
|
|
205
|
+
}
|
|
206
|
+
// The sizes of a KEM group's shares: the client's (encapsulation key and
|
|
207
|
+
// ECDH public key) and the server's (ciphertext and ECDH public key).
|
|
208
|
+
function kem_sizes(group) {
|
|
209
|
+
let d = KEM_GROUPS[group], m = MLKEM[d.kem], e = d.ecdh ? ECDH_PART[d.ecdh].len : 0;
|
|
210
|
+
return { client: m.ek + e, server: m.ct + e, ek: m.ek, ct: m.ct, ecdh: e };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// A client's default offer: the hybrid post-quantum group first where
|
|
214
|
+
// node:crypto has ML-KEM, then the classical groups — what node:tls offers by
|
|
215
|
+
// default (OpenSSL 3.5: X25519MLKEM768 and X25519 shares) and Chrome and
|
|
216
|
+
// Firefox do. With the hybrid first, an X25519 share goes beside it
|
|
217
|
+
// (tls_session), so a classical server costs no extra round trip. The cost:
|
|
218
|
+
// a ClientHello of ~1.4 KB rather than ~0.3 (two QUIC Initial packets), and
|
|
219
|
+
// key generation of ~0.1 ms — node:crypto's, native. Without ML-KEM (Node
|
|
220
|
+
// before 24.7), the classical groups alone, X25519 first. The other
|
|
221
|
+
// post-quantum groups are there to ask for (ecdhCurve), not offered unasked:
|
|
222
|
+
// each is another 1–1.7 KB in every ClientHello.
|
|
223
|
+
const SUPPORTED_GROUPS = (MLKEM_AVAILABLE ? [NAMED_GROUP.X25519MLKEM768] : []).concat([
|
|
224
|
+
NAMED_GROUP.X25519, // preferred classical: fastest, no point validation pitfalls
|
|
113
225
|
NAMED_GROUP.SECP256R1,
|
|
114
226
|
NAMED_GROUP.SECP384R1,
|
|
115
|
-
];
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
// A server's default preference: every post-quantum group node:crypto can do,
|
|
230
|
+
// first — the hybrids (X25519MLKEM768 first: what browsers send), then
|
|
231
|
+
// ML-KEM alone, strongest first — then the classical ones. A client that
|
|
232
|
+
// offers any post-quantum group gets one (tls_session: its first, by its own
|
|
233
|
+
// order, with a HelloRetryRequest when it sent no share for it); any other
|
|
234
|
+
// client gets the classical group it asked for.
|
|
235
|
+
const SERVER_DEFAULT_GROUPS = [
|
|
236
|
+
NAMED_GROUP.X25519MLKEM768, NAMED_GROUP.SECP256R1MLKEM768, NAMED_GROUP.SECP384R1MLKEM1024,
|
|
237
|
+
NAMED_GROUP.MLKEM1024, NAMED_GROUP.MLKEM768, NAMED_GROUP.MLKEM512,
|
|
238
|
+
].filter(kem_group_available).concat([
|
|
239
|
+
NAMED_GROUP.X25519,
|
|
240
|
+
NAMED_GROUP.SECP256R1,
|
|
241
|
+
NAMED_GROUP.SECP384R1,
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
// Everything implemented here, on either side. A KEM group only where
|
|
245
|
+
// node:crypto has its ML-KEM — a list that names it anyway (a hardcoded server
|
|
246
|
+
// list, a client's options.groups) has it skipped, as with any unimplemented
|
|
247
|
+
// group.
|
|
248
|
+
const IMPLEMENTED_GROUPS = SERVER_DEFAULT_GROUPS;
|
|
116
249
|
|
|
117
250
|
function is_supported_group(group) {
|
|
118
|
-
return
|
|
251
|
+
return IMPLEMENTED_GROUPS.indexOf(group) >= 0;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function is_kem_group(group) {
|
|
255
|
+
return !!KEM_GROUPS[group];
|
|
119
256
|
}
|
|
120
257
|
|
|
121
258
|
/**
|
|
@@ -123,15 +260,72 @@ function is_supported_group(group) {
|
|
|
123
260
|
* Returns { private_key, public_key } or null for an unsupported group.
|
|
124
261
|
*/
|
|
125
262
|
function generate_keypair(group) {
|
|
126
|
-
if (group === NAMED_GROUP.X25519)
|
|
127
|
-
let priv = new Uint8Array(crypto.randomBytes(32));
|
|
128
|
-
return { private_key: priv, public_key: x25519_get_public_key(priv) };
|
|
129
|
-
}
|
|
263
|
+
if (group === NAMED_GROUP.X25519) return ECDH_PART.x25519.generate();
|
|
130
264
|
if (group === NAMED_GROUP.SECP256R1) return p256_generate_keypair();
|
|
131
265
|
if (group === NAMED_GROUP.SECP384R1) return p384_generate_keypair();
|
|
266
|
+
if (kem_group_available(group)) {
|
|
267
|
+
// The CLIENT side: an ML-KEM key pair, and an ECDH one for a hybrid; the
|
|
268
|
+
// share is the two public keys in the group's order.
|
|
269
|
+
let d = KEM_GROUPS[group], sz = kem_sizes(group);
|
|
270
|
+
let kem = crypto.generateKeyPairSync(MLKEM[d.kem].type);
|
|
271
|
+
let spki = kem.publicKey.export({ format: 'der', type: 'spki' });
|
|
272
|
+
let ek = spki.subarray(spki.length - sz.ek);
|
|
273
|
+
let ec = d.ecdh ? ECDH_PART[d.ecdh].generate() : null;
|
|
274
|
+
let pub = new Uint8Array(sz.client);
|
|
275
|
+
if (!ec) pub.set(ek, 0);
|
|
276
|
+
else if (d.kemFirst) { pub.set(ek, 0); pub.set(ec.public_key, sz.ek); }
|
|
277
|
+
else { pub.set(ec.public_key, 0); pub.set(ek, sz.ecdh); }
|
|
278
|
+
return { private_key: { kem: kem.privateKey, ecdh: ec ? ec.private_key : null }, public_key: pub };
|
|
279
|
+
}
|
|
132
280
|
return null;
|
|
133
281
|
}
|
|
134
282
|
|
|
283
|
+
// The two halves of a share or a ciphertext, by the group's layout.
|
|
284
|
+
function split_share(group, buf, kemLen) {
|
|
285
|
+
let d = KEM_GROUPS[group];
|
|
286
|
+
if (!d.ecdh) return { kem: buf, ecdh: null };
|
|
287
|
+
let e = ECDH_PART[d.ecdh].len;
|
|
288
|
+
return d.kemFirst ? { kem: buf.subarray(0, kemLen), ecdh: buf.subarray(kemLen) }
|
|
289
|
+
: { ecdh: buf.subarray(0, e), kem: buf.subarray(e) };
|
|
290
|
+
}
|
|
291
|
+
// The shared secret: ML-KEM's and the ECDH one, concatenated in the group's
|
|
292
|
+
// order (draft-ietf-tls-ecdhe-mlkem §3); ML-KEM alone, its own.
|
|
293
|
+
function join_secret(group, kemSecret, ecdhSecret) {
|
|
294
|
+
let d = KEM_GROUPS[group];
|
|
295
|
+
if (!d.ecdh) return new Uint8Array(kemSecret);
|
|
296
|
+
let out = new Uint8Array(kemSecret.length + ecdhSecret.length);
|
|
297
|
+
if (d.kemFirst) { out.set(kemSecret, 0); out.set(ecdhSecret, kemSecret.length); }
|
|
298
|
+
else { out.set(ecdhSecret, 0); out.set(kemSecret, ecdhSecret.length); }
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The SERVER side of a KEM group: from the client's key share, the server's
|
|
304
|
+
* key share and the shared secret, together. There is no server key pair to
|
|
305
|
+
* make first — the ciphertext IS the server's share (with, for a hybrid, the
|
|
306
|
+
* server's ECDH public key).
|
|
307
|
+
* Throws on a malformed share; the caller turns that into illegal_parameter.
|
|
308
|
+
*/
|
|
309
|
+
function kem_server_exchange(group, clientShare) {
|
|
310
|
+
if (!kem_group_available(group)) throw new Error('not an implemented KEM group: 0x' + (group >>> 0).toString(16));
|
|
311
|
+
let d = KEM_GROUPS[group], sz = kem_sizes(group);
|
|
312
|
+
if (!clientShare || clientShare.length !== sz.client) {
|
|
313
|
+
throw new Error(group_name(group) + ' client key_exchange must be ' + sz.client + ' bytes, got ' + (clientShare ? clientShare.length : 0));
|
|
314
|
+
}
|
|
315
|
+
let parts = split_share(group, clientShare, sz.ek);
|
|
316
|
+
let enc = crypto.encapsulate(mlkem_public_key(d.kem, parts.kem));
|
|
317
|
+
let ec = null, ecdhSecret = null;
|
|
318
|
+
if (d.ecdh) {
|
|
319
|
+
ec = ECDH_PART[d.ecdh].generate();
|
|
320
|
+
ecdhSecret = ECDH_PART[d.ecdh].shared(ec.private_key, parts.ecdh); // throws on a bad point / all-zero X25519
|
|
321
|
+
}
|
|
322
|
+
let share = new Uint8Array(sz.server);
|
|
323
|
+
if (!ec) share.set(enc.ciphertext, 0);
|
|
324
|
+
else if (d.kemFirst) { share.set(enc.ciphertext, 0); share.set(ec.public_key, sz.ct); }
|
|
325
|
+
else { share.set(ec.public_key, 0); share.set(enc.ciphertext, sz.ecdh); }
|
|
326
|
+
return { public_key: share, shared_secret: join_secret(group, new Uint8Array(enc.sharedKey), ecdhSecret) };
|
|
327
|
+
}
|
|
328
|
+
|
|
135
329
|
/**
|
|
136
330
|
* Validate a peer's key_exchange value for `group` and normalize it to the
|
|
137
331
|
* form the primitives below expect.
|
|
@@ -144,6 +338,19 @@ function generate_keypair(group) {
|
|
|
144
338
|
function normalize_peer_public_key(group, pub) {
|
|
145
339
|
if (!pub || pub.length === 0) throw new Error('empty key_exchange');
|
|
146
340
|
|
|
341
|
+
if (is_kem_group(group)) {
|
|
342
|
+
// A client's share (encapsulation key, and the ECDH part of a hybrid) or
|
|
343
|
+
// a server's (ciphertext, and the ECDH part) — nothing else. The ML-KEM
|
|
344
|
+
// key's own validity is checked by encapsulate; the ECDH part by its
|
|
345
|
+
// primitive.
|
|
346
|
+
if (!kem_group_available(group)) throw new Error('unsupported group 0x' + (group >>> 0).toString(16));
|
|
347
|
+
let sz = kem_sizes(group);
|
|
348
|
+
if (pub.length !== sz.client && pub.length !== sz.server) {
|
|
349
|
+
throw new Error(group_name(group) + ' key_exchange must be ' + sz.client + ' or ' + sz.server + ' bytes, got ' + pub.length);
|
|
350
|
+
}
|
|
351
|
+
return pub;
|
|
352
|
+
}
|
|
353
|
+
|
|
147
354
|
if (group === NAMED_GROUP.X25519) {
|
|
148
355
|
if (pub.length !== 32) {
|
|
149
356
|
throw new Error('X25519 key_exchange must be 32 bytes, got ' + pub.length);
|
|
@@ -204,14 +411,34 @@ function get_shared_secret(group, localPrivateRaw, remotePublicRaw) {
|
|
|
204
411
|
if (group === NAMED_GROUP.X25519) return x25519_get_shared_secret(localPrivateRaw, pub);
|
|
205
412
|
if (group === NAMED_GROUP.SECP256R1) return p256_get_shared_secret(localPrivateRaw, pub);
|
|
206
413
|
if (group === NAMED_GROUP.SECP384R1) return p384_get_shared_secret(localPrivateRaw, pub);
|
|
414
|
+
if (kem_group_available(group)) {
|
|
415
|
+
// The CLIENT side: the server's share is the ciphertext (and, for a
|
|
416
|
+
// hybrid, the server's ECDH public key), in the group's order.
|
|
417
|
+
let sz = kem_sizes(group);
|
|
418
|
+
if (pub.length !== sz.server) {
|
|
419
|
+
throw new Error(group_name(group) + ' server key_exchange must be ' + sz.server + ' bytes, got ' + pub.length);
|
|
420
|
+
}
|
|
421
|
+
let d = KEM_GROUPS[group];
|
|
422
|
+
let parts = split_share(group, pub, sz.ct);
|
|
423
|
+
let kemShared = new Uint8Array(crypto.decapsulate(localPrivateRaw.kem, parts.kem));
|
|
424
|
+
let ecdhShared = d.ecdh ? ECDH_PART[d.ecdh].shared(localPrivateRaw.ecdh, parts.ecdh) : null;
|
|
425
|
+
return join_secret(group, kemShared, ecdhShared);
|
|
426
|
+
}
|
|
207
427
|
|
|
208
428
|
throw new Error('unsupported group 0x' + (group >>> 0).toString(16));
|
|
209
429
|
}
|
|
210
430
|
|
|
211
431
|
export {
|
|
212
432
|
NAMED_GROUP,
|
|
433
|
+
GROUP_NAMES,
|
|
434
|
+
group_name,
|
|
435
|
+
kem_group_available,
|
|
213
436
|
SUPPORTED_GROUPS,
|
|
437
|
+
SERVER_DEFAULT_GROUPS,
|
|
438
|
+
MLKEM_AVAILABLE,
|
|
214
439
|
is_supported_group,
|
|
440
|
+
is_kem_group,
|
|
441
|
+
kem_server_exchange,
|
|
215
442
|
generate_keypair,
|
|
216
443
|
normalize_peer_public_key,
|
|
217
444
|
get_shared_secret,
|
|
@@ -221,4 +448,4 @@ export {
|
|
|
221
448
|
p256_get_shared_secret,
|
|
222
449
|
p384_generate_keypair,
|
|
223
450
|
p384_get_shared_secret
|
|
224
|
-
};
|
|
451
|
+
};
|