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.
- package/README.md +258 -20
- package/index.cjs +9 -7
- package/index.d.ts +323 -23
- package/index.js +126 -0
- package/package.json +39 -6
- package/src/compat.js +325 -46
- package/src/crypto.js +19 -0
- package/src/dtls_session.js +141 -17
- package/src/dtls_socket.js +32 -0
- package/src/node_options.js +138 -0
- package/src/record.js +192 -57
- package/src/secure_context.js +63 -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 +39 -4
- package/src/session/signing.js +56 -3
- package/src/session/ticket.js +38 -25
- package/src/tls_session.js +2835 -205
- package/src/tls_socket.js +839 -148
- package/src/utils.js +68 -1
- package/src/wire.js +148 -9
- package/types/crypto.d.ts +40 -0
- package/types/record.d.ts +40 -0
- package/types/session.d.ts +3 -0
- package/types/wire.d.ts +62 -0
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
|
+
};
|