lemon-tls 0.7.0 → 0.10.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.
@@ -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
+ };