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.
@@ -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
- return crypto.createPrivateKey({ key: buf, format: 'pem', passphrase: passphrase });
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', passphrase: passphrase });
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', passphrase: passphrase });
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', passphrase: passphrase });
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
+ };
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * ECDH key generation and shared secret helpers using node:crypto.
3
- * Supports X25519 and P-256 (secp256r1).
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
- const SUPPORTED_GROUPS = [
112
- NAMED_GROUP.X25519, // preferred: fastest, no point validation pitfalls
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 SUPPORTED_GROUPS.indexOf(group) >= 0;
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
+ };