@yozz.app/tls 0.1.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/dist/index.mjs ADDED
@@ -0,0 +1,3872 @@
1
+ import { asciiLower, decodeCertificate, decodeDer, decodeInteger } from "@yozz.app/x509";
2
+ //#region src/wire.ts
3
+ const CONTENT_TYPES = {
4
+ change_cipher_spec: 20,
5
+ alert: 21,
6
+ handshake: 22,
7
+ application_data: 23
8
+ };
9
+ const HANDSHAKE_TYPES = {
10
+ client_hello: 1,
11
+ server_hello: 2,
12
+ new_session_ticket: 4,
13
+ end_of_early_data: 5,
14
+ encrypted_extensions: 8,
15
+ certificate: 11,
16
+ certificate_request: 13,
17
+ certificate_verify: 15,
18
+ finished: 20,
19
+ key_update: 24,
20
+ message_hash: 254
21
+ };
22
+ const NAMED_GROUPS = {
23
+ secp256r1: 23,
24
+ secp384r1: 24,
25
+ x25519: 29
26
+ };
27
+ /**
28
+ * Every group this client implements, in the order it offers them — so the
29
+ * first one carries the key share, and a server that accepts our first choice
30
+ * negotiates without a HelloRetryRequest.
31
+ *
32
+ * P-384 is not optional here: `posteo.de` refuses X25519 and P-256 outright.
33
+ *
34
+ * It doubles as the default for `supportedGroups`, and `wire.test.ts` holds it
35
+ * to covering every key of `NAMED_GROUPS` — `namedGroupFromCode` searches this
36
+ * list, so a group implemented but missing from it would decode as unknown.
37
+ */
38
+ const SUPPORTED_GROUPS = [
39
+ "x25519",
40
+ "secp256r1",
41
+ "secp384r1"
42
+ ];
43
+ /** The wire code back to the name, `undefined` for a group we do not implement. */
44
+ const namedGroupFromCode = (code) => SUPPORTED_GROUPS.find((name) => NAMED_GROUPS[name] === code);
45
+ const SIGNATURE_SCHEMES = {
46
+ ecdsa_secp256r1_sha256: 1027,
47
+ ecdsa_secp384r1_sha384: 1283,
48
+ rsa_pss_rsae_sha256: 2052,
49
+ rsa_pss_rsae_sha384: 2053,
50
+ rsa_pss_rsae_sha512: 2054,
51
+ ed25519: 2055
52
+ };
53
+ /**
54
+ * Every scheme this client can verify a CertificateVerify with, in the order it
55
+ * offers them, and the default for `signatureSchemes`.
56
+ *
57
+ * The list is the security boundary, not just a preference: RFC 9846 §4.5.2
58
+ * says a server's "signature algorithm MUST be one offered in the client's
59
+ * `signature_algorithms` extension", and the handshake refuses one that is not.
60
+ * So a scheme missing from here is a scheme the server may not sign with, and a
61
+ * scheme present but unimplemented in `verify.ts` is a hole — `wire.test.ts`
62
+ * holds this to covering every key of `SIGNATURE_SCHEMES`, the same way
63
+ * `SUPPORTED_GROUPS` is held to `NAMED_GROUPS`.
64
+ *
65
+ * Ed25519 is offered where BoringSSL disables it by default. It is a smaller,
66
+ * misuse-resistant signature over a curve we already carry for key exchange,
67
+ * and refusing it would only push a server onto RSA.
68
+ */
69
+ const SUPPORTED_SIGNATURE_SCHEMES = [
70
+ "ecdsa_secp256r1_sha256",
71
+ "ecdsa_secp384r1_sha384",
72
+ "rsa_pss_rsae_sha256",
73
+ "rsa_pss_rsae_sha384",
74
+ "rsa_pss_rsae_sha512",
75
+ "ed25519"
76
+ ];
77
+ /** The wire code back to the name, `undefined` for a scheme we do not implement. */
78
+ const signatureSchemeFromCode = (code) => SUPPORTED_SIGNATURE_SCHEMES.find((name) => SIGNATURE_SCHEMES[name] === code);
79
+ const CERTIFICATE_SIGNATURE_SCHEMES = {
80
+ ecdsa_secp256r1_sha256: {
81
+ code: 1027,
82
+ algorithmOid: "1.2.840.10045.4.3.2",
83
+ curveOid: "1.2.840.10045.3.1.7"
84
+ },
85
+ ecdsa_secp384r1_sha384: {
86
+ code: 1283,
87
+ algorithmOid: "1.2.840.10045.4.3.3",
88
+ curveOid: "1.3.132.0.34"
89
+ },
90
+ /**
91
+ * P-521 as a CERTIFICATE signature, which is not the P-521 that DECISIONS puts
92
+ * out of v1. That one is the ECDHE group and the CertificateVerify scheme —
93
+ * things a peer negotiates with us. This is a curve an intermediate we did not
94
+ * choose may already have been signed with, and `@yozz.app/x509` verifies it, so
95
+ * withholding it would understate the validator for no gain.
96
+ */
97
+ ecdsa_secp521r1_sha512: {
98
+ code: 1539,
99
+ algorithmOid: "1.2.840.10045.4.3.4",
100
+ curveOid: "1.3.132.0.35"
101
+ },
102
+ rsa_pkcs1_sha256: {
103
+ code: 1025,
104
+ algorithmOid: "1.2.840.113549.1.1.11",
105
+ curveOid: null
106
+ },
107
+ rsa_pkcs1_sha384: {
108
+ code: 1281,
109
+ algorithmOid: "1.2.840.113549.1.1.12",
110
+ curveOid: null
111
+ },
112
+ rsa_pkcs1_sha512: {
113
+ code: 1537,
114
+ algorithmOid: "1.2.840.113549.1.1.13",
115
+ curveOid: null
116
+ }
117
+ };
118
+ /**
119
+ * What goes on the wire, in preference order. ECDSA first because those chains
120
+ * are smaller and this client pays for every byte of a mail server's flight;
121
+ * RSA-PKCS1 after, because that is what most of the WebPKI is actually signed
122
+ * with and a server MUST be able to find it here.
123
+ */
124
+ const OFFERED_CERTIFICATE_SIGNATURE_SCHEMES = [
125
+ "ecdsa_secp256r1_sha256",
126
+ "ecdsa_secp384r1_sha384",
127
+ "ecdsa_secp521r1_sha512",
128
+ "rsa_pkcs1_sha256",
129
+ "rsa_pkcs1_sha384",
130
+ "rsa_pkcs1_sha512"
131
+ ];
132
+ const EXTENSION_TYPES = {
133
+ server_name: 0,
134
+ supported_groups: 10,
135
+ signature_algorithms: 13,
136
+ signature_algorithms_cert: 50,
137
+ /** RFC 7685. Sent, never read: a server has no reason to echo it. */
138
+ padding: 21,
139
+ pre_shared_key: 41,
140
+ early_data: 42,
141
+ supported_versions: 43,
142
+ cookie: 44,
143
+ psk_key_exchange_modes: 45,
144
+ key_share: 51
145
+ };
146
+ /**
147
+ * RFC 9846 §4.3.9. This client offers `psk_dhe_ke` alone: `psk_ke` resumes with
148
+ * no fresh key exchange, so one stolen ticket decrypts every session that ever
149
+ * used it. Forward secrecy is the reason to run our own TLS in the first place.
150
+ */
151
+ const PSK_KEY_EXCHANGE_MODES = {
152
+ psk_ke: 0,
153
+ psk_dhe_ke: 1
154
+ };
155
+ const ALERT_DESCRIPTIONS = {
156
+ close_notify: 0,
157
+ unexpected_message: 10,
158
+ bad_record_mac: 20,
159
+ record_overflow: 22,
160
+ handshake_failure: 40,
161
+ bad_certificate: 42,
162
+ unsupported_certificate: 43,
163
+ certificate_revoked: 44,
164
+ certificate_expired: 45,
165
+ certificate_unknown: 46,
166
+ illegal_parameter: 47,
167
+ unknown_ca: 48,
168
+ access_denied: 49,
169
+ decode_error: 50,
170
+ decrypt_error: 51,
171
+ protocol_version: 70,
172
+ insufficient_security: 71,
173
+ internal_error: 80,
174
+ inappropriate_fallback: 86,
175
+ user_canceled: 90,
176
+ missing_extension: 109,
177
+ unsupported_extension: 110,
178
+ unrecognized_name: 112,
179
+ bad_certificate_status_response: 113,
180
+ unknown_psk_identity: 115,
181
+ certificate_required: 116,
182
+ general_error: 117,
183
+ no_application_protocol: 120
184
+ };
185
+ const LEGACY_RECORD_VERSION = {
186
+ FIRST_CLIENT_HELLO: 769,
187
+ STANDARD: 771
188
+ };
189
+ const TLS_VERSION = {
190
+ V1_0: 769,
191
+ V1_2: 771,
192
+ V1_3: 772
193
+ };
194
+ /**
195
+ * RFC 9846 §4.2.3: SHA-256("HelloRetryRequest")
196
+ */
197
+ const HRR_MAGIC_RANDOM = Uint8Array.of(207, 33, 173, 116, 229, 154, 97, 17, 190, 29, 140, 2, 30, 101, 184, 145, 194, 162, 17, 22, 122, 187, 140, 94, 7, 158, 9, 226, 200, 168, 51, 156);
198
+ /**
199
+ * RFC 9846 §4.2.3: Downgrade sentinels in the last 8 octets of ServerHello.random.
200
+ */
201
+ const DOWNGRADE_SENTINEL_TLS_1_2 = Uint8Array.of(68, 79, 87, 78, 71, 82, 68, 1);
202
+ const DOWNGRADE_SENTINEL_TLS_1_1 = Uint8Array.of(68, 79, 87, 78, 71, 82, 68, 0);
203
+ //#endregion
204
+ //#region src/alert.ts
205
+ const CODE_TO_DESCRIPTION = new Map(Object.entries(ALERT_DESCRIPTIONS).map(([desc, code]) => [code, desc]));
206
+ const encodeAlert = (alert) => {
207
+ const levelCode = alert.level === "warning" ? 1 : 2;
208
+ const descCode = ALERT_DESCRIPTIONS[alert.description];
209
+ if (descCode === void 0) throw new Error(`Unknown alert description: ${alert.description}`);
210
+ return Uint8Array.of(levelCode, descCode);
211
+ };
212
+ const decodeAlert = (bytes) => {
213
+ if (bytes.length !== 2) return {
214
+ ok: false,
215
+ description: "decode_error"
216
+ };
217
+ const levelCode = bytes[0];
218
+ const descCode = bytes[1];
219
+ if (levelCode === void 0 || descCode === void 0) return {
220
+ ok: false,
221
+ description: "decode_error"
222
+ };
223
+ const level = levelCode === 1 ? "warning" : levelCode === 2 ? "fatal" : void 0;
224
+ if (level === void 0) return {
225
+ ok: false,
226
+ description: "illegal_parameter"
227
+ };
228
+ const description = CODE_TO_DESCRIPTION.get(descCode);
229
+ if (description === void 0) return {
230
+ ok: false,
231
+ description: "illegal_parameter",
232
+ unknownDescriptionCode: descCode
233
+ };
234
+ return {
235
+ ok: true,
236
+ alert: {
237
+ level,
238
+ description
239
+ }
240
+ };
241
+ };
242
+ const VALIDATION_FAILURE_ALERTS = {
243
+ "malformed-certificate": "bad_certificate",
244
+ "certificate-expired": "certificate_expired",
245
+ "certificate-not-yet-valid": "certificate_expired",
246
+ "unsupported-signature-algorithm": "unsupported_certificate",
247
+ "unknown-critical-extension": "unsupported_certificate",
248
+ "basic-constraints-violation": "bad_certificate",
249
+ "key-usage-violation": "bad_certificate",
250
+ "extended-key-usage-violation": "bad_certificate",
251
+ "name-constraints-violation": "bad_certificate",
252
+ "invalid-signature": "bad_certificate",
253
+ "name-mismatch": "certificate_unknown",
254
+ "no-path-to-trust-anchor": "unknown_ca",
255
+ "maximum-chain-depth-exceeded": "unknown_ca",
256
+ /**
257
+ * §6.2's `unknown_ca` is "the certificate was not accepted because the CA
258
+ * certificate could not be located or could not be matched with a known
259
+ * trust anchor" — and a root distrusted for leaves this new is exactly a CA
260
+ * that cannot be matched, for this chain, however well we know it. The
261
+ * distinction the RFC has no alert for is kept where it is useful: in the
262
+ * failure code the caller reads, not on the wire.
263
+ */
264
+ "certificate-authority-distrusted": "unknown_ca",
265
+ /**
266
+ * RFC 9846 §6.2: "certificate_unknown: Some other (unspecified) issue arose
267
+ * in processing the certificate, rendering it unacceptable." A validator
268
+ * refusing on its own policy is exactly the unspecified issue — the chain
269
+ * itself was fine, so none of the specific certificate alerts is true.
270
+ */
271
+ "rejected-by-policy": "certificate_unknown"
272
+ };
273
+ const alertForValidationFailure = (failure) => ({
274
+ level: "fatal",
275
+ description: VALIDATION_FAILURE_ALERTS[failure.code]
276
+ });
277
+ //#endregion
278
+ //#region src/bytes.ts
279
+ /**
280
+ * Byte-level encoding and decoding primitives for TLS records and messages.
281
+ */
282
+ const concat$1 = (...parts) => {
283
+ const joined = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
284
+ let offset = 0;
285
+ for (const part of parts) {
286
+ joined.set(part, offset);
287
+ offset += part.length;
288
+ }
289
+ return joined;
290
+ };
291
+ const readUint8 = (bytes, offset) => {
292
+ if (!Number.isInteger(offset) || offset < 0 || offset >= bytes.length) throw new Error(`readUint8 out of bounds: offset ${offset}, length ${bytes.length}`);
293
+ const val = bytes[offset];
294
+ if (val === void 0) throw new Error(`readUint8 byte at offset ${offset} is undefined`);
295
+ return val;
296
+ };
297
+ const readUint16 = (bytes, offset) => {
298
+ if (!Number.isInteger(offset) || offset < 0 || offset + 2 > bytes.length) throw new Error(`readUint16 out of bounds: offset ${offset}, length ${bytes.length}`);
299
+ const b0 = bytes[offset];
300
+ const b1 = bytes[offset + 1];
301
+ if (b0 === void 0 || b1 === void 0) throw new Error(`readUint16 bytes at offset ${offset} undefined`);
302
+ return b0 << 8 | b1;
303
+ };
304
+ const readUint24 = (bytes, offset) => {
305
+ if (!Number.isInteger(offset) || offset < 0 || offset + 3 > bytes.length) throw new Error(`readUint24 out of bounds: offset ${offset}, length ${bytes.length}`);
306
+ const b0 = bytes[offset];
307
+ const b1 = bytes[offset + 1];
308
+ const b2 = bytes[offset + 2];
309
+ if (b0 === void 0 || b1 === void 0 || b2 === void 0) throw new Error(`readUint24 bytes at offset ${offset} undefined`);
310
+ return b0 << 16 | b1 << 8 | b2;
311
+ };
312
+ const writeUint8 = (value) => {
313
+ if (!Number.isInteger(value) || value < 0 || value > 255) throw new Error(`writeUint8 invalid value: ${value}`);
314
+ return Uint8Array.of(value);
315
+ };
316
+ const writeUint16 = (value) => {
317
+ if (!Number.isInteger(value) || value < 0 || value > 65535) throw new Error(`writeUint16 invalid value: ${value}`);
318
+ return Uint8Array.of(value >> 8 & 255, value & 255);
319
+ };
320
+ const writeUint24 = (value) => {
321
+ if (!Number.isInteger(value) || value < 0 || value > 16777215) throw new Error(`writeUint24 invalid value: ${value}`);
322
+ return Uint8Array.of(value >> 16 & 255, value >> 8 & 255, value & 255);
323
+ };
324
+ //#endregion
325
+ //#region src/handshake-messages.ts
326
+ /**
327
+ * TLS 1.3 Handshake Message Codec (RFC 9846 §4).
328
+ */
329
+ const encodeExtensions = (extensions) => {
330
+ const parts = [];
331
+ let declaredTotalLen;
332
+ for (const ext of extensions) {
333
+ const extTypeCode = ext.kind === "unknown" ? ext.typeCode : EXTENSION_TYPES[ext.kind];
334
+ if (extTypeCode === void 0) throw new Error(`Unknown extension kind: ${ext.kind}`);
335
+ let body;
336
+ let declaredExtLen;
337
+ switch (ext.kind) {
338
+ case "server_name":
339
+ if (ext.serverNames.length === 0) body = /* @__PURE__ */ new Uint8Array(0);
340
+ else {
341
+ const listParts = [];
342
+ for (const name of ext.serverNames) {
343
+ const nameBytes = new TextEncoder().encode(name);
344
+ listParts.push(writeUint8(0), writeUint16(nameBytes.length), nameBytes);
345
+ }
346
+ const listBytes = concat$1(...listParts);
347
+ body = concat$1(writeUint16(listBytes.length), listBytes);
348
+ }
349
+ break;
350
+ case "supported_groups": {
351
+ const groupsBytes = concat$1(...ext.groups.map((g) => writeUint16(g)));
352
+ body = concat$1(writeUint16(groupsBytes.length), groupsBytes);
353
+ break;
354
+ }
355
+ case "padding":
356
+ body = new Uint8Array(ext.length);
357
+ break;
358
+ case "signature_algorithms":
359
+ case "signature_algorithms_cert": {
360
+ const schemesBytes = concat$1(...ext.schemes.map((s) => writeUint16(s)));
361
+ body = concat$1(writeUint16(schemesBytes.length), schemesBytes);
362
+ break;
363
+ }
364
+ case "supported_versions":
365
+ if (ext.isServerHello) body = writeUint16(ext.versions[0] ?? 772);
366
+ else {
367
+ const versionsBytes = concat$1(...ext.versions.map((v) => writeUint16(v)));
368
+ body = concat$1(writeUint8(versionsBytes.length), versionsBytes);
369
+ }
370
+ break;
371
+ case "key_share":
372
+ if (ext.clientShares !== void 0) {
373
+ const sharesParts = [];
374
+ for (const share of ext.clientShares) sharesParts.push(writeUint16(share.group), writeUint16(share.keyExchange.length), share.keyExchange);
375
+ const sharesBytes = concat$1(...sharesParts);
376
+ body = concat$1(writeUint16(sharesBytes.length), sharesBytes);
377
+ } else if (ext.serverShare !== void 0) body = concat$1(writeUint16(ext.serverShare.group), writeUint16(ext.serverShare.keyExchange.length), ext.serverShare.keyExchange);
378
+ else if (ext.selectedGroup !== void 0) body = writeUint16(ext.selectedGroup);
379
+ else body = /* @__PURE__ */ new Uint8Array(0);
380
+ break;
381
+ case "cookie":
382
+ body = concat$1(writeUint16(ext.cookie.length), ext.cookie);
383
+ break;
384
+ case "pre_shared_key":
385
+ if (ext.selectedIdentity !== void 0) body = writeUint16(ext.selectedIdentity);
386
+ else if (ext.identities !== void 0) {
387
+ const idParts = [];
388
+ for (const id of ext.identities) {
389
+ const ageBytes = Uint8Array.of(id.obfuscatedTicketAge >>> 24 & 255, id.obfuscatedTicketAge >>> 16 & 255, id.obfuscatedTicketAge >>> 8 & 255, id.obfuscatedTicketAge & 255);
390
+ idParts.push(writeUint16(id.identity.length), id.identity, ageBytes);
391
+ }
392
+ const idBytes = concat$1(...idParts);
393
+ if (ext.truncatedPreBinder) {
394
+ declaredExtLen = idBytes.length + 2 + 35;
395
+ body = concat$1(writeUint16(idBytes.length), idBytes);
396
+ } else if (ext.binders !== void 0) {
397
+ const binderParts = [];
398
+ for (const binder of ext.binders) binderParts.push(writeUint8(binder.length), binder);
399
+ const binderBytes = concat$1(...binderParts);
400
+ body = concat$1(writeUint16(idBytes.length), idBytes, writeUint16(binderBytes.length), binderBytes);
401
+ } else body = concat$1(writeUint16(idBytes.length), idBytes);
402
+ } else body = /* @__PURE__ */ new Uint8Array(0);
403
+ break;
404
+ case "psk_key_exchange_modes":
405
+ body = concat$1(writeUint8(ext.modes.length), ...ext.modes.map((mode) => writeUint8(mode)));
406
+ break;
407
+ case "early_data":
408
+ if (ext.maxEarlyDataSize !== void 0) body = Uint8Array.of(ext.maxEarlyDataSize >>> 24 & 255, ext.maxEarlyDataSize >>> 16 & 255, ext.maxEarlyDataSize >>> 8 & 255, ext.maxEarlyDataSize & 255);
409
+ else body = /* @__PURE__ */ new Uint8Array(0);
410
+ break;
411
+ case "unknown":
412
+ body = ext.data;
413
+ break;
414
+ }
415
+ parts.push(writeUint16(extTypeCode), writeUint16(declaredExtLen ?? body.length), body);
416
+ }
417
+ const allExtensions = concat$1(...parts);
418
+ const actualLen = allExtensions.length;
419
+ if (extensions.some((e) => e.kind === "pre_shared_key" && e.truncatedPreBinder === true)) declaredTotalLen = actualLen + 35;
420
+ return concat$1(writeUint16(declaredTotalLen ?? actualLen), allExtensions);
421
+ };
422
+ const decodeExtensions = (bytes, context = "other", isHrr = false, allowPreBinderTruncation = false) => {
423
+ const isServerHello = context === "server_hello";
424
+ /** `key_share`, `supported_versions`, `cookie` and `pre_shared_key` live only in the hellos. */
425
+ const isHello = context !== "other";
426
+ if (bytes.length < 2) return {
427
+ ok: false,
428
+ description: "decode_error"
429
+ };
430
+ const extTotalLength = readUint16(bytes, 0);
431
+ const exactOk = bytes.length === 2 + extTotalLength;
432
+ const truncatedOk = allowPreBinderTruncation && bytes.length === 2 + extTotalLength - 35;
433
+ if (!exactOk && !truncatedOk) return {
434
+ ok: false,
435
+ description: "decode_error"
436
+ };
437
+ const seenTypes = /* @__PURE__ */ new Set();
438
+ const extensions = [];
439
+ let offset = 2;
440
+ while (offset < bytes.length) {
441
+ if (offset + 4 > bytes.length) return {
442
+ ok: false,
443
+ description: "decode_error"
444
+ };
445
+ const typeCode = readUint16(bytes, offset);
446
+ const dataLen = readUint16(bytes, offset + 2);
447
+ offset += 4;
448
+ if (seenTypes.has(typeCode)) return {
449
+ ok: false,
450
+ description: "illegal_parameter"
451
+ };
452
+ seenTypes.add(typeCode);
453
+ let extData;
454
+ if (offset + dataLen <= bytes.length) {
455
+ extData = bytes.subarray(offset, offset + dataLen);
456
+ offset += dataLen;
457
+ } else if (allowPreBinderTruncation && truncatedOk && typeCode === EXTENSION_TYPES.pre_shared_key) {
458
+ extData = bytes.subarray(offset);
459
+ offset = bytes.length;
460
+ } else return {
461
+ ok: false,
462
+ description: "decode_error"
463
+ };
464
+ if (typeCode === EXTENSION_TYPES.server_name) if (extData.length === 0) extensions.push({
465
+ kind: "server_name",
466
+ serverNames: []
467
+ });
468
+ else {
469
+ if (extData.length < 2) return {
470
+ ok: false,
471
+ description: "decode_error"
472
+ };
473
+ const listLen = readUint16(extData, 0);
474
+ if (extData.length !== 2 + listLen) return {
475
+ ok: false,
476
+ description: "decode_error"
477
+ };
478
+ let nOffset = 2;
479
+ const serverNames = [];
480
+ while (nOffset < extData.length) {
481
+ if (nOffset + 3 > extData.length) return {
482
+ ok: false,
483
+ description: "decode_error"
484
+ };
485
+ const nameType = readUint8(extData, nOffset);
486
+ const nameLen = readUint16(extData, nOffset + 1);
487
+ nOffset += 3;
488
+ if (nOffset + nameLen > extData.length) return {
489
+ ok: false,
490
+ description: "decode_error"
491
+ };
492
+ if (nameType === 0) {
493
+ const nameBytes = extData.subarray(nOffset, nOffset + nameLen);
494
+ serverNames.push(new TextDecoder().decode(nameBytes));
495
+ }
496
+ nOffset += nameLen;
497
+ }
498
+ extensions.push({
499
+ kind: "server_name",
500
+ serverNames
501
+ });
502
+ }
503
+ else if (typeCode === EXTENSION_TYPES.supported_groups) {
504
+ if (extData.length < 2) return {
505
+ ok: false,
506
+ description: "decode_error"
507
+ };
508
+ const listLen = readUint16(extData, 0);
509
+ if (extData.length !== 2 + listLen || listLen % 2 !== 0) return {
510
+ ok: false,
511
+ description: "decode_error"
512
+ };
513
+ const groups = [];
514
+ for (let i = 2; i < extData.length; i += 2) groups.push(readUint16(extData, i));
515
+ extensions.push({
516
+ kind: "supported_groups",
517
+ groups
518
+ });
519
+ } else if (typeCode === EXTENSION_TYPES.signature_algorithms) {
520
+ if (extData.length < 2) return {
521
+ ok: false,
522
+ description: "decode_error"
523
+ };
524
+ const listLen = readUint16(extData, 0);
525
+ if (extData.length !== 2 + listLen || listLen % 2 !== 0) return {
526
+ ok: false,
527
+ description: "decode_error"
528
+ };
529
+ const schemes = [];
530
+ for (let i = 2; i < extData.length; i += 2) schemes.push(readUint16(extData, i));
531
+ extensions.push({
532
+ kind: "signature_algorithms",
533
+ schemes
534
+ });
535
+ } else if (typeCode === EXTENSION_TYPES.signature_algorithms_cert) {
536
+ /**
537
+ * Same `SignatureSchemeList` body as its sibling above, decoded for one
538
+ * reason: **so a test can read back what the ClientHello actually sent.**
539
+ * Left as `unknown` it still travelled correctly — `offeredExtensionCodes`
540
+ * keys on `typeCode` — but the drift gate in `wire.test.ts` could not name
541
+ * it, and a review found that gate passing with the extension deleted
542
+ * outright. A wire format nothing can assert on is a wire format nothing
543
+ * holds.
544
+ */
545
+ if (extData.length < 2) return {
546
+ ok: false,
547
+ description: "decode_error"
548
+ };
549
+ const listLen = readUint16(extData, 0);
550
+ if (extData.length !== 2 + listLen || listLen % 2 !== 0) return {
551
+ ok: false,
552
+ description: "decode_error"
553
+ };
554
+ const schemes = [];
555
+ for (let i = 2; i < extData.length; i += 2) schemes.push(readUint16(extData, i));
556
+ extensions.push({
557
+ kind: "signature_algorithms_cert",
558
+ schemes
559
+ });
560
+ } else if (typeCode === EXTENSION_TYPES.padding) extensions.push({
561
+ kind: "padding",
562
+ length: extData.length
563
+ });
564
+ else if (isHello && typeCode === EXTENSION_TYPES.supported_versions) if (isServerHello) {
565
+ if (extData.length !== 2) return {
566
+ ok: false,
567
+ description: "decode_error"
568
+ };
569
+ extensions.push({
570
+ kind: "supported_versions",
571
+ versions: [readUint16(extData, 0)],
572
+ isServerHello: true
573
+ });
574
+ } else {
575
+ if (extData.length < 1) return {
576
+ ok: false,
577
+ description: "decode_error"
578
+ };
579
+ const listLen = readUint8(extData, 0);
580
+ if (extData.length !== 1 + listLen || listLen % 2 !== 0) return {
581
+ ok: false,
582
+ description: "decode_error"
583
+ };
584
+ const versions = [];
585
+ for (let i = 1; i < extData.length; i += 2) versions.push(readUint16(extData, i));
586
+ extensions.push({
587
+ kind: "supported_versions",
588
+ versions,
589
+ isServerHello: false
590
+ });
591
+ }
592
+ else if (isHello && typeCode === EXTENSION_TYPES.key_share) if (isHrr) {
593
+ if (extData.length !== 2) return {
594
+ ok: false,
595
+ description: "decode_error"
596
+ };
597
+ extensions.push({
598
+ kind: "key_share",
599
+ selectedGroup: readUint16(extData, 0)
600
+ });
601
+ } else if (isServerHello) {
602
+ if (extData.length < 4) return {
603
+ ok: false,
604
+ description: "decode_error"
605
+ };
606
+ const group = readUint16(extData, 0);
607
+ const kLen = readUint16(extData, 2);
608
+ if (extData.length !== 4 + kLen) return {
609
+ ok: false,
610
+ description: "decode_error"
611
+ };
612
+ extensions.push({
613
+ kind: "key_share",
614
+ serverShare: {
615
+ group,
616
+ keyExchange: extData.subarray(4, 4 + kLen)
617
+ }
618
+ });
619
+ } else {
620
+ if (extData.length < 2) return {
621
+ ok: false,
622
+ description: "decode_error"
623
+ };
624
+ const listLen = readUint16(extData, 0);
625
+ if (extData.length !== 2 + listLen) return {
626
+ ok: false,
627
+ description: "decode_error"
628
+ };
629
+ let sOffset = 2;
630
+ const clientShares = [];
631
+ while (sOffset < extData.length) {
632
+ if (sOffset + 4 > extData.length) return {
633
+ ok: false,
634
+ description: "decode_error"
635
+ };
636
+ const group = readUint16(extData, sOffset);
637
+ const kLen = readUint16(extData, sOffset + 2);
638
+ sOffset += 4;
639
+ if (sOffset + kLen > extData.length) return {
640
+ ok: false,
641
+ description: "decode_error"
642
+ };
643
+ clientShares.push({
644
+ group,
645
+ keyExchange: extData.subarray(sOffset, sOffset + kLen)
646
+ });
647
+ sOffset += kLen;
648
+ }
649
+ extensions.push({
650
+ kind: "key_share",
651
+ clientShares
652
+ });
653
+ }
654
+ else if (isHello && typeCode === EXTENSION_TYPES.cookie) {
655
+ if (extData.length < 2) return {
656
+ ok: false,
657
+ description: "decode_error"
658
+ };
659
+ const cLen = readUint16(extData, 0);
660
+ if (extData.length !== 2 + cLen) return {
661
+ ok: false,
662
+ description: "decode_error"
663
+ };
664
+ if (readUint16(extData, 0) === 0) return {
665
+ ok: false,
666
+ description: "decode_error"
667
+ };
668
+ extensions.push({
669
+ kind: "cookie",
670
+ cookie: extData.subarray(2)
671
+ });
672
+ } else if (isHello && typeCode === EXTENSION_TYPES.pre_shared_key) if (isServerHello) {
673
+ if (extData.length !== 2) return {
674
+ ok: false,
675
+ description: "decode_error"
676
+ };
677
+ extensions.push({
678
+ kind: "pre_shared_key",
679
+ selectedIdentity: readUint16(extData, 0)
680
+ });
681
+ } else {
682
+ if (extData.length < 4) return {
683
+ ok: false,
684
+ description: "decode_error"
685
+ };
686
+ const idListLen = readUint16(extData, 0);
687
+ let idOffset = 2;
688
+ if (idOffset + idListLen > extData.length) return {
689
+ ok: false,
690
+ description: "decode_error"
691
+ };
692
+ const identities = [];
693
+ const idEnd = 2 + idListLen;
694
+ while (idOffset < idEnd) {
695
+ if (idOffset + 6 > idEnd) return {
696
+ ok: false,
697
+ description: "decode_error"
698
+ };
699
+ const idLen = readUint16(extData, idOffset);
700
+ idOffset += 2;
701
+ if (idOffset + idLen + 4 > idEnd) return {
702
+ ok: false,
703
+ description: "decode_error"
704
+ };
705
+ const identity = extData.subarray(idOffset, idOffset + idLen);
706
+ idOffset += idLen;
707
+ const age = (extData[idOffset] ?? 0) << 24 | (extData[idOffset + 1] ?? 0) << 16 | (extData[idOffset + 2] ?? 0) << 8 | (extData[idOffset + 3] ?? 0);
708
+ idOffset += 4;
709
+ identities.push({
710
+ identity,
711
+ obfuscatedTicketAge: age >>> 0
712
+ });
713
+ }
714
+ if (idOffset === extData.length && dataLen === extData.length + 35) extensions.push({
715
+ kind: "pre_shared_key",
716
+ identities,
717
+ binders: [],
718
+ truncatedPreBinder: true
719
+ });
720
+ else {
721
+ if (idOffset + 2 > extData.length) return {
722
+ ok: false,
723
+ description: "decode_error"
724
+ };
725
+ const binderListLen = readUint16(extData, idOffset);
726
+ idOffset += 2;
727
+ if (idOffset + binderListLen !== extData.length) return {
728
+ ok: false,
729
+ description: "decode_error"
730
+ };
731
+ const binders = [];
732
+ while (idOffset < extData.length) {
733
+ const bLen = readUint8(extData, idOffset);
734
+ idOffset += 1;
735
+ if (idOffset + bLen > extData.length) return {
736
+ ok: false,
737
+ description: "decode_error"
738
+ };
739
+ binders.push(extData.subarray(idOffset, idOffset + bLen));
740
+ idOffset += bLen;
741
+ }
742
+ extensions.push({
743
+ kind: "pre_shared_key",
744
+ identities,
745
+ binders
746
+ });
747
+ }
748
+ }
749
+ else if (context === "client_hello" && typeCode === EXTENSION_TYPES.psk_key_exchange_modes) {
750
+ if (extData.length < 2) return {
751
+ ok: false,
752
+ description: "decode_error"
753
+ };
754
+ const modesLen = readUint8(extData, 0);
755
+ if (modesLen === 0 || extData.length !== 1 + modesLen) return {
756
+ ok: false,
757
+ description: "decode_error"
758
+ };
759
+ extensions.push({
760
+ kind: "psk_key_exchange_modes",
761
+ modes: [...extData.subarray(1)]
762
+ });
763
+ } else if (typeCode === EXTENSION_TYPES.early_data) if (extData.length === 4) {
764
+ const maxEarlyDataSize = (extData[0] ?? 0) << 24 | (extData[1] ?? 0) << 16 | (extData[2] ?? 0) << 8 | (extData[3] ?? 0);
765
+ extensions.push({
766
+ kind: "early_data",
767
+ maxEarlyDataSize: maxEarlyDataSize >>> 0
768
+ });
769
+ } else extensions.push({ kind: "early_data" });
770
+ else extensions.push({
771
+ kind: "unknown",
772
+ typeCode,
773
+ data: extData
774
+ });
775
+ }
776
+ return {
777
+ ok: true,
778
+ value: extensions
779
+ };
780
+ };
781
+ const encodeHandshakeMessage = (msg) => {
782
+ let typeCode;
783
+ let body;
784
+ let declaredLength;
785
+ switch (msg.kind) {
786
+ case "client_hello": {
787
+ typeCode = HANDSHAKE_TYPES.client_hello;
788
+ const suitesBytes = concat$1(...msg.cipherSuites.map((s) => writeUint16(s)));
789
+ const suitesBlock = concat$1(writeUint16(suitesBytes.length), suitesBytes);
790
+ const sessionBlock = concat$1(writeUint8(msg.legacySessionId.length), msg.legacySessionId);
791
+ const compBlock = concat$1(writeUint8(msg.legacyCompressionMethods.length), msg.legacyCompressionMethods);
792
+ const extBlock = encodeExtensions(msg.extensions);
793
+ body = concat$1(writeUint16(msg.legacyVersion), msg.random, sessionBlock, suitesBlock, compBlock, extBlock);
794
+ if (msg.truncatedPreBinder) declaredLength = body.length + 35;
795
+ break;
796
+ }
797
+ case "server_hello": {
798
+ typeCode = HANDSHAKE_TYPES.server_hello;
799
+ const sessionBlock = concat$1(writeUint8(msg.legacySessionIdEcho.length), msg.legacySessionIdEcho);
800
+ const extBlock = encodeExtensions(msg.extensions);
801
+ body = concat$1(writeUint16(msg.legacyVersion), msg.random, sessionBlock, writeUint16(msg.cipherSuite), writeUint8(msg.legacyCompressionMethod), extBlock);
802
+ break;
803
+ }
804
+ case "encrypted_extensions":
805
+ typeCode = HANDSHAKE_TYPES.encrypted_extensions;
806
+ body = encodeExtensions(msg.extensions);
807
+ break;
808
+ case "certificate": {
809
+ typeCode = HANDSHAKE_TYPES.certificate;
810
+ const contextBlock = concat$1(writeUint8(msg.certificateRequestContext.length), msg.certificateRequestContext);
811
+ const entryParts = [];
812
+ for (const entry of msg.certificateList) {
813
+ const certBlock = concat$1(writeUint24(entry.certData.length), entry.certData);
814
+ const extBlock = entry.rawExtensions ?? encodeExtensions(entry.extensions);
815
+ entryParts.push(certBlock, extBlock);
816
+ }
817
+ const listBytes = concat$1(...entryParts);
818
+ body = concat$1(contextBlock, writeUint24(listBytes.length), listBytes);
819
+ break;
820
+ }
821
+ case "certificate_request":
822
+ typeCode = HANDSHAKE_TYPES.certificate_request;
823
+ body = concat$1(concat$1(writeUint8(msg.certificateRequestContext.length), msg.certificateRequestContext), encodeExtensions(msg.extensions));
824
+ break;
825
+ case "certificate_verify":
826
+ typeCode = HANDSHAKE_TYPES.certificate_verify;
827
+ body = concat$1(writeUint16(msg.scheme), writeUint16(msg.signature.length), msg.signature);
828
+ break;
829
+ case "finished":
830
+ typeCode = HANDSHAKE_TYPES.finished;
831
+ body = msg.verifyData;
832
+ break;
833
+ case "new_session_ticket":
834
+ typeCode = HANDSHAKE_TYPES.new_session_ticket;
835
+ body = concat$1(Uint8Array.of(msg.ticketLifetime >>> 24 & 255, msg.ticketLifetime >>> 16 & 255, msg.ticketLifetime >>> 8 & 255, msg.ticketLifetime & 255), Uint8Array.of(msg.ticketAgeAdd >>> 24 & 255, msg.ticketAgeAdd >>> 16 & 255, msg.ticketAgeAdd >>> 8 & 255, msg.ticketAgeAdd & 255), concat$1(writeUint8(msg.ticketNonce.length), msg.ticketNonce), concat$1(writeUint16(msg.ticket.length), msg.ticket), encodeExtensions(msg.extensions));
836
+ break;
837
+ case "key_update":
838
+ typeCode = HANDSHAKE_TYPES.key_update;
839
+ body = Uint8Array.of(msg.requestUpdate ? 1 : 0);
840
+ break;
841
+ case "end_of_early_data":
842
+ typeCode = HANDSHAKE_TYPES.end_of_early_data;
843
+ body = /* @__PURE__ */ new Uint8Array(0);
844
+ break;
845
+ }
846
+ return concat$1(writeUint8(typeCode), writeUint24(declaredLength ?? body.length), body);
847
+ };
848
+ const decodeHandshakeMessage = (bytes) => {
849
+ if (bytes.length < 4) return {
850
+ ok: false,
851
+ description: "decode_error"
852
+ };
853
+ const typeCode = readUint8(bytes, 0);
854
+ const length = readUint24(bytes, 1);
855
+ if (!(typeCode === HANDSHAKE_TYPES.client_hello && bytes.length === 4 + length - 35) && bytes.length !== 4 + length) return {
856
+ ok: false,
857
+ description: "decode_error"
858
+ };
859
+ const body = bytes.subarray(4);
860
+ switch (typeCode) {
861
+ case HANDSHAKE_TYPES.client_hello: {
862
+ if (body.length < 34) return {
863
+ ok: false,
864
+ description: "decode_error"
865
+ };
866
+ const legacyVersion = readUint16(body, 0);
867
+ const random = body.subarray(2, 34);
868
+ let offset = 34;
869
+ if (offset + 1 > body.length) return {
870
+ ok: false,
871
+ description: "decode_error"
872
+ };
873
+ const sessLen = readUint8(body, offset);
874
+ offset += 1;
875
+ if (offset + sessLen > body.length) return {
876
+ ok: false,
877
+ description: "decode_error"
878
+ };
879
+ const legacySessionId = body.subarray(offset, offset + sessLen);
880
+ offset += sessLen;
881
+ if (offset + 2 > body.length) return {
882
+ ok: false,
883
+ description: "decode_error"
884
+ };
885
+ const suitesLen = readUint16(body, offset);
886
+ offset += 2;
887
+ if (offset + suitesLen > body.length || suitesLen % 2 !== 0) return {
888
+ ok: false,
889
+ description: "decode_error"
890
+ };
891
+ const cipherSuites = [];
892
+ for (let i = offset; i < offset + suitesLen; i += 2) cipherSuites.push(readUint16(body, i));
893
+ offset += suitesLen;
894
+ if (offset + 1 > body.length) return {
895
+ ok: false,
896
+ description: "decode_error"
897
+ };
898
+ const compLen = readUint8(body, offset);
899
+ offset += 1;
900
+ if (offset + compLen > body.length) return {
901
+ ok: false,
902
+ description: "decode_error"
903
+ };
904
+ const legacyCompressionMethods = body.subarray(offset, offset + compLen);
905
+ offset += compLen;
906
+ let extensions = [];
907
+ if (offset < body.length) {
908
+ const extRes = decodeExtensions(body.subarray(offset), "client_hello", false, true);
909
+ if (!extRes.ok) return extRes;
910
+ extensions = extRes.value;
911
+ }
912
+ const truncatedPreBinder = extensions.some((e) => e.kind === "pre_shared_key" && e.truncatedPreBinder === true);
913
+ return {
914
+ ok: true,
915
+ value: {
916
+ kind: "client_hello",
917
+ legacyVersion,
918
+ random,
919
+ legacySessionId,
920
+ cipherSuites,
921
+ legacyCompressionMethods,
922
+ extensions,
923
+ ...truncatedPreBinder ? { truncatedPreBinder: true } : {}
924
+ }
925
+ };
926
+ }
927
+ case HANDSHAKE_TYPES.server_hello: {
928
+ if (body.length < 34) return {
929
+ ok: false,
930
+ description: "decode_error"
931
+ };
932
+ const legacyVersion = readUint16(body, 0);
933
+ /**
934
+ * Two bytes in, and they decide whether the rest is even this message.
935
+ * RFC 9846 §4.2.3: "A client which receives a TLS 1.3 Server Hello with a
936
+ * legacy_version value not equal to 0x0303 MUST abort the handshake with a
937
+ * protocol_version alert." An SSL 3.0 ServerHello has no extensions block
938
+ * at all, so parsing on gets a decode_error where the honest answer — and
939
+ * the one that tells a self-hosted user their server is too old — is right
940
+ * here.
941
+ */
942
+ if (legacyVersion !== 771) return {
943
+ ok: false,
944
+ description: "protocol_version"
945
+ };
946
+ const random = body.subarray(2, 34);
947
+ let offset = 34;
948
+ if (offset + 1 > body.length) return {
949
+ ok: false,
950
+ description: "decode_error"
951
+ };
952
+ const sessLen = readUint8(body, offset);
953
+ offset += 1;
954
+ if (offset + sessLen > body.length) return {
955
+ ok: false,
956
+ description: "decode_error"
957
+ };
958
+ const legacySessionIdEcho = body.subarray(offset, offset + sessLen);
959
+ offset += sessLen;
960
+ if (offset + 3 > body.length) return {
961
+ ok: false,
962
+ description: "decode_error"
963
+ };
964
+ const cipherSuite = readUint16(body, offset);
965
+ const legacyCompressionMethod = readUint8(body, offset + 2);
966
+ offset += 3;
967
+ const isHrr = random.length === 32 && random.every((b, i) => b === [
968
+ 207,
969
+ 33,
970
+ 173,
971
+ 116,
972
+ 229,
973
+ 154,
974
+ 97,
975
+ 17,
976
+ 190,
977
+ 29,
978
+ 140,
979
+ 2,
980
+ 30,
981
+ 101,
982
+ 184,
983
+ 145,
984
+ 194,
985
+ 162,
986
+ 17,
987
+ 22,
988
+ 122,
989
+ 187,
990
+ 140,
991
+ 94,
992
+ 7,
993
+ 158,
994
+ 9,
995
+ 226,
996
+ 200,
997
+ 168,
998
+ 51,
999
+ 156
1000
+ ][i]);
1001
+ let extensions = [];
1002
+ if (offset < body.length) {
1003
+ const extRes = decodeExtensions(body.subarray(offset), "server_hello", isHrr);
1004
+ if (!extRes.ok) return extRes;
1005
+ extensions = extRes.value;
1006
+ }
1007
+ return {
1008
+ ok: true,
1009
+ value: {
1010
+ kind: "server_hello",
1011
+ legacyVersion,
1012
+ random,
1013
+ legacySessionIdEcho,
1014
+ cipherSuite,
1015
+ legacyCompressionMethod,
1016
+ extensions
1017
+ }
1018
+ };
1019
+ }
1020
+ case HANDSHAKE_TYPES.encrypted_extensions: {
1021
+ const extRes = decodeExtensions(body);
1022
+ if (!extRes.ok) return extRes;
1023
+ return {
1024
+ ok: true,
1025
+ value: {
1026
+ kind: "encrypted_extensions",
1027
+ extensions: extRes.value
1028
+ }
1029
+ };
1030
+ }
1031
+ case HANDSHAKE_TYPES.certificate: {
1032
+ if (body.length < 4) return {
1033
+ ok: false,
1034
+ description: "decode_error"
1035
+ };
1036
+ const ctxLen = readUint8(body, 0);
1037
+ let offset = 1;
1038
+ if (offset + ctxLen > body.length) return {
1039
+ ok: false,
1040
+ description: "decode_error"
1041
+ };
1042
+ const certificateRequestContext = body.subarray(offset, offset + ctxLen);
1043
+ offset += ctxLen;
1044
+ if (offset + 3 > body.length) return {
1045
+ ok: false,
1046
+ description: "decode_error"
1047
+ };
1048
+ const listLen = readUint24(body, offset);
1049
+ offset += 3;
1050
+ if (offset + listLen !== body.length) return {
1051
+ ok: false,
1052
+ description: "decode_error"
1053
+ };
1054
+ const certificateList = [];
1055
+ const listEnd = offset + listLen;
1056
+ while (offset < listEnd) {
1057
+ if (offset + 3 > listEnd) return {
1058
+ ok: false,
1059
+ description: "decode_error"
1060
+ };
1061
+ const certLen = readUint24(body, offset);
1062
+ offset += 3;
1063
+ if (offset + certLen > listEnd) return {
1064
+ ok: false,
1065
+ description: "decode_error"
1066
+ };
1067
+ const certData = body.subarray(offset, offset + certLen);
1068
+ offset += certLen;
1069
+ if (offset + 2 > listEnd) return {
1070
+ ok: false,
1071
+ description: "decode_error"
1072
+ };
1073
+ const extLen = readUint16(body, offset);
1074
+ if (offset + 2 + extLen > listEnd) return {
1075
+ ok: false,
1076
+ description: "decode_error"
1077
+ };
1078
+ const rawExtensions = body.subarray(offset, offset + 2 + extLen);
1079
+ const extRes = decodeExtensions(rawExtensions);
1080
+ if (!extRes.ok) return extRes;
1081
+ offset += 2 + extLen;
1082
+ certificateList.push({
1083
+ certData,
1084
+ extensions: extRes.value,
1085
+ rawExtensions
1086
+ });
1087
+ }
1088
+ return {
1089
+ ok: true,
1090
+ value: {
1091
+ kind: "certificate",
1092
+ certificateRequestContext,
1093
+ certificateList
1094
+ }
1095
+ };
1096
+ }
1097
+ case HANDSHAKE_TYPES.certificate_request: {
1098
+ if (body.length < 1) return {
1099
+ ok: false,
1100
+ description: "decode_error"
1101
+ };
1102
+ const ctxLen = readUint8(body, 0);
1103
+ let offset = 1;
1104
+ if (offset + ctxLen > body.length) return {
1105
+ ok: false,
1106
+ description: "decode_error"
1107
+ };
1108
+ const certificateRequestContext = body.subarray(offset, offset + ctxLen);
1109
+ offset += ctxLen;
1110
+ const extRes = decodeExtensions(body.subarray(offset));
1111
+ if (!extRes.ok) return extRes;
1112
+ return {
1113
+ ok: true,
1114
+ value: {
1115
+ kind: "certificate_request",
1116
+ certificateRequestContext,
1117
+ extensions: extRes.value
1118
+ }
1119
+ };
1120
+ }
1121
+ case HANDSHAKE_TYPES.certificate_verify: {
1122
+ if (body.length < 4) return {
1123
+ ok: false,
1124
+ description: "decode_error"
1125
+ };
1126
+ const scheme = readUint16(body, 0);
1127
+ const sigLen = readUint16(body, 2);
1128
+ if (body.length !== 4 + sigLen) return {
1129
+ ok: false,
1130
+ description: "decode_error"
1131
+ };
1132
+ return {
1133
+ ok: true,
1134
+ value: {
1135
+ kind: "certificate_verify",
1136
+ scheme,
1137
+ signature: body.subarray(4)
1138
+ }
1139
+ };
1140
+ }
1141
+ case HANDSHAKE_TYPES.finished: return {
1142
+ ok: true,
1143
+ value: {
1144
+ kind: "finished",
1145
+ verifyData: body
1146
+ }
1147
+ };
1148
+ case HANDSHAKE_TYPES.new_session_ticket: {
1149
+ if (body.length < 13) return {
1150
+ ok: false,
1151
+ description: "decode_error"
1152
+ };
1153
+ const ticketLifetime = (body[0] ?? 0) << 24 | (body[1] ?? 0) << 16 | (body[2] ?? 0) << 8 | (body[3] ?? 0);
1154
+ const ticketAgeAdd = (body[4] ?? 0) << 24 | (body[5] ?? 0) << 16 | (body[6] ?? 0) << 8 | (body[7] ?? 0);
1155
+ let offset = 8;
1156
+ const nonceLen = readUint8(body, offset);
1157
+ offset += 1;
1158
+ if (offset + nonceLen > body.length) return {
1159
+ ok: false,
1160
+ description: "decode_error"
1161
+ };
1162
+ const ticketNonce = body.subarray(offset, offset + nonceLen);
1163
+ offset += nonceLen;
1164
+ if (offset + 2 > body.length) return {
1165
+ ok: false,
1166
+ description: "decode_error"
1167
+ };
1168
+ const ticketLen = readUint16(body, offset);
1169
+ offset += 2;
1170
+ if (ticketLen === 0) return {
1171
+ ok: false,
1172
+ description: "decode_error"
1173
+ };
1174
+ if (offset + ticketLen > body.length) return {
1175
+ ok: false,
1176
+ description: "decode_error"
1177
+ };
1178
+ const ticket = body.subarray(offset, offset + ticketLen);
1179
+ offset += ticketLen;
1180
+ const extRes = decodeExtensions(body.subarray(offset));
1181
+ if (!extRes.ok) return extRes;
1182
+ return {
1183
+ ok: true,
1184
+ value: {
1185
+ kind: "new_session_ticket",
1186
+ ticketLifetime: ticketLifetime >>> 0,
1187
+ ticketAgeAdd: ticketAgeAdd >>> 0,
1188
+ ticketNonce,
1189
+ ticket,
1190
+ extensions: extRes.value
1191
+ }
1192
+ };
1193
+ }
1194
+ case HANDSHAKE_TYPES.key_update: {
1195
+ if (body.length !== 1) return {
1196
+ ok: false,
1197
+ description: "decode_error"
1198
+ };
1199
+ const val = body[0];
1200
+ if (val !== 0 && val !== 1) return {
1201
+ ok: false,
1202
+ description: "illegal_parameter"
1203
+ };
1204
+ return {
1205
+ ok: true,
1206
+ value: {
1207
+ kind: "key_update",
1208
+ requestUpdate: val === 1
1209
+ }
1210
+ };
1211
+ }
1212
+ case HANDSHAKE_TYPES.end_of_early_data:
1213
+ if (body.length !== 0) return {
1214
+ ok: false,
1215
+ description: "decode_error"
1216
+ };
1217
+ return {
1218
+ ok: true,
1219
+ value: { kind: "end_of_early_data" }
1220
+ };
1221
+ default: return {
1222
+ ok: false,
1223
+ description: "unexpected_message"
1224
+ };
1225
+ }
1226
+ };
1227
+ /**
1228
+ * How many trailing octets of a ClientHello the binder list occupies, given one
1229
+ * offered identity: a uint16 list length, a uint8 entry length, and the binder.
1230
+ *
1231
+ * It is the tail of the message because `pre_shared_key` MUST be its last
1232
+ * extension (RFC 9846 §4.3.11), and that is the whole reason the truncation
1233
+ * §4.3.11.2 asks for needs no parsing to find.
1234
+ */
1235
+ const binderListLength = (binderLength) => 3 + binderLength;
1236
+ /**
1237
+ * Builds a production ClientHello per RFC 9846 §4.2.2.
1238
+ */
1239
+ /**
1240
+ * RFC 7685's padding rule, and how many octets of body it wants — `null` when
1241
+ * the message needs no padding at all.
1242
+ *
1243
+ * §4, *Example Usage*: "if the ClientHello message length is between 256 and
1244
+ * 511 bytes, then a padding extension SHOULD be added to make the ClientHello
1245
+ * 512 bytes long." The range is not arithmetic, it is a bug: F5 load balancers
1246
+ * of a certain vintage hang on a ClientHello in it, and a mail client that
1247
+ * dials whatever host a user names meets middleboxes nobody chose.
1248
+ *
1249
+ * **The extension is not free, which is what the `- 4` is.** §4: "Note that a
1250
+ * padding extension of length zero adds 4 bytes to the ClientHello" — its own
1251
+ * type and length fields. So a 509-byte message cannot be padded to exactly 512
1252
+ * and takes an empty one instead, landing at 513, out of the range and past the
1253
+ * hazard. The zero floor is that case, not a rounding guard.
1254
+ *
1255
+ * **A retried ClientHello may be padded independently, and that is spelled out
1256
+ * rather than inferred.** §4.1.2 lists what a second ClientHello may change from
1257
+ * the first and includes "Optionally adding, removing, or changing the length of
1258
+ * the `padding` extension [RFC7685]" — so a HelloRetryRequest that alters the
1259
+ * key share, and with it the length, does not have to carry the first hello's
1260
+ * padding forward. Table 1 gives the extension `CH` and nothing else, so a
1261
+ * server echoing it earns `illegal_parameter` from `misplacedExtensionAlert`
1262
+ * like any other extension in a message where it is not defined.
1263
+ *
1264
+ * **This client only reached the range when `signature_algorithms_cert` was
1265
+ * added.** Before it, the 84-character hostname BoGo picks for
1266
+ * `ClientHelloPadding` produced 255 bytes — one byte short, and `scope.ts`
1267
+ * carried a rule saying so and predicting that any further extension would make
1268
+ * its premise false. It did: 273. The prediction is the reason this is
1269
+ * implemented rather than discovered against a real mail host.
1270
+ */
1271
+ const PADDING_TARGET = 512;
1272
+ const PADDING_RANGE_START = 256;
1273
+ const PADDING_EXTENSION_HEADER_BYTES = 4;
1274
+ const paddingFor = (messageLength) => {
1275
+ if (messageLength < PADDING_RANGE_START || messageLength >= PADDING_TARGET) return null;
1276
+ return Math.max(0, PADDING_TARGET - messageLength - PADDING_EXTENSION_HEADER_BYTES);
1277
+ };
1278
+ const encodeProductionClientHello = (options) => {
1279
+ const random = options.random ?? crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32));
1280
+ const legacySessionId = options.legacySessionId ?? crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32));
1281
+ const group = options.group ?? "x25519";
1282
+ const extensions = [
1283
+ {
1284
+ kind: "server_name",
1285
+ serverNames: [options.serverName]
1286
+ },
1287
+ {
1288
+ kind: "supported_groups",
1289
+ groups: (options.supportedGroups ?? SUPPORTED_GROUPS).map((name) => NAMED_GROUPS[name])
1290
+ },
1291
+ {
1292
+ kind: "signature_algorithms",
1293
+ schemes: (options.signatureSchemes ?? SUPPORTED_SIGNATURE_SCHEMES).map((name) => SIGNATURE_SCHEMES[name])
1294
+ },
1295
+ (
1296
+ /**
1297
+ * NOT derived from `signatureSchemes`, and not configurable with it. That
1298
+ * option is the caller's policy about what may sign a CertificateVerify;
1299
+ * this is a fact about what `@yozz.app/x509` can verify in a chain, and a caller
1300
+ * narrowing the first has said nothing about the second. RFC 9846 §4.3.3 is
1301
+ * why both must be sent: omitting this one makes `signature_algorithms`
1302
+ * answer for certificates too, which is the answer that was wrong.
1303
+ */
1304
+ {
1305
+ kind: "signature_algorithms_cert",
1306
+ schemes: OFFERED_CERTIFICATE_SIGNATURE_SCHEMES.map((name) => CERTIFICATE_SIGNATURE_SCHEMES[name].code)
1307
+ }),
1308
+ {
1309
+ kind: "supported_versions",
1310
+ versions: [TLS_VERSION.V1_3],
1311
+ isServerHello: false
1312
+ },
1313
+ {
1314
+ kind: "key_share",
1315
+ clientShares: [{
1316
+ group: NAMED_GROUPS[group],
1317
+ keyExchange: options.keySharePublicKey
1318
+ }]
1319
+ }
1320
+ ];
1321
+ if (options.cookie !== void 0) extensions.push({
1322
+ kind: "cookie",
1323
+ cookie: options.cookie
1324
+ });
1325
+ /**
1326
+ * Offered on every ClientHello, resumption in hand or not: RFC 9846 §4.7.1
1327
+ * lets a server decline to issue a ticket to a client that has not said it
1328
+ * can use one, and BoringSSL's server does exactly that. Without it the first
1329
+ * connection never earns the session the second one needs.
1330
+ */
1331
+ extensions.push({
1332
+ kind: "psk_key_exchange_modes",
1333
+ modes: [PSK_KEY_EXCHANGE_MODES.psk_dhe_ke]
1334
+ });
1335
+ if (options.psk !== void 0) extensions.push({
1336
+ kind: "pre_shared_key",
1337
+ identities: [{
1338
+ identity: options.psk.identity,
1339
+ obfuscatedTicketAge: options.psk.obfuscatedTicketAge
1340
+ }],
1341
+ binders: [new Uint8Array(options.psk.binderLength)]
1342
+ });
1343
+ /**
1344
+ * §4.3.11 puts `pre_shared_key` last, and `bindClientHello` DEPENDS on it: it
1345
+ * cuts a fixed-size tail off the finished message rather than parsing for the
1346
+ * binder list. An extension appended after this point would move that tail, so
1347
+ * the binder would cover the wrong bytes — and the only symptom is a server
1348
+ * refusing a handshake for no stated reason. Cheaper to fail here.
1349
+ */
1350
+ if (options.psk !== void 0 && extensions.at(-1)?.kind !== "pre_shared_key") throw new Error("pre_shared_key must be the last ClientHello extension");
1351
+ const build = (withExtensions) => encodeHandshakeMessage({
1352
+ kind: "client_hello",
1353
+ legacyVersion: TLS_VERSION.V1_2,
1354
+ random,
1355
+ legacySessionId,
1356
+ cipherSuites: [4865, 4866],
1357
+ legacyCompressionMethods: Uint8Array.of(0),
1358
+ extensions: withExtensions
1359
+ });
1360
+ const unpadded = build(extensions);
1361
+ const padding = paddingFor(unpadded.length);
1362
+ if (padding === null) return unpadded;
1363
+ /**
1364
+ * BEFORE `pre_shared_key`, which §4.3.11 requires to be last and which
1365
+ * `bindClientHello` reads as a fixed-size tail. Appending after it would move
1366
+ * that tail and the binder would cover the wrong bytes.
1367
+ */
1368
+ const insertAt = extensions.at(-1)?.kind === "pre_shared_key" ? -1 : extensions.length;
1369
+ return build(extensions.toSpliced(insertAt, 0, {
1370
+ kind: "padding",
1371
+ length: padding
1372
+ }));
1373
+ };
1374
+ //#endregion
1375
+ //#region src/key-schedule.ts
1376
+ /**
1377
+ * The two suites the spike measured as necessary and sufficient across real mail
1378
+ * providers — `posteo.de` takes only the second. `TLS_CHACHA20_POLY1305_SHA256`
1379
+ * is deliberately absent: WebCrypto has no ChaCha20, and RFC 9846 keeps it
1380
+ * optional.
1381
+ */
1382
+ const CIPHER_SUITES = {
1383
+ TLS_AES_128_GCM_SHA256: {
1384
+ code: 4865,
1385
+ hash: "SHA-256",
1386
+ hashLength: 32,
1387
+ keyLength: 16,
1388
+ ivLength: 12
1389
+ },
1390
+ TLS_AES_256_GCM_SHA384: {
1391
+ code: 4866,
1392
+ hash: "SHA-384",
1393
+ hashLength: 48,
1394
+ keyLength: 32,
1395
+ ivLength: 12
1396
+ }
1397
+ };
1398
+ const concat = (...parts) => {
1399
+ const joined = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
1400
+ let offset = 0;
1401
+ for (const part of parts) {
1402
+ joined.set(part, offset);
1403
+ offset += part.length;
1404
+ }
1405
+ return joined;
1406
+ };
1407
+ /**
1408
+ * WebCrypto's types demand a buffer proven not to be shared, which a view into a
1409
+ * caller's array cannot prove — the same boundary `@yozz.app/x509`'s verifier copies
1410
+ * at, for the same reason. Handshake-sized, and against an HMAC.
1411
+ */
1412
+ const nonShared$3 = (bytes) => new Uint8Array(bytes);
1413
+ const hmac = async (hash, key, message) => new Uint8Array(await crypto.subtle.sign("HMAC", await crypto.subtle.importKey("raw", nonShared$3(key), {
1414
+ name: "HMAC",
1415
+ hash
1416
+ }, false, ["sign"]), nonShared$3(message)));
1417
+ /**
1418
+ * RFC 5869 §2.1. An empty salt means HashLen zero octets — which is also how RFC
1419
+ * 8446 §7.1 draws the Early Secret's `0` — and the substitution is not optional:
1420
+ * WebCrypto refuses a zero-length HMAC key outright.
1421
+ */
1422
+ const hkdfExtract = (suite, salt, ikm) => {
1423
+ const { hash, hashLength } = CIPHER_SUITES[suite];
1424
+ return hmac(hash, salt.length === 0 ? new Uint8Array(hashLength) : salt, ikm);
1425
+ };
1426
+ /** RFC 5869 §2.3. */
1427
+ const hkdfExpand = async (suite, prk, info, length) => {
1428
+ const { hash, hashLength } = CIPHER_SUITES[suite];
1429
+ if (!Number.isInteger(length) || length < 0 || length > 255 * hashLength) throw new Error(`HKDF-Expand cannot produce ${length} octets under ${hash}`);
1430
+ const output = new Uint8Array(length);
1431
+ let block = /* @__PURE__ */ new Uint8Array(0);
1432
+ for (let counter = 1; (counter - 1) * hashLength < length; counter += 1) {
1433
+ block = await hmac(hash, prk, concat(block, info, Uint8Array.of(counter)));
1434
+ output.set(block.subarray(0, length - (counter - 1) * hashLength), (counter - 1) * hashLength);
1435
+ }
1436
+ return output;
1437
+ };
1438
+ /**
1439
+ * The `HkdfLabel` struct of RFC 9846 §7.1 — a uint16 length, then the label
1440
+ * under a `tls13 ` prefix and the context, each with a one-octet length. Getting
1441
+ * this wrong produces keys that are simply different, so it fails at `Finished`
1442
+ * with `decrypt_error` and never nearer the cause.
1443
+ */
1444
+ const hkdfLabel = (label, context, length) => {
1445
+ const prefixed = new TextEncoder().encode(`tls13 ${label}`);
1446
+ if (prefixed.length < 7 || prefixed.length > 255) throw new Error(`HkdfLabel label must be 7..255 octets, got ${prefixed.length}: "${label}"`);
1447
+ if (context.length > 255) throw new Error("HkdfLabel context is over 255 octets");
1448
+ return concat(Uint8Array.of(length >> 8, length & 255), Uint8Array.of(prefixed.length), prefixed, Uint8Array.of(context.length), context);
1449
+ };
1450
+ /**
1451
+ * `async` so that a rejected label fails the same way a rejected length does.
1452
+ * Without it `hkdfLabel` throws synchronously, as an argument evaluated before
1453
+ * the call, and a caller using `.catch()` rather than `await` would miss it.
1454
+ */
1455
+ const hkdfExpandLabel = async (suite, secret, label, context, length) => hkdfExpand(suite, secret, hkdfLabel(label, context, length), length);
1456
+ /**
1457
+ * RFC 9846 §7.1's `Derive-Secret(Secret, Label, Messages)` — and it takes
1458
+ * MESSAGES, hashing them itself, because that is what the RFC's signature says.
1459
+ *
1460
+ * An earlier shape took the transcript pre-hashed while keeping this name. That
1461
+ * invites a handshake author reading the RFC to pass `ClientHello ‖ ServerHello`
1462
+ * straight in, which derives a secret that is merely different: every such bug
1463
+ * surfaces at `Finished` as `decrypt_error`, nowhere near the cause. Pass
1464
+ * nothing for the empty transcript. For the one derivation that takes an EMPTY
1465
+ * context rather than `Hash("")` — the `finished` key — use `hkdfExpandLabel`.
1466
+ */
1467
+ const deriveSecret = async (suite, secret, label, ...messages) => hkdfExpandLabel(suite, secret, label, await transcriptHash(suite, ...messages), CIPHER_SUITES[suite].hashLength);
1468
+ /** `Transcript-Hash` over the handshake messages as they went on the wire. */
1469
+ const transcriptHash = async (suite, ...messages) => new Uint8Array(await crypto.subtle.digest(CIPHER_SUITES[suite].hash, concat(...messages)));
1470
+ /**
1471
+ * The three Extracts of the schedule, each folding in the one input it exists
1472
+ * for. `Derive-Secret(., "derived", "")` between them is the step that is easy
1473
+ * to forget, and it hashes the EMPTY transcript rather than the handshake so
1474
+ * far.
1475
+ */
1476
+ const earlySecret = (suite, psk) => hkdfExtract(suite, /* @__PURE__ */ new Uint8Array(0), psk ?? new Uint8Array(CIPHER_SUITES[suite].hashLength));
1477
+ const handshakeSecret = async (suite, early, sharedSecret) => hkdfExtract(suite, await deriveSecret(suite, early, "derived"), sharedSecret);
1478
+ const masterSecret = async (suite, handshake) => hkdfExtract(suite, await deriveSecret(suite, handshake, "derived"), new Uint8Array(CIPHER_SUITES[suite].hashLength));
1479
+ /**
1480
+ * RFC 9846 §7.5's `TLS-Exporter`, verbatim:
1481
+ *
1482
+ * ```
1483
+ * TLS-Exporter(label, context_value, key_length) =
1484
+ * HKDF-Expand-Label(Derive-Secret(Secret, label, ""),
1485
+ * "exporter", Hash(context_value), key_length)
1486
+ * ```
1487
+ *
1488
+ * `Secret` is the `exporter_master_secret`, and the caller holds it because
1489
+ * this module never holds a connection. Note the two DIFFERENT hashes: the
1490
+ * inner `Derive-Secret` hashes the EMPTY string, which is why it takes no
1491
+ * messages, and the outer one hashes the caller's context.
1492
+ *
1493
+ * **This is the only caller that can reach `hkdfExpand`'s multi-block loop.**
1494
+ * Every expansion in a TLS 1.3 schedule is at most one hash length, so T(1) is
1495
+ * all a handshake ever runs; an exporter is the one that asks for more, and
1496
+ * BoGo asks for 1024 octets — 32 blocks under SHA-256, 16 under SHA-384.
1497
+ *
1498
+ * `key_length` is bounded by HKDF itself (255 × hashLength) and `hkdfExpand`
1499
+ * rejects anything past it, so a caller asking for a megabyte gets an error
1500
+ * rather than a truncated key.
1501
+ */
1502
+ const exportKeyingMaterial = async (suite, exporterMaster, label, context, length) => hkdfExpandLabel(suite, await deriveSecret(suite, exporterMaster, label), "exporter", await transcriptHash(suite, context), length);
1503
+ /** RFC 9846 §7.3. Both halves come off the same traffic secret. */
1504
+ const trafficKeys = async (suite, secret) => {
1505
+ const { keyLength, ivLength } = CIPHER_SUITES[suite];
1506
+ const [key, iv] = await Promise.all([hkdfExpandLabel(suite, secret, "key", /* @__PURE__ */ new Uint8Array(0), keyLength), hkdfExpandLabel(suite, secret, "iv", /* @__PURE__ */ new Uint8Array(0), ivLength)]);
1507
+ return {
1508
+ key,
1509
+ iv
1510
+ };
1511
+ };
1512
+ /** RFC 9846 §4.5.3. The base key is the sender's handshake traffic secret. */
1513
+ const finishedKey = (suite, baseKey) => hkdfExpandLabel(suite, baseKey, "finished", /* @__PURE__ */ new Uint8Array(0), CIPHER_SUITES[suite].hashLength);
1514
+ /** Our own `Finished.verify_data`, to send. */
1515
+ const verifyData = (suite, key, transcript) => hmac(CIPHER_SUITES[suite].hash, key, transcript);
1516
+ /**
1517
+ * Their `Finished.verify_data`, to check — through `subtle.verify`, which is
1518
+ * constant-time, rather than a byte comparison that is not. Correctness here is
1519
+ * free, so there is no reason to spend a timing side channel on it.
1520
+ */
1521
+ const isVerifyDataValid = async (suite, key, transcript, received) => crypto.subtle.verify("HMAC", await crypto.subtle.importKey("raw", nonShared$3(key), {
1522
+ name: "HMAC",
1523
+ hash: CIPHER_SUITES[suite].hash
1524
+ }, false, ["verify"]), nonShared$3(received), nonShared$3(transcript));
1525
+ //#endregion
1526
+ //#region src/key-share.ts
1527
+ /**
1528
+ * TLS 1.3 Key Share and Diffie-Hellman Key Exchange (RFC 9846 §4.3.8) over WebCrypto.
1529
+ * Supports X25519, secp256r1 (P-256), and secp384r1 (P-384).
1530
+ */
1531
+ const nonShared$2 = (bytes) => new Uint8Array(bytes);
1532
+ const toBase64Url = (bytes) => {
1533
+ let binary = "";
1534
+ for (let i = 0; i < bytes.length; i += 1) {
1535
+ const b = bytes[i];
1536
+ if (b !== void 0) binary += String.fromCharCode(b);
1537
+ }
1538
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
1539
+ };
1540
+ const fromBase64Url = (b64url) => {
1541
+ let b64 = b64url.replaceAll("-", "+").replaceAll("_", "/");
1542
+ while (b64.length % 4 !== 0) b64 += "=";
1543
+ const binary = atob(b64);
1544
+ const bytes = new Uint8Array(binary.length);
1545
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
1546
+ return bytes;
1547
+ };
1548
+ const mod = (a, m) => (a % m + m) % m;
1549
+ const modInverse = (k, m) => {
1550
+ let [a, b] = [k, m];
1551
+ let [x0, x1] = [0n, 1n];
1552
+ if (m === 1n) return 0n;
1553
+ while (a > 1n) {
1554
+ const q = a / b;
1555
+ [a, b] = [b, a % b];
1556
+ [x0, x1] = [x1 - q * x0, x0];
1557
+ }
1558
+ if (x1 < 0n) x1 += m;
1559
+ return x1;
1560
+ };
1561
+ const jacobianAdd = (p1, p2, p) => {
1562
+ if (p1 === null) return p2;
1563
+ if (p2 === null) return p1;
1564
+ const [x1, y1, z1] = p1;
1565
+ const [x2, y2, z2] = p2;
1566
+ const z1z1 = mod(z1 * z1, p);
1567
+ const z2z2 = mod(z2 * z2, p);
1568
+ const u1 = mod(x1 * z2z2, p);
1569
+ const u2 = mod(x2 * z1z1, p);
1570
+ const s1 = mod(y1 * z2 * z2z2, p);
1571
+ const s2 = mod(y2 * z1 * z1z1, p);
1572
+ if (u1 === u2) {
1573
+ if (s1 === s2) return jacobianDouble(p1, p);
1574
+ return null;
1575
+ }
1576
+ const h = mod(u2 - u1, p);
1577
+ const i = mod(4n * h * h, p);
1578
+ const j = mod(h * i, p);
1579
+ const r = mod(2n * (s2 - s1), p);
1580
+ const v = mod(u1 * i, p);
1581
+ const x3 = mod(r * r - j - 2n * v, p);
1582
+ return [
1583
+ x3,
1584
+ mod(r * (v - x3) - 2n * s1 * j, p),
1585
+ mod(mod((z1 + z2) * (z1 + z2) - z1z1 - z2z2, p) * h, p)
1586
+ ];
1587
+ };
1588
+ const jacobianDouble = (p1, p) => {
1589
+ if (p1 === null) return null;
1590
+ const [x1, y1, z1] = p1;
1591
+ const a = mod(x1 * x1, p);
1592
+ const b = mod(y1 * y1, p);
1593
+ const c = mod(b * b, p);
1594
+ const d = mod(2n * (mod((x1 + b) * (x1 + b), p) - a - c), p);
1595
+ const z1z1 = mod(z1 * z1, p);
1596
+ const e = mod(3n * (x1 - z1z1) * (x1 + z1z1), p);
1597
+ const f = mod(e * e - 2n * d, p);
1598
+ return [
1599
+ f,
1600
+ mod(e * (d - f) - 8n * c, p),
1601
+ mod(2n * y1 * z1, p)
1602
+ ];
1603
+ };
1604
+ const ecScalarMult = (k, generator, p) => {
1605
+ const [gx, gy] = generator;
1606
+ const pJac = [
1607
+ gx,
1608
+ gy,
1609
+ 1n
1610
+ ];
1611
+ let rJac = null;
1612
+ let curr = pJac;
1613
+ let scalar = k;
1614
+ while (scalar > 0n) {
1615
+ if (scalar & 1n) rJac = jacobianAdd(rJac, curr, p);
1616
+ curr = jacobianDouble(curr, p);
1617
+ scalar >>= 1n;
1618
+ }
1619
+ if (rJac === null) throw new Error("Scalar multiplication resulted in point at infinity");
1620
+ const [rx, ry, rz] = rJac;
1621
+ const zInv = modInverse(rz, p);
1622
+ const zInv2 = mod(zInv * zInv, p);
1623
+ const zInv3 = mod(zInv2 * zInv, p);
1624
+ return [mod(rx * zInv2, p), mod(ry * zInv3, p)];
1625
+ };
1626
+ const P256 = {
1627
+ p: 2n ** 256n - 2n ** 224n + 2n ** 192n + 2n ** 96n - 1n,
1628
+ G: [48439561293906451759052585252797914202762949526041747995844080717082404635286n, 36134250956749795798585127919587881956611106672985015071877198253568414405109n]
1629
+ };
1630
+ const P384 = {
1631
+ p: 2n ** 384n - 2n ** 128n - 2n ** 96n + 2n ** 32n - 1n,
1632
+ G: [26247035095799689268623156744566981891852923491109213387815615900925518854738050089022388053975719786650872476732087n, 8325710961489029985546751289520108179287853048861315594709205902480503199884419224438643760392947333078086511627871n]
1633
+ };
1634
+ const bytesToBigInt = (bytes) => {
1635
+ let hex = "";
1636
+ for (let i = 0; i < bytes.length; i += 1) {
1637
+ const b = bytes[i];
1638
+ if (b !== void 0) hex += b.toString(16).padStart(2, "0");
1639
+ }
1640
+ return BigInt(`0x${hex}`);
1641
+ };
1642
+ const bigIntToBytes$1 = (value, byteLength) => {
1643
+ let hex = value.toString(16);
1644
+ if (hex.length % 2 !== 0) hex = `0${hex}`;
1645
+ const targetHex = hex.padStart(byteLength * 2, "0");
1646
+ const bytes = new Uint8Array(byteLength);
1647
+ for (let i = 0; i < byteLength; i += 1) {
1648
+ const byteHex = targetHex.slice(i * 2, i * 2 + 2);
1649
+ bytes[i] = Number.parseInt(byteHex, 16);
1650
+ }
1651
+ return bytes;
1652
+ };
1653
+ /** The generation parameters per group, so the switch below has one arm to read. */
1654
+ const GENERATION_ALGORITHMS = {
1655
+ x25519: { name: "X25519" },
1656
+ secp256r1: {
1657
+ name: "ECDH",
1658
+ namedCurve: "P-256"
1659
+ },
1660
+ secp384r1: {
1661
+ name: "ECDH",
1662
+ namedCurve: "P-384"
1663
+ }
1664
+ };
1665
+ const generateKeyShare = async (group) => {
1666
+ const generated = await crypto.subtle.generateKey(GENERATION_ALGORITHMS[group], true, ["deriveBits"]);
1667
+ if (!("privateKey" in generated)) throw new Error(`WebCrypto returned a single key for ${group}, not a pair`);
1668
+ return {
1669
+ privateKey: generated.privateKey,
1670
+ publicKey: new Uint8Array(await crypto.subtle.exportKey("raw", generated.publicKey))
1671
+ };
1672
+ };
1673
+ const importPrivateShare = async (group, privateKeyBytes) => {
1674
+ switch (group) {
1675
+ case "x25519": {
1676
+ const pkcs8 = concat$1(Uint8Array.of(48, 46, 2, 1, 0, 48, 5, 6, 3, 43, 101, 110, 4, 34, 4, 32), privateKeyBytes);
1677
+ const privateKey = await crypto.subtle.importKey("pkcs8", nonShared$2(pkcs8), { name: "X25519" }, true, ["deriveBits"]);
1678
+ const jwk = await crypto.subtle.exportKey("jwk", privateKey);
1679
+ if (jwk.x === void 0) throw new Error("Failed to export X25519 public key from private key");
1680
+ return {
1681
+ privateKey,
1682
+ publicKey: fromBase64Url(jwk.x)
1683
+ };
1684
+ }
1685
+ case "secp256r1": {
1686
+ const [x, y] = ecScalarMult(bytesToBigInt(privateKeyBytes), P256.G, P256.p);
1687
+ const xBytes = bigIntToBytes$1(x, 32);
1688
+ const yBytes = bigIntToBytes$1(y, 32);
1689
+ const publicKey = concat$1(Uint8Array.of(4), xBytes, yBytes);
1690
+ const jwk = {
1691
+ kty: "EC",
1692
+ crv: "P-256",
1693
+ d: toBase64Url(privateKeyBytes),
1694
+ x: toBase64Url(xBytes),
1695
+ y: toBase64Url(yBytes),
1696
+ ext: true
1697
+ };
1698
+ return {
1699
+ privateKey: await crypto.subtle.importKey("jwk", jwk, {
1700
+ name: "ECDH",
1701
+ namedCurve: "P-256"
1702
+ }, true, ["deriveBits"]),
1703
+ publicKey
1704
+ };
1705
+ }
1706
+ case "secp384r1": {
1707
+ const [x, y] = ecScalarMult(bytesToBigInt(privateKeyBytes), P384.G, P384.p);
1708
+ const xBytes = bigIntToBytes$1(x, 48);
1709
+ const yBytes = bigIntToBytes$1(y, 48);
1710
+ const publicKey = concat$1(Uint8Array.of(4), xBytes, yBytes);
1711
+ const jwk = {
1712
+ kty: "EC",
1713
+ crv: "P-384",
1714
+ d: toBase64Url(privateKeyBytes),
1715
+ x: toBase64Url(xBytes),
1716
+ y: toBase64Url(yBytes),
1717
+ ext: true
1718
+ };
1719
+ return {
1720
+ privateKey: await crypto.subtle.importKey("jwk", jwk, {
1721
+ name: "ECDH",
1722
+ namedCurve: "P-384"
1723
+ }, true, ["deriveBits"]),
1724
+ publicKey
1725
+ };
1726
+ }
1727
+ }
1728
+ };
1729
+ const deriveSharedSecret = async (group, privateKey, peerPublicKeyBytes) => {
1730
+ switch (group) {
1731
+ case "x25519": {
1732
+ const peerKey = await crypto.subtle.importKey("raw", nonShared$2(peerPublicKeyBytes), { name: "X25519" }, false, []);
1733
+ const bits = await crypto.subtle.deriveBits({
1734
+ name: "X25519",
1735
+ public: peerKey
1736
+ }, privateKey, 256);
1737
+ return new Uint8Array(bits);
1738
+ }
1739
+ case "secp256r1": {
1740
+ const peerKey = await crypto.subtle.importKey("raw", nonShared$2(peerPublicKeyBytes), {
1741
+ name: "ECDH",
1742
+ namedCurve: "P-256"
1743
+ }, false, []);
1744
+ const bits = await crypto.subtle.deriveBits({
1745
+ name: "ECDH",
1746
+ public: peerKey
1747
+ }, privateKey, 256);
1748
+ return new Uint8Array(bits);
1749
+ }
1750
+ case "secp384r1": {
1751
+ const peerKey = await crypto.subtle.importKey("raw", nonShared$2(peerPublicKeyBytes), {
1752
+ name: "ECDH",
1753
+ namedCurve: "P-384"
1754
+ }, false, []);
1755
+ const bits = await crypto.subtle.deriveBits({
1756
+ name: "ECDH",
1757
+ public: peerKey
1758
+ }, privateKey, 384);
1759
+ return new Uint8Array(bits);
1760
+ }
1761
+ }
1762
+ };
1763
+ //#endregion
1764
+ //#region src/pinning.ts
1765
+ /**
1766
+ * RFC 7469 §2.1.1's pin: base64 of SHA-256 over the SubjectPublicKeyInfo DER.
1767
+ *
1768
+ * A STRING, and that is the load-bearing part. The pin goes into a caller's
1769
+ * store, which for YOZZ means it is serialised and read back, and a
1770
+ * `Uint8Array` through `JSON.stringify` becomes `{"0":48,"1":89,...}` — an
1771
+ * object that revives as something no byte comparison will ever match, so every
1772
+ * reconnection reads as a rotation. It is also the format `openssl x509
1773
+ * -pubkey | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary |
1774
+ * base64` prints, which is how a user checks a pin out of band.
1775
+ */
1776
+ const publicKeyPin = async (subjectPublicKeyInfoDer) => {
1777
+ const digest = await crypto.subtle.digest("SHA-256", new Uint8Array(subjectPublicKeyInfoDer));
1778
+ return btoa(String.fromCharCode(...new Uint8Array(digest)));
1779
+ };
1780
+ /**
1781
+ * A `Validator` that runs another one and then refuses any chain whose leaf key
1782
+ * is not the pinned one.
1783
+ *
1784
+ * The inner validator runs FIRST and its failure is returned untouched. A pin
1785
+ * that matched could otherwise carry an expired or unanchored chain through,
1786
+ * which would make pinning a way to weaken validation rather than a check on
1787
+ * top of it.
1788
+ *
1789
+ * A mismatch is `rejected-by-policy`, which `@yozz.app/tls` maps to a
1790
+ * `certificate_unknown` alert. It deliberately is not one of the codes that
1791
+ * name a property of the chain: the chain is fine. Reporting a rotated key as
1792
+ * `no-path-to-trust-anchor` would send a user to their CA over a problem that
1793
+ * has nothing to do with one.
1794
+ *
1795
+ * `pin` is required. There is no null-means-allow-anything mode, because that
1796
+ * is a pinned validator that silently never fires — a caller with no pin yet
1797
+ * passes the unwrapped validator and learns one from the result.
1798
+ *
1799
+ * **One per connection**, since a pin belongs to a host and the caller is the
1800
+ * one holding the store that maps between them. Reusing an instance across
1801
+ * hosts refuses the second one, which is the safe direction and still a bug.
1802
+ *
1803
+ * ONE pin, not a set. HPKP needed a backup pin to stop a browser bricking a
1804
+ * site it could not un-pin; a mismatch here asks the user, who can accept. The
1805
+ * case that would need a set is a hostname behind independently keyed
1806
+ * frontends, and every IP of all nine stage-3 mail hosts serves one key.
1807
+ */
1808
+ const pinnedValidator = ({ validator, pin }) => ({
1809
+ name: `${validator.name}+spki-pin`,
1810
+ validatePath: async (request) => {
1811
+ const result = await validator.validatePath(request);
1812
+ if (!result.ok) return result;
1813
+ if (await publicKeyPin(result.path.leafSubjectPublicKeyInfoDer) !== pin) return {
1814
+ ok: false,
1815
+ reason: { code: "rejected-by-policy" }
1816
+ };
1817
+ return result;
1818
+ }
1819
+ });
1820
+ //#endregion
1821
+ //#region src/record.ts
1822
+ /**
1823
+ * TLS 1.3 Record Layer (RFC 9846 §5): plaintext and AEAD framing, sealing, and opening.
1824
+ */
1825
+ const nonShared$1 = (bytes) => new Uint8Array(bytes);
1826
+ /** 2^14 of content, plus the one byte naming its type (RFC 9846 §5.2). */
1827
+ const MAX_INNER_PLAINTEXT = 16385;
1828
+ /**
1829
+ * `TLSPlaintext.length` is `uint16` but RFC 9846 §5.1 caps it at 2^14 — the
1830
+ * ceiling every check below and the fragmenting writer in `handshake.ts` share,
1831
+ * so a message larger than one record is split rather than refused.
1832
+ */
1833
+ const MAX_RECORD_PLAINTEXT = 16384;
1834
+ const sealPlain = (type, payload, legacyVersion = LEGACY_RECORD_VERSION.STANDARD) => {
1835
+ if (payload.length > 16384) throw new Error("record_overflow");
1836
+ const typeCode = CONTENT_TYPES[type];
1837
+ if (typeCode === void 0) throw new Error(`Unknown content type: ${type}`);
1838
+ return concat$1(writeUint8(typeCode), writeUint16(legacyVersion), writeUint16(payload.length), payload);
1839
+ };
1840
+ const openPlain = (record) => {
1841
+ if (record.length < 5) return {
1842
+ ok: false,
1843
+ description: "decode_error"
1844
+ };
1845
+ const typeCode = record[0];
1846
+ const v0 = record[1];
1847
+ const v1 = record[2];
1848
+ const l0 = record[3];
1849
+ const l1 = record[4];
1850
+ if (typeCode === void 0 || v0 === void 0 || v1 === void 0 || l0 === void 0 || l1 === void 0) return {
1851
+ ok: false,
1852
+ description: "decode_error"
1853
+ };
1854
+ const length = l0 << 8 | l1;
1855
+ if (length > 16384) return {
1856
+ ok: false,
1857
+ description: "record_overflow"
1858
+ };
1859
+ if (record.length !== 5 + length) return {
1860
+ ok: false,
1861
+ description: "decode_error"
1862
+ };
1863
+ const type = Object.entries(CONTENT_TYPES).find(([, code]) => code === typeCode)?.[0];
1864
+ if (type === void 0) return {
1865
+ ok: false,
1866
+ description: "decode_error"
1867
+ };
1868
+ return {
1869
+ ok: true,
1870
+ type,
1871
+ payload: record.subarray(5)
1872
+ };
1873
+ };
1874
+ const buildNonce = (iv, seq) => {
1875
+ if (iv.length !== 12) throw new Error(`AEAD IV must be 12 octets, got ${iv.length}`);
1876
+ const nonce = /* @__PURE__ */ new Uint8Array(12);
1877
+ nonce.set(iv);
1878
+ const view = /* @__PURE__ */ new DataView(/* @__PURE__ */ new ArrayBuffer(8));
1879
+ view.setBigUint64(0, BigInt(seq), false);
1880
+ for (let i = 0; i < 8; i += 1) {
1881
+ const b = nonce[4 + i];
1882
+ const s = view.getUint8(i);
1883
+ if (b !== void 0) nonce[4 + i] = b ^ s;
1884
+ }
1885
+ return nonce;
1886
+ };
1887
+ const sealAead = async (key, iv, seq, type, payload, paddingZeros = 0) => {
1888
+ if (payload.length > 16384) throw new Error("record_overflow");
1889
+ const typeCode = CONTENT_TYPES[type];
1890
+ if (typeCode === void 0) throw new Error(`Unknown content type: ${type}`);
1891
+ const innerPlaintext = new Uint8Array(payload.length + 1 + paddingZeros);
1892
+ innerPlaintext.set(payload, 0);
1893
+ innerPlaintext[payload.length] = typeCode;
1894
+ const ciphertextLength = innerPlaintext.length + 16;
1895
+ if (ciphertextLength > 16640) throw new Error("record_overflow");
1896
+ const aad = Uint8Array.of(CONTENT_TYPES.application_data, 3, 3, ciphertextLength >> 8 & 255, ciphertextLength & 255);
1897
+ const nonce = buildNonce(iv, seq);
1898
+ const cryptoKey = key instanceof CryptoKey ? key : await crypto.subtle.importKey("raw", nonShared$1(key), { name: "AES-GCM" }, false, ["encrypt"]);
1899
+ return concat$1(aad, new Uint8Array(await crypto.subtle.encrypt({
1900
+ name: "AES-GCM",
1901
+ iv: nonce,
1902
+ additionalData: aad,
1903
+ tagLength: 128
1904
+ }, cryptoKey, nonShared$1(innerPlaintext))));
1905
+ };
1906
+ const openAead = async (key, iv, seq, record) => {
1907
+ if (record.length < 5) return {
1908
+ ok: false,
1909
+ description: "decode_error"
1910
+ };
1911
+ const outerType = record[0];
1912
+ const v0 = record[1];
1913
+ const v1 = record[2];
1914
+ const l0 = record[3];
1915
+ const l1 = record[4];
1916
+ if (outerType === void 0 || v0 === void 0 || v1 === void 0 || l0 === void 0 || l1 === void 0) return {
1917
+ ok: false,
1918
+ description: "decode_error"
1919
+ };
1920
+ if (outerType !== CONTENT_TYPES.application_data) return {
1921
+ ok: false,
1922
+ description: "decode_error"
1923
+ };
1924
+ /**
1925
+ * The one place the version is NOT ignored, and §5.2 is why: on a
1926
+ * TLSCiphertext "the legacy_record_version field is always 0x0303... there
1927
+ * are no historical compatibility concerns where other values might be
1928
+ * received". A ciphertext is never the initial ClientHello, so 0x0301 is not
1929
+ * a case either — §5.1's "MUST be ignored" governs the PLAINTEXT field, which
1930
+ * `openPlain` and `RecordReader` duly ignore.
1931
+ */
1932
+ if ((v0 << 8 | v1) !== LEGACY_RECORD_VERSION.STANDARD) return {
1933
+ ok: false,
1934
+ description: "decode_error"
1935
+ };
1936
+ const length = l0 << 8 | l1;
1937
+ if (length > 16640) return {
1938
+ ok: false,
1939
+ description: "record_overflow"
1940
+ };
1941
+ if (record.length !== 5 + length) return {
1942
+ ok: false,
1943
+ description: "decode_error"
1944
+ };
1945
+ if (length < 17) return {
1946
+ ok: false,
1947
+ description: "bad_record_mac"
1948
+ };
1949
+ const aad = record.subarray(0, 5);
1950
+ const ciphertextWithTag = record.subarray(5);
1951
+ const nonce = buildNonce(iv, seq);
1952
+ const cryptoKey = key instanceof CryptoKey ? key : await crypto.subtle.importKey("raw", nonShared$1(key), { name: "AES-GCM" }, false, ["decrypt"]);
1953
+ let innerPlaintext;
1954
+ try {
1955
+ innerPlaintext = new Uint8Array(await crypto.subtle.decrypt({
1956
+ name: "AES-GCM",
1957
+ iv: nonShared$1(nonce),
1958
+ additionalData: nonShared$1(aad),
1959
+ tagLength: 128
1960
+ }, cryptoKey, nonShared$1(ciphertextWithTag)));
1961
+ } catch {
1962
+ return {
1963
+ ok: false,
1964
+ description: "bad_record_mac"
1965
+ };
1966
+ }
1967
+ /**
1968
+ * RFC 9846 §5.2: content is capped at 2^14, and the inner plaintext is that
1969
+ * plus the one content-type byte. Padding does NOT buy room — checking the
1970
+ * content alone lets 2^14 of it through with padding stacked on top, which is
1971
+ * the record BoGo's `LargePlaintext-TLS13-Padded-16384-1` sends.
1972
+ */
1973
+ if (innerPlaintext.length > MAX_INNER_PLAINTEXT) return {
1974
+ ok: false,
1975
+ description: "record_overflow"
1976
+ };
1977
+ let i = innerPlaintext.length - 1;
1978
+ while (i >= 0 && innerPlaintext[i] === 0) i -= 1;
1979
+ if (i < 0) return {
1980
+ ok: false,
1981
+ description: "unexpected_message"
1982
+ };
1983
+ const innerTypeCode = innerPlaintext[i];
1984
+ const payload = innerPlaintext.subarray(0, i);
1985
+ const type = Object.entries(CONTENT_TYPES).find(([, code]) => code === innerTypeCode)?.[0];
1986
+ if (type === void 0) return {
1987
+ ok: false,
1988
+ description: "unexpected_message"
1989
+ };
1990
+ return {
1991
+ ok: true,
1992
+ type,
1993
+ payload
1994
+ };
1995
+ };
1996
+ var RecordReader = class {
1997
+ buffer = /* @__PURE__ */ new Uint8Array(0);
1998
+ readChunk;
1999
+ constructor(readChunk) {
2000
+ this.readChunk = readChunk ?? (() => Promise.resolve(null));
2001
+ }
2002
+ feed(chunk) {
2003
+ this.buffer = concat$1(this.buffer, chunk);
2004
+ }
2005
+ async readRecord() {
2006
+ while (this.buffer.length < 5) {
2007
+ const chunk = await this.readChunk();
2008
+ if (chunk === null) {
2009
+ if (this.buffer.length === 0) return {
2010
+ ok: true,
2011
+ kind: "eof"
2012
+ };
2013
+ return {
2014
+ ok: false,
2015
+ kind: "truncated"
2016
+ };
2017
+ }
2018
+ this.buffer = concat$1(this.buffer, chunk);
2019
+ }
2020
+ const v0 = this.buffer[1];
2021
+ const v1 = this.buffer[2];
2022
+ const l0 = this.buffer[3];
2023
+ const l1 = this.buffer[4];
2024
+ if (v0 === void 0 || v1 === void 0 || l0 === void 0 || l1 === void 0) return {
2025
+ ok: false,
2026
+ kind: "alert",
2027
+ description: "decode_error"
2028
+ };
2029
+ const length = l0 << 8 | l1;
2030
+ if (length > 16640) return {
2031
+ ok: false,
2032
+ kind: "alert",
2033
+ description: "record_overflow"
2034
+ };
2035
+ const totalRecordLength = 5 + length;
2036
+ while (this.buffer.length < totalRecordLength) {
2037
+ const chunk = await this.readChunk();
2038
+ if (chunk === null) return {
2039
+ ok: false,
2040
+ kind: "truncated"
2041
+ };
2042
+ this.buffer = concat$1(this.buffer, chunk);
2043
+ }
2044
+ const record = this.buffer.subarray(0, totalRecordLength);
2045
+ this.buffer = this.buffer.subarray(totalRecordLength);
2046
+ return {
2047
+ ok: true,
2048
+ kind: "record",
2049
+ record
2050
+ };
2051
+ }
2052
+ };
2053
+ //#endregion
2054
+ //#region src/session.ts
2055
+ /**
2056
+ * Session resumption — RFC 9846 §2.2, §4.3.11 and §4.7.1.
2057
+ *
2058
+ * A `NewSessionTicket` is not a session. It NAMES one: the secret it stands for
2059
+ * is derived from the connection that issued it, and the ticket itself is an
2060
+ * opaque label the server chose. So this module turns a ticket plus the
2061
+ * issuing connection's resumption master secret into a value a caller can
2062
+ * store, and computes the binder that proves, on the next connection, that we
2063
+ * hold the secret rather than merely the label.
2064
+ *
2065
+ * Nothing here reads or writes a record, and nothing here decides when to
2066
+ * resume — the handshake supplies the transcript and asks. That is what lets
2067
+ * every rule about whether a ticket is usable live here, stated once, rather
2068
+ * than at each of the sites that would otherwise have to remember them: two
2069
+ * that decide a ticket is not a session at all, and four that decide a stored
2070
+ * one may not go on the wire today.
2071
+ */
2072
+ /**
2073
+ * **Both `Date` fields are real `Date`s, and a caller that persists a session
2074
+ * has to revive them.** JSON turns them into strings, and this module reads them
2075
+ * with `.getTime()` — a rehydrated string would throw out of `startTls` rather
2076
+ * than being refused. The store is the caller's, so the revival is too.
2077
+ */
2078
+ /**
2079
+ * RFC 9846 §4.7.1: "Servers MUST NOT use any value greater than 604800 seconds
2080
+ * (7 days)", and "Clients MUST NOT use tickets for longer than 7 days after
2081
+ * issuance, regardless of the ticket_lifetime". A server that sends more has not
2082
+ * earned a fatal alert — the RFC names none — so the ceiling is applied on the
2083
+ * way in and the session is simply shorter-lived than the server hoped.
2084
+ */
2085
+ const MAX_TICKET_LIFETIME_SECONDS = 604800;
2086
+ /**
2087
+ * How long a chain of tickets may outlive the handshake that authenticated it.
2088
+ *
2089
+ * RFC 9846 §4.7.1 asks for a limit and says what it should weigh: "the lifetime
2090
+ * of the peer's certificate, the likelihood of intervening revocation, and the
2091
+ * time since the peer's online CertificateVerify signature". Two of those three
2092
+ * are sharp here — v1 checks no revocation at all, so a compromised key is only
2093
+ * ever noticed by a chain being rebuilt, and public mail leaves rotate every
2094
+ * 60-90 days. So the ceiling is the same week a single ticket gets: resumption
2095
+ * stops until a full handshake proves the peer again, which for a client that
2096
+ * reconnects daily costs one handshake in seven.
2097
+ */
2098
+ const MAX_AUTHENTICATION_AGE_SECONDS = 604800;
2099
+ /**
2100
+ * The largest ticket this client will keep.
2101
+ *
2102
+ * `opaque ticket<1..2^16-1>` allows 65535, and the ClientHello it would ride
2103
+ * back in declares its extensions with a uint16 too — so a ticket near that
2104
+ * ceiling cannot be ENCODED, and the throw lands on the connection AFTER the one
2105
+ * that received it, out of a session the caller has already stored. A ticket the
2106
+ * next ClientHello cannot carry is not a session, and refusing it here is the
2107
+ * only place that decision is made once.
2108
+ *
2109
+ * 16KiB is far above anything real: RFC 8448's ticket is 178 octets, and
2110
+ * OpenSSL and BoringSSL mint a few hundred. It coincides with a record's
2111
+ * capacity and is not derived from it — the limit being dodged is the uint16
2112
+ * extension block, roughly four times larger. A ticket at this ceiling still
2113
+ * overruns one record, which is what keeps the fragmenting writer exercised.
2114
+ */
2115
+ const MAX_TICKET_BYTES = 16384;
2116
+ /**
2117
+ * RFC 9846 §4.7.1's `PSK = HKDF-Expand-Label(resumption_secret, "resumption",
2118
+ * ticket_nonce, Hash.length)` — 9846 renamed it, and 8446's
2119
+ * `resumption_master_secret` appears nowhere in the newer document — or
2120
+ * `undefined` for a ticket that
2121
+ * could never be used, which is a different thing from one that has expired
2122
+ * since. Both live here because both are properties of the ticket's own fields,
2123
+ * and a caller handed a session it can never offer has been given a liability to
2124
+ * store rather than a session.
2125
+ */
2126
+ const sessionFromTicket = async ({ serverName, expectedPeerName, suite, resumptionSecret, receivedAt, authenticatedAt, peerSignatureScheme, peerCertificateChain, ticket, ticketNonce, ticketAgeAdd, ticketLifetime }) => {
2127
+ /**
2128
+ * Three ways a ticket is not a session at all: no window to be used in, too
2129
+ * large to ride back in a ClientHello, or behind a certificate check already
2130
+ * past the ceiling — the last of which a long-held `IDLE` connection reaches
2131
+ * on its own, renewing on day 8 of a chain that started at day 0. Refusing
2132
+ * here rather than only at `isSessionOfferable` is the difference between a
2133
+ * caller storing nothing and a caller storing a liability.
2134
+ */
2135
+ if (ticketLifetime === 0 || ticket.length > MAX_TICKET_BYTES || !isWithinAuthenticationCeiling(authenticatedAt, receivedAt)) return;
2136
+ return {
2137
+ serverName,
2138
+ expectedPeerName,
2139
+ suite,
2140
+ ticket,
2141
+ ticketAgeAdd,
2142
+ receivedAt,
2143
+ authenticatedAt,
2144
+ peerSignatureScheme,
2145
+ peerCertificateChain,
2146
+ lifetimeSeconds: Math.min(ticketLifetime, MAX_TICKET_LIFETIME_SECONDS),
2147
+ preSharedKey: await hkdfExpandLabel(suite, resumptionSecret, "resumption", ticketNonce, CIPHER_SUITES[suite].hashLength)
2148
+ };
2149
+ };
2150
+ /**
2151
+ * `| undefined` because the argument is a rehydrated value wearing a type, and
2152
+ * a store that dropped the field entirely is the first thing this has to
2153
+ * survive rather than throw on.
2154
+ */
2155
+ const isDerChain = (chain) => chain?.leafDer instanceof Uint8Array && Array.isArray(chain.intermediateDer) && chain.intermediateDer.every((der) => der instanceof Uint8Array);
2156
+ /**
2157
+ * The two fields a `TlsSession` carries that TypeScript cannot vouch for, and
2158
+ * the ONE place that decides they are usable.
2159
+ *
2160
+ * A session is a value the caller stored and revived, so its type is a claim
2161
+ * rather than a fact. JSON is the ordinary way it becomes false: a
2162
+ * `SignatureScheme` comes back as a string that is no longer a scheme we
2163
+ * implement, and a `Uint8Array` comes back as `{"0":48,...}` — or the field is
2164
+ * simply gone. Neither is visible to the compiler and both are typed correctly.
2165
+ *
2166
+ * **It throws where it is CALLED FROM that matters, and that is `startTls`,
2167
+ * before a byte goes out.** A cross-model review found this check living only
2168
+ * in `inheritedAuthentication`, which runs after the client's Finished — so a
2169
+ * store that had dropped the chain reached `validatePath` with `undefined` and
2170
+ * came back out of `startTls` as a raw `TypeError`, mid-handshake, where every
2171
+ * other failure in the package is a typed `TlsFailure`. Two other shapes were
2172
+ * worse than that one: a JSON-revived chain reached `validatePath` and was
2173
+ * refused as `malformed-certificate`, blaming the mail host for a broken store,
2174
+ * and with `reverifyOnResume: false` the throw landed AFTER the handshake
2175
+ * completed on the wire.
2176
+ *
2177
+ * It throws rather than returning a `TlsFailure` because it is the CALLER's
2178
+ * bug, not the peer's — the same reason an empty `supportedGroups` throws.
2179
+ */
2180
+ const assertUsableSession = (session) => {
2181
+ if (!SUPPORTED_SIGNATURE_SCHEMES.includes(session.peerSignatureScheme)) throw new Error("the stored session names no signature scheme this client implements");
2182
+ if (!isDerChain(session.peerCertificateChain)) throw new Error("the stored session carries no usable peer certificate chain");
2183
+ };
2184
+ /**
2185
+ * What the connection ABOUT TO MINT A TICKET should record — and the reason it
2186
+ * lives here rather than inline in the handshake.
2187
+ *
2188
+ * A resumed handshake verifies no SIGNATURE: no Certificate, no
2189
+ * CertificateVerify. (It may re-validate the stored chain — `reverifyOnResume`
2190
+ * in `handshake.ts` — which is a different claim and does not re-prove the
2191
+ * signature.) So it INHERITS, and inheriting is load-bearing. `authenticatedAt` is what
2192
+ * `isWithinAuthenticationCeiling` above measures against, and RFC 9846 §4.7.1
2193
+ * is explicit about why the measurement exists: "it is possible to continue
2194
+ * issuing new tickets which indefinitely extend the lifetime of the keying
2195
+ * material originally derived from an initial non-PSK handshake". Take a fresh
2196
+ * instant here and every renewed ticket looks newly authenticated, the ceiling
2197
+ * never bites, and resumption to a peer outlives the one signature that ever
2198
+ * proved it — which in v1 nothing else would catch, because v1 checks no
2199
+ * revocation at all.
2200
+ *
2201
+ * That failure is invisible: the handshake succeeds, the data flows, and the
2202
+ * only symptom is a limit silently not applying. It is extracted so the rule
2203
+ * has somewhere to be tested, because no peer this package can drive mints a
2204
+ * ticket on a resumed connection — see the call site in `handshake.ts`.
2205
+ */
2206
+ const inheritedAuthentication = (resumed, verifiedNow) => {
2207
+ if (resumed !== void 0) {
2208
+ /**
2209
+ * The same rehydration check `startTls` already ran on the way in, because
2210
+ * this function is reachable without it: it is exported, unit-tested
2211
+ * directly, and is where the renewal rule is stated. Running it twice costs
2212
+ * two comparisons and removes the question of which caller is responsible.
2213
+ */
2214
+ assertUsableSession(resumed);
2215
+ const { authenticatedAt, peerSignatureScheme, peerCertificateChain } = resumed;
2216
+ return {
2217
+ authenticatedAt,
2218
+ peerSignatureScheme,
2219
+ peerCertificateChain
2220
+ };
2221
+ }
2222
+ if (verifiedNow === void 0) throw new Error("a connection authenticated the peer neither freshly nor by resumption");
2223
+ return verifiedNow;
2224
+ };
2225
+ const isWithinAuthenticationCeiling = (authenticatedAt, now) => {
2226
+ const age = now.getTime() - authenticatedAt.getTime();
2227
+ return age >= 0 && age < MAX_AUTHENTICATION_AGE_SECONDS * 1e3;
2228
+ };
2229
+ const ticketAgeMs = (session, now) => now.getTime() - session.receivedAt.getTime();
2230
+ /**
2231
+ * RFC 9846 §4.3.11.1: the age we report is the real age in milliseconds plus
2232
+ * the server's own random offset, modulo 2^32. The offset is what stops a
2233
+ * passive observer tying two connections together by their ticket ages.
2234
+ */
2235
+ const obfuscatedTicketAge = (session, now) => (ticketAgeMs(session, now) + session.ticketAgeAdd) % 4294967296;
2236
+ /**
2237
+ * DNS names are case-insensitive, so a caller that round-trips a hostname
2238
+ * through anything case-normalising would otherwise silently stop resuming.
2239
+ *
2240
+ * The fold is `@yozz.app/x509`'s own, imported rather than rewritten: it maps A-Z
2241
+ * and nothing else, which is exact for an LDH hostname. `String.toLowerCase()`
2242
+ * is full Unicode and would WIDEN this comparison — KELVIN SIGN folds to `k` —
2243
+ * in a security check whose sibling package refuses to normalise anything it
2244
+ * does not have to. An IP is compared as written.
2245
+ */
2246
+ const isSamePeerName = (a, b) => {
2247
+ if (a === null || b === null) return a === b;
2248
+ if (a.kind !== b.kind) return false;
2249
+ return a.kind === "dns" ? asciiLower(a.value) === asciiLower(b.value) : a.value === b.value;
2250
+ };
2251
+ /**
2252
+ * Whether a stored session may still go on the wire.
2253
+ *
2254
+ * The identity checks come first and they are the load-bearing ones: a resumed
2255
+ * handshake proves nothing about who the peer is, so the session may only be
2256
+ * offered on a connection asking for exactly the identity the issuing
2257
+ * connection proved. Same host, and the same `expectedPeerName` policy — an
2258
+ * unset one and a `null` one are different answers, and neither may stand in
2259
+ * for the other.
2260
+ *
2261
+ * Then two clocks, because a ticket has two ages. Its own, which the server set
2262
+ * a lifetime on; and the age of the CERTIFICATE CHECK behind it, which renewal
2263
+ * would otherwise extend forever (§4.7.1). A negative age fails both: it encodes
2264
+ * as a huge `obfuscated_ticket_age`, which a server doing 0-RTT anti-replay
2265
+ * reads as a replayed ticket.
2266
+ */
2267
+ const isSessionOfferable = (session, serverName, expectedPeerName, now) => {
2268
+ const age = ticketAgeMs(session, now);
2269
+ return asciiLower(session.serverName) === asciiLower(serverName) && isSamePeerName(session.expectedPeerName, expectedPeerName) && age >= 0 && age < session.lifetimeSeconds * 1e3 && isWithinAuthenticationCeiling(session.authenticatedAt, now);
2270
+ };
2271
+ /**
2272
+ * RFC 9846 §4.3.11.2's `PskBinderEntry`, "computed in the same way as the
2273
+ * Finished message but with the BaseKey being the binder_key".
2274
+ *
2275
+ * The transcript it runs over is the handshake so far ending in a TRUNCATED
2276
+ * ClientHello — everything up to and including the identity list, with the
2277
+ * binder list itself cut off. After a HelloRetryRequest that is three messages
2278
+ * (`message_hash`, the retry, the truncated second ClientHello), not one, which
2279
+ * is why this takes the whole transcript rather than a single message.
2280
+ */
2281
+ const pskBinder = async (session, ...transcript) => {
2282
+ const binderKey = await deriveSecret(session.suite, await earlySecret(session.suite, session.preSharedKey), "res binder");
2283
+ return verifyData(session.suite, await finishedKey(session.suite, binderKey), await transcriptHash(session.suite, ...transcript));
2284
+ };
2285
+ /**
2286
+ * The same ClientHello with its binder filled in.
2287
+ *
2288
+ * The binder covers the message it travels in, so it cannot be written by the
2289
+ * pass that lays the message out — the bytes have to exist first, with the
2290
+ * binder's own space zeroed, and then be overwritten. `precedingMessages` is
2291
+ * the handshake before this ClientHello: empty for the first one, and the
2292
+ * `message_hash` plus the HelloRetryRequest for a retried one.
2293
+ */
2294
+ const bindClientHello = async (session, clientHello, precedingMessages) => {
2295
+ const binderLength = CIPHER_SUITES[session.suite].hashLength;
2296
+ const truncated = clientHello.subarray(0, clientHello.length - binderListLength(binderLength));
2297
+ const binder = await pskBinder(session, ...precedingMessages, truncated);
2298
+ const bound = new Uint8Array(clientHello);
2299
+ bound.set(binder, clientHello.length - binderLength);
2300
+ return bound;
2301
+ };
2302
+ //#endregion
2303
+ //#region src/transcript.ts
2304
+ /**
2305
+ * TLS 1.3 Running Transcript and Transcript Hash (RFC 9846 §4.1).
2306
+ */
2307
+ var Transcript = class {
2308
+ suite;
2309
+ messages = [];
2310
+ hasReplacedClientHello1 = false;
2311
+ constructor(suite = "TLS_AES_128_GCM_SHA256") {
2312
+ this.suite = suite;
2313
+ }
2314
+ add(message) {
2315
+ this.messages.push(message);
2316
+ }
2317
+ /**
2318
+ * RFC 9846 §4.1:
2319
+ * When a HelloRetryRequest is sent or received, the transcript is reconstructed as:
2320
+ * message_hash (type 254) || uint24(Hash.length) || Hash(ClientHello1)
2321
+ */
2322
+ async replaceClientHello1WithMessageHash() {
2323
+ if (this.hasReplacedClientHello1) throw new Error("replaceClientHello1WithMessageHash may be called only once");
2324
+ if (this.messages.length === 0) throw new Error("No ClientHello1 in transcript to replace");
2325
+ const ch1 = this.messages[0];
2326
+ if (ch1 === void 0) throw new Error("ClientHello1 is undefined");
2327
+ const hash = await transcriptHash(this.suite, ch1);
2328
+ const messageHashMsg = concat$1(writeUint8(HANDSHAKE_TYPES.message_hash), writeUint24(hash.length), hash);
2329
+ this.messages[0] = messageHashMsg;
2330
+ this.hasReplacedClientHello1 = true;
2331
+ }
2332
+ async hash() {
2333
+ return transcriptHash(this.suite, ...this.messages);
2334
+ }
2335
+ getMessages() {
2336
+ return [...this.messages];
2337
+ }
2338
+ };
2339
+ //#endregion
2340
+ //#region src/verify.ts
2341
+ /**
2342
+ * CertificateVerify signature verification and leaf public key importing (RFC 9846 §4.5.2).
2343
+ */
2344
+ const nonShared = (bytes) => new Uint8Array(bytes);
2345
+ const parseOid = (bytes) => {
2346
+ if (bytes.length === 0) return "";
2347
+ const first = bytes[0];
2348
+ if (first === void 0) return "";
2349
+ const arcs = [Math.floor(first / 40), first % 40];
2350
+ let currentArc = 0;
2351
+ for (let i = 1; i < bytes.length; i += 1) {
2352
+ const b = bytes[i];
2353
+ if (b === void 0) break;
2354
+ currentArc = currentArc << 7 | b & 127;
2355
+ if ((b & 128) === 0) {
2356
+ arcs.push(currentArc);
2357
+ currentArc = 0;
2358
+ }
2359
+ }
2360
+ return arcs.join(".");
2361
+ };
2362
+ const SCHEME_CURVE_OIDS = {
2363
+ [SIGNATURE_SCHEMES.ecdsa_secp256r1_sha256]: "1.2.840.10045.3.1.7",
2364
+ [SIGNATURE_SCHEMES.ecdsa_secp384r1_sha384]: "1.3.132.0.34"
2365
+ };
2366
+ const getSpkiAlgorithm = (spkiDer) => {
2367
+ try {
2368
+ const tree = decodeDer(spkiDer);
2369
+ if (tree.tagClass !== "universal" || tree.tagNumber !== 16 || !tree.isConstructed) return;
2370
+ const algId = tree.children[0];
2371
+ if (algId === void 0 || algId.tagNumber !== 16 || !algId.isConstructed) return;
2372
+ const oidNode = algId.children[0];
2373
+ if (oidNode === void 0 || oidNode.tagNumber !== 6) return;
2374
+ const parameters = algId.children[1];
2375
+ return {
2376
+ oid: parseOid(oidNode.content),
2377
+ curveOid: parameters !== void 0 && parameters.tagNumber === 6 ? parseOid(parameters.content) : void 0
2378
+ };
2379
+ } catch {
2380
+ return;
2381
+ }
2382
+ };
2383
+ const bigIntToBytes = (value, byteLength) => {
2384
+ let hex = value.toString(16);
2385
+ if (hex.length % 2 !== 0) hex = `0${hex}`;
2386
+ const targetHex = hex.padStart(byteLength * 2, "0");
2387
+ const bytes = new Uint8Array(byteLength);
2388
+ for (let i = 0; i < byteLength; i += 1) {
2389
+ const byteHex = targetHex.slice(i * 2, i * 2 + 2);
2390
+ bytes[i] = Number.parseInt(byteHex, 16);
2391
+ }
2392
+ return bytes;
2393
+ };
2394
+ const derToP1363 = (derSignature, scalarLength) => {
2395
+ const tree = decodeDer(derSignature);
2396
+ if (tree.tagClass !== "universal" || tree.tagNumber !== 16 || !tree.isConstructed) throw new Error("malformed ECDSA signature DER SEQUENCE");
2397
+ if (tree.children.length !== 2) throw new Error("ECDSA signature SEQUENCE must have exactly 2 integers");
2398
+ const rNode = tree.children[0];
2399
+ const sNode = tree.children[1];
2400
+ if (rNode === void 0 || sNode === void 0) throw new Error("ECDSA signature missing r or s");
2401
+ const rBigInt = decodeInteger(rNode);
2402
+ const sBigInt = decodeInteger(sNode);
2403
+ return concat$1(bigIntToBytes(rBigInt, scalarLength), bigIntToBytes(sBigInt, scalarLength));
2404
+ };
2405
+ const importLeafKey = async (spkiDer, scheme, algorithmOid) => {
2406
+ const algorithm = getSpkiAlgorithm(spkiDer);
2407
+ const oid = algorithmOid ?? algorithm?.oid;
2408
+ if (oid === void 0) return {
2409
+ ok: false,
2410
+ description: "bad_certificate"
2411
+ };
2412
+ /**
2413
+ * §4.3.3 names the two RSA-PSS families apart: `rsa_pss_rsae_*` are
2414
+ * "RSASSA-PSS algorithms with public key OID rsaEncryption", and an
2415
+ * id-RSASSA-PSS key belongs to `rsa_pss_pss_*`, which this client does not
2416
+ * offer. Accepting both OIDs here took a key the scheme forbids.
2417
+ */
2418
+ const requiredCurveOid = SCHEME_CURVE_OIDS[scheme];
2419
+ if (requiredCurveOid !== void 0 && algorithm !== void 0 && algorithm.curveOid !== requiredCurveOid) return {
2420
+ ok: false,
2421
+ description: "illegal_parameter"
2422
+ };
2423
+ try {
2424
+ switch (scheme) {
2425
+ case SIGNATURE_SCHEMES.rsa_pss_rsae_sha256:
2426
+ if (oid !== "1.2.840.113549.1.1.1") return {
2427
+ ok: false,
2428
+ description: "illegal_parameter"
2429
+ };
2430
+ return {
2431
+ ok: true,
2432
+ key: await crypto.subtle.importKey("spki", nonShared(spkiDer), {
2433
+ name: "RSA-PSS",
2434
+ hash: "SHA-256"
2435
+ }, false, ["verify"])
2436
+ };
2437
+ case SIGNATURE_SCHEMES.rsa_pss_rsae_sha384:
2438
+ if (oid !== "1.2.840.113549.1.1.1") return {
2439
+ ok: false,
2440
+ description: "illegal_parameter"
2441
+ };
2442
+ return {
2443
+ ok: true,
2444
+ key: await crypto.subtle.importKey("spki", nonShared(spkiDer), {
2445
+ name: "RSA-PSS",
2446
+ hash: "SHA-384"
2447
+ }, false, ["verify"])
2448
+ };
2449
+ case SIGNATURE_SCHEMES.rsa_pss_rsae_sha512:
2450
+ if (oid !== "1.2.840.113549.1.1.1") return {
2451
+ ok: false,
2452
+ description: "illegal_parameter"
2453
+ };
2454
+ return {
2455
+ ok: true,
2456
+ key: await crypto.subtle.importKey("spki", nonShared(spkiDer), {
2457
+ name: "RSA-PSS",
2458
+ hash: "SHA-512"
2459
+ }, false, ["verify"])
2460
+ };
2461
+ case SIGNATURE_SCHEMES.ecdsa_secp256r1_sha256:
2462
+ if (oid !== "1.2.840.10045.2.1") return {
2463
+ ok: false,
2464
+ description: "illegal_parameter"
2465
+ };
2466
+ return {
2467
+ ok: true,
2468
+ key: await crypto.subtle.importKey("spki", nonShared(spkiDer), {
2469
+ name: "ECDSA",
2470
+ namedCurve: "P-256"
2471
+ }, false, ["verify"])
2472
+ };
2473
+ case SIGNATURE_SCHEMES.ecdsa_secp384r1_sha384:
2474
+ if (oid !== "1.2.840.10045.2.1") return {
2475
+ ok: false,
2476
+ description: "illegal_parameter"
2477
+ };
2478
+ return {
2479
+ ok: true,
2480
+ key: await crypto.subtle.importKey("spki", nonShared(spkiDer), {
2481
+ name: "ECDSA",
2482
+ namedCurve: "P-384"
2483
+ }, false, ["verify"])
2484
+ };
2485
+ case SIGNATURE_SCHEMES.ed25519:
2486
+ if (oid !== "1.3.101.112") return {
2487
+ ok: false,
2488
+ description: "illegal_parameter"
2489
+ };
2490
+ return {
2491
+ ok: true,
2492
+ key: await crypto.subtle.importKey("spki", nonShared(spkiDer), { name: "Ed25519" }, false, ["verify"])
2493
+ };
2494
+ default: return {
2495
+ ok: false,
2496
+ description: "illegal_parameter"
2497
+ };
2498
+ }
2499
+ } catch {
2500
+ return {
2501
+ ok: false,
2502
+ description: "bad_certificate"
2503
+ };
2504
+ }
2505
+ };
2506
+ const verifyCertificateVerify = async (options) => {
2507
+ const keyRes = await importLeafKey(options.spkiDer, options.scheme, options.algorithmOid);
2508
+ if (!keyRes.ok) return keyRes;
2509
+ const signedData = concat$1((/* @__PURE__ */ new Uint8Array(64)).fill(32), new TextEncoder().encode("TLS 1.3, server CertificateVerify"), Uint8Array.of(0), options.transcriptHash);
2510
+ let isValid = false;
2511
+ try {
2512
+ switch (options.scheme) {
2513
+ case SIGNATURE_SCHEMES.rsa_pss_rsae_sha256:
2514
+ isValid = await crypto.subtle.verify({
2515
+ name: "RSA-PSS",
2516
+ saltLength: 32
2517
+ }, keyRes.key, nonShared(options.signature), nonShared(signedData));
2518
+ break;
2519
+ case SIGNATURE_SCHEMES.rsa_pss_rsae_sha384:
2520
+ isValid = await crypto.subtle.verify({
2521
+ name: "RSA-PSS",
2522
+ saltLength: 48
2523
+ }, keyRes.key, nonShared(options.signature), nonShared(signedData));
2524
+ break;
2525
+ case SIGNATURE_SCHEMES.rsa_pss_rsae_sha512:
2526
+ isValid = await crypto.subtle.verify({
2527
+ name: "RSA-PSS",
2528
+ saltLength: 64
2529
+ }, keyRes.key, nonShared(options.signature), nonShared(signedData));
2530
+ break;
2531
+ case SIGNATURE_SCHEMES.ecdsa_secp256r1_sha256: {
2532
+ const p1363Sig = derToP1363(options.signature, 32);
2533
+ isValid = await crypto.subtle.verify({
2534
+ name: "ECDSA",
2535
+ hash: "SHA-256"
2536
+ }, keyRes.key, nonShared(p1363Sig), nonShared(signedData));
2537
+ break;
2538
+ }
2539
+ case SIGNATURE_SCHEMES.ecdsa_secp384r1_sha384: {
2540
+ const p1363Sig = derToP1363(options.signature, 48);
2541
+ isValid = await crypto.subtle.verify({
2542
+ name: "ECDSA",
2543
+ hash: "SHA-384"
2544
+ }, keyRes.key, nonShared(p1363Sig), nonShared(signedData));
2545
+ break;
2546
+ }
2547
+ case SIGNATURE_SCHEMES.ed25519:
2548
+ isValid = await crypto.subtle.verify({ name: "Ed25519" }, keyRes.key, nonShared(options.signature), nonShared(signedData));
2549
+ break;
2550
+ default: return {
2551
+ ok: false,
2552
+ description: "illegal_parameter"
2553
+ };
2554
+ }
2555
+ } catch {
2556
+ return {
2557
+ ok: false,
2558
+ description: "decrypt_error"
2559
+ };
2560
+ }
2561
+ if (!isValid) return {
2562
+ ok: false,
2563
+ description: "decrypt_error"
2564
+ };
2565
+ return { ok: true };
2566
+ };
2567
+ //#endregion
2568
+ //#region src/handshake.ts
2569
+ /**
2570
+ * TLS 1.3 Handshake State Machine and Connection Management (RFC 9846 §4, App. E.4).
2571
+ */
2572
+ /**
2573
+ * How large a handshake message we will buffer.
2574
+ *
2575
+ * `Handshake.length` is a uint24, so the RFC's own ceiling is 16MB — which is
2576
+ * 16MB a peer can make us hold before we are able to check a single byte of it.
2577
+ * The cap is ours to choose, and 16KB was too small: BoGo's `LargeMessage`
2578
+ * sends a 51-certificate chain of about 23KB and expects it to arrive, and a
2579
+ * real chain of ML-DSA certificates would be larger still.
2580
+ */
2581
+ const MAX_HANDSHAKE_MESSAGE_BODY = 65536;
2582
+ /**
2583
+ * Ceilings on records that carry the conversation nowhere.
2584
+ *
2585
+ * TLS 1.3 sets none of these. A peer may send zero-length application records
2586
+ * as a traffic-analysis countermeasure (RFC 9846 §5.4), `user_canceled` alerts
2587
+ * are warnings to be ignored (§6.1), and `KeyUpdate` has no rate — so a peer
2588
+ * that sends any of them forever costs us everything and itself nothing. The
2589
+ * numbers are BoringSSL's, adopted exactly because BoGo pins them from both
2590
+ * sides: 32 empty fragments must pass and 33 must fail, 4 `user_canceled` must
2591
+ * pass and 5 must fail.
2592
+ *
2593
+ * All three count CONSECUTIVE occurrences and reset the moment a byte of
2594
+ * application data is delivered — BoringSSL's own rule, in as many words:
2595
+ * "Only when at least one byte is returned, clear the counters for empty
2596
+ * records and warnings" (`ssl/tls_record.cc`), with `key_update_count` cleared
2597
+ * on delivered data in `ssl/ssl_lib.cc`. Counting them over the connection's
2598
+ * LIFETIME instead would kill a perfectly ordinary IMAP session on its 33rd
2599
+ * rekey, hours in, which is exactly the connection this client is for.
2600
+ */
2601
+ const MAX_CONSECUTIVE_EMPTY_RECORDS = 32;
2602
+ const MAX_WARNING_ALERTS = 4;
2603
+ const MAX_KEY_UPDATES = 32;
2604
+ /**
2605
+ * `NewSessionTicket` belongs on that list too, and BoGo pins no number for it,
2606
+ * so this one is ours. It is the same shape of abuse — a post-handshake message
2607
+ * a peer may send at will, which `read()` answers by looping instead of
2608
+ * returning — with a sharper edge than the others: each ticket now costs an HKDF
2609
+ * expansion AND a call into the caller's own storage. A peer that sends them
2610
+ * forever starves the read the application is waiting on.
2611
+ *
2612
+ * 32 is far above anything real. OpenSSL, BoringSSL and Node all send two.
2613
+ */
2614
+ const MAX_CONSECUTIVE_SESSION_TICKETS = 32;
2615
+ /**
2616
+ * What the server may answer with, per message.
2617
+ *
2618
+ * RFC 9846 §4.3 is flat about it: an endpoint MUST NOT send an extension
2619
+ * response the peer did not request, and one that arrives anyway is fatal —
2620
+ * `unsupported_extension`. So the test is TWO things at once, and both matter:
2621
+ * the extension has to be one we actually offered, AND one defined for the
2622
+ * message it turned up in.
2623
+ *
2624
+ * Matched by TYPE CODE rather than by our decoder's `kind`, because everything
2625
+ * we do not model decodes to `unknown` and two different unknowns would
2626
+ * otherwise look alike.
2627
+ *
2628
+ * The lists are RFC 9846 §4.3's table, plus RFC 8449's `record_size_limit`.
2629
+ * A `CertificateEntry` carries only responses to offers this client does not
2630
+ * make, so in practice nothing may appear there at all.
2631
+ */
2632
+ const PERMITTED_IN_SERVER_HELLO = [
2633
+ 41,
2634
+ 43,
2635
+ 51
2636
+ ];
2637
+ const PERMITTED_IN_ENCRYPTED_EXTENSIONS = [
2638
+ 0,
2639
+ 1,
2640
+ 10,
2641
+ 14,
2642
+ 15,
2643
+ 16,
2644
+ 19,
2645
+ 20,
2646
+ 28,
2647
+ 42
2648
+ ];
2649
+ const PERMITTED_IN_CERTIFICATE_ENTRY = [5, 18];
2650
+ /** A HelloRetryRequest's cookie answers no offer — the server starts that one. */
2651
+ const COOKIE = 44;
2652
+ /**
2653
+ * How large a cookie we will echo back.
2654
+ *
2655
+ * A ClientHello's extensions are `Extension extensions<8..2^16-1>` — the whole
2656
+ * BLOCK is a uint16 — and a cookie is `opaque cookie<1..2^16-1>` chosen by the
2657
+ * server. So a cookie near its own legal maximum makes a ClientHello that CANNOT
2658
+ * BE ENCODED at all, by anyone; the limit is the wire format's, not ours, and
2659
+ * fragmenting records does not touch it.
2660
+ *
2661
+ * That draws the line this file follows: an input the PEER sizes gets a typed
2662
+ * failure, an input the CALLER sizes is allowed to throw. A ticket is bounded
2663
+ * where it is stored (`MAX_TICKET_BYTES`), and the cookie here — 16KiB leaves
2664
+ * room for the largest ticket we keep and every other extension several times
2665
+ * over. Real cookies are tens of bytes; they carry a server's stateless retry
2666
+ * state, not data.
2667
+ */
2668
+ const MAX_ECHOED_COOKIE_BYTES = 16384;
2669
+ /**
2670
+ * A HelloRetryRequest is NOT a ServerHello for this purpose.
2671
+ *
2672
+ * RFC 9846's Table 1 gives each extension the messages it may appear in, and HRR
2673
+ * is a column of its own: `supported_versions` and `key_share` are `CH, SH, HRR`
2674
+ * and `cookie` is `CH, HRR`, while `pre_shared_key` is `CH, SH` — excluded from
2675
+ * the retry on purpose. Reusing the ServerHello list plus a cookie was right
2676
+ * until this client offered a PSK; from then on 41 was in the offered set AND in
2677
+ * the permitted one, so a retry carrying `pre_shared_key` passed both halves of
2678
+ * the check and was silently ignored. §4.3 makes it `illegal_parameter`.
2679
+ */
2680
+ const PERMITTED_IN_HELLO_RETRY_REQUEST = [
2681
+ 43,
2682
+ COOKIE,
2683
+ 51
2684
+ ];
2685
+ const extensionTypeCode = (extension) => extension.kind === "unknown" ? extension.typeCode : EXTENSION_TYPES[extension.kind];
2686
+ /**
2687
+ * Which alert a server extension has earned, if any. RFC 9846 §4.3 draws a line
2688
+ * between two failures that look alike and are not:
2689
+ *
2690
+ * - an extension we never offered is a response to nothing —
2691
+ * `unsupported_extension`;
2692
+ * - an extension we DID offer, turning up in a message where that extension is
2693
+ * not defined, is a misplaced one — `illegal_parameter`.
2694
+ */
2695
+ const misplacedExtensionAlert = (extensions, offered, permitted) => {
2696
+ for (const extension of extensions) {
2697
+ const code = extensionTypeCode(extension);
2698
+ if (!offered.has(code)) return "unsupported_extension";
2699
+ if (!permitted.includes(code)) return "illegal_parameter";
2700
+ }
2701
+ return null;
2702
+ };
2703
+ const isSameBytes = (a, b) => a.length === b.length && a.every((byte, index) => byte === b[index]);
2704
+ const expectedKeyShareLength = (group) => {
2705
+ if (group === "x25519") return 32;
2706
+ if (group === "secp256r1") return 65;
2707
+ return 97;
2708
+ };
2709
+ const suiteFromCode = (code) => {
2710
+ if (code === 4865) return "TLS_AES_128_GCM_SHA256";
2711
+ if (code === 4866) return "TLS_AES_256_GCM_SHA384";
2712
+ };
2713
+ /**
2714
+ * What a session looks like in a ClientHello, with the binder still to come.
2715
+ *
2716
+ * The caller passes the instant rather than this reading a clock, because the
2717
+ * two ClientHellos want different answers: the first is built from the same
2718
+ * reading that decided the session was offerable at all, and the retried one
2719
+ * from a fresh reading, which RFC 9846 §4.2.2 asks for in as many words.
2720
+ */
2721
+ const pskOffer = (session, now) => session === void 0 ? void 0 : {
2722
+ identity: session.ticket,
2723
+ obfuscatedTicketAge: obfuscatedTicketAge(session, now),
2724
+ binderLength: CIPHER_SUITES[session.suite].hashLength
2725
+ };
2726
+ const rebindTranscript = (suite, previous) => {
2727
+ const next = new Transcript(suite);
2728
+ for (const message of previous.getMessages()) next.add(message);
2729
+ return next;
2730
+ };
2731
+ /**
2732
+ * A ClientHello as records, fragmented if it does not fit in one.
2733
+ *
2734
+ * RFC 9846 §5.1 allows it — "Handshake messages MAY be coalesced into a single
2735
+ * TLSPlaintext record or fragmented across several records" — and this client
2736
+ * needs it, because two of the things that go INTO a ClientHello are sized by
2737
+ * the peer. A ticket is `opaque ticket<1..2^16-1>` (§4.7.1) and comes back as
2738
+ * the PSK identity; a HelloRetryRequest cookie is `opaque cookie<1..2^16-1>`
2739
+ * (§4.3.2) and comes back echoed. Either at its legal maximum overruns a single
2740
+ * record, and `sealPlain` answers that by THROWING — so a server could hand us a
2741
+ * 16KB ticket and poison the next connection before a byte went out.
2742
+ *
2743
+ * The legacy record version rides on every fragment: §5.1 deprecates the field
2744
+ * and requires it ignored, so the only thing that matters is the middlebox
2745
+ * compatibility the first ClientHello's 0x0301 buys, and a fragment of that
2746
+ * message is still that message.
2747
+ */
2748
+ const clientHelloRecords = (message, legacyVersion) => Array.from({ length: Math.ceil(message.length / MAX_RECORD_PLAINTEXT) }, (_, index) => sealPlain("handshake", message.subarray(index * MAX_RECORD_PLAINTEXT, (index + 1) * MAX_RECORD_PLAINTEXT), legacyVersion));
2749
+ /**
2750
+ * The state machine, with the replay hooks as a second argument. Exported for
2751
+ * `replay.ts` and for nothing else, which `index.test.ts` enforces by name: the
2752
+ * import guard on `replay.ts` cannot see a production file that reaches past it
2753
+ * to here.
2754
+ */
2755
+ const runHandshake = async (options, replay) => {
2756
+ const { transport, serverName } = options;
2757
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
2758
+ /**
2759
+ * The identity the chain is checked against, resolved once. It is what the
2760
+ * session is bound to, so it has to be the SAME expression the validator is
2761
+ * given — computing it twice is how the two come to disagree.
2762
+ */
2763
+ const expectedPeerName = options.expectedPeerName !== void 0 ? options.expectedPeerName : {
2764
+ kind: "dns",
2765
+ value: serverName
2766
+ };
2767
+ /**
2768
+ * Before a byte goes out, and deliberately before `isSessionOfferable` gets a
2769
+ * say. A session is a value the caller stored and revived, so the two fields
2770
+ * TypeScript cannot vouch for are checked at the door — see
2771
+ * `assertUsableSession`, and the review that moved it here from a call site
2772
+ * one flight too late.
2773
+ *
2774
+ * Unconditional rather than only-if-we-would-offer-it: a session whose shape
2775
+ * did not survive the store is the caller's bug whether or not this
2776
+ * connection would have used it, and finding out on the connection that
2777
+ * happens to match is finding out late.
2778
+ */
2779
+ if (options.session !== void 0) assertUsableSession(options.session);
2780
+ let transcript = new Transcript();
2781
+ const reader = new RecordReader(() => transport.read());
2782
+ const handshakeBuffer = [];
2783
+ const extractNextHandshakeMessage = () => {
2784
+ const combined = concat$1(...handshakeBuffer);
2785
+ if (combined.length < 4) return { kind: "need-more" };
2786
+ const length = readUint24(combined, 1);
2787
+ if (length > MAX_HANDSHAKE_MESSAGE_BODY) return { kind: "overflow" };
2788
+ if (combined.length < 4 + length) return { kind: "need-more" };
2789
+ const msgBytes = combined.subarray(0, 4 + length);
2790
+ const remainder = combined.subarray(4 + length);
2791
+ handshakeBuffer.length = 0;
2792
+ if (remainder.length > 0) handshakeBuffer.push(remainder);
2793
+ return {
2794
+ kind: "message",
2795
+ bytes: msgBytes
2796
+ };
2797
+ };
2798
+ let clientWriteKeys = null;
2799
+ /**
2800
+ * The compatibility-mode ChangeCipherSpec, owed once we send a non-empty
2801
+ * legacy_session_id and paid exactly once.
2802
+ *
2803
+ * RFC 9846 D.4 puts it "immediately before the second flight", and the second
2804
+ * flight is whatever we protect first: a retried ClientHello, the Finished
2805
+ * flight, or a fatal alert when the handshake ends early. Sending it only
2806
+ * before Finished left every aborted handshake without it, and a peer that
2807
+ * expects a middlebox-friendly stream reads the alert that follows as a
2808
+ * malformed ChangeCipherSpec instead of the alert it is.
2809
+ */
2810
+ let compatibilityCcsPending = false;
2811
+ const sendCompatibilityCcsIfPending = async () => {
2812
+ if (!compatibilityCcsPending) return;
2813
+ compatibilityCcsPending = false;
2814
+ await transport.write(sealPlain("change_cipher_spec", Uint8Array.of(1)));
2815
+ };
2816
+ /**
2817
+ * A peer that hangs up mid-handshake is a truncated connection, not a bug in
2818
+ * this file. `ByteDuplex.write` rejects when the socket has gone, and letting
2819
+ * that escape turned "the server closed on us" into an unhandled error with a
2820
+ * stack trace where a typed failure belongs.
2821
+ */
2822
+ const writeToPeer = async (record) => {
2823
+ try {
2824
+ await transport.write(record);
2825
+ return true;
2826
+ } catch {
2827
+ return false;
2828
+ }
2829
+ };
2830
+ const sendFatalAlert = async (description) => {
2831
+ try {
2832
+ const alertBytes = encodeAlert({
2833
+ level: "fatal",
2834
+ description
2835
+ });
2836
+ if (clientWriteKeys === null) {
2837
+ await transport.write(sealPlain("alert", alertBytes));
2838
+ return;
2839
+ }
2840
+ await sendCompatibilityCcsIfPending();
2841
+ const sealed = await sealAead(clientWriteKeys.key, clientWriteKeys.iv, clientWriteKeys.seq, "alert", alertBytes);
2842
+ clientWriteKeys = {
2843
+ key: clientWriteKeys.key,
2844
+ iv: clientWriteKeys.iv,
2845
+ seq: clientWriteKeys.seq + 1n
2846
+ };
2847
+ await transport.write(sealed);
2848
+ } catch {}
2849
+ };
2850
+ const failAlert = async (description) => {
2851
+ await sendFatalAlert(description);
2852
+ return {
2853
+ ok: false,
2854
+ reason: {
2855
+ kind: "alert-sent",
2856
+ alert: {
2857
+ level: "fatal",
2858
+ description
2859
+ }
2860
+ }
2861
+ };
2862
+ };
2863
+ /**
2864
+ * Every path validation this connection performs, at both the sites that
2865
+ * perform one — the peer's Certificate on a full handshake, and the stored
2866
+ * chain again on a resumed one. Written once because the two must ask the
2867
+ * same question: a re-check under a laxer policy than the original would
2868
+ * report a chain as still good on terms it was never accepted on.
2869
+ */
2870
+ const validatePeerChain = (chain) => options.validator.validatePath({
2871
+ peerCertificateDer: chain.leafDer,
2872
+ untrustedIntermediateDer: chain.intermediateDer,
2873
+ trustAnchors: options.trustAnchors,
2874
+ validationTime: options.validationTime,
2875
+ expectedPeerName,
2876
+ requiredKeyUsages: ["digitalSignature"],
2877
+ requiredExtendedKeyUsages: ["serverAuth"],
2878
+ maximumIntermediateCount: null
2879
+ });
2880
+ const failValidation = async (reason, chain) => {
2881
+ const alert = alertForValidationFailure(reason);
2882
+ await sendFatalAlert(alert.description);
2883
+ return {
2884
+ ok: false,
2885
+ reason: {
2886
+ kind: "certificate",
2887
+ reason,
2888
+ alert,
2889
+ chain
2890
+ }
2891
+ };
2892
+ };
2893
+ /**
2894
+ * What we offer, and which of them carries the share. The RFC calls the
2895
+ * second one out separately (§4.3.8): a HelloRetryRequest may only move us to
2896
+ * a group we offered and did NOT already share, so both facts are needed.
2897
+ */
2898
+ /**
2899
+ * COPIED, not aliased. The list is read twice — once for ClientHello1 and
2900
+ * again for the ClientHello a HelloRetryRequest asks for, a full round trip
2901
+ * later — and RFC 9846 §4.2.4 requires the second to offer what the first
2902
+ * did. A caller holding a mutable array could otherwise change it in between,
2903
+ * or after the checks below have already passed on it.
2904
+ */
2905
+ const offeredGroups = [...options.supportedGroups ?? SUPPORTED_GROUPS];
2906
+ const firstOfferedGroup = offeredGroups[0];
2907
+ if (firstOfferedGroup === void 0) throw new Error("supportedGroups must offer at least one group");
2908
+ /**
2909
+ * RFC 9846 §4.3.7: "The `named_group_list` MUST NOT contain any duplicate
2910
+ * entries. A recipient MAY abort a connection with a fatal
2911
+ * `illegal_parameter` alert if it detects a duplicate entry." So a repeat is
2912
+ * a ClientHello a conforming peer may hang up on, and the caller would see it
2913
+ * as an unexplained server-side refusal. It throws rather than returning a
2914
+ * typed failure because it is OUR bug, not the peer's — the same reason the
2915
+ * empty case above does.
2916
+ */
2917
+ if (new Set(offeredGroups).size !== offeredGroups.length) throw new Error("supportedGroups must not repeat a group");
2918
+ /**
2919
+ * COPIED and checked for the same reasons as the groups above: the list is
2920
+ * read again for the ClientHello a HelloRetryRequest asks for, and again when
2921
+ * the CertificateVerify arrives to decide whether the server signed with
2922
+ * something we offered. §4.3.3 gives `supported_signature_algorithms` a
2923
+ * `<2..2^16-2>` bound, so an empty list is not encodable; a repeat is our bug
2924
+ * either way, which is why both throw rather than returning a typed failure.
2925
+ *
2926
+ * **This one stays the CALLER'S list, where `offeredExtensionCodes` and
2927
+ * `offeredGroupCodes` below are read back off the ClientHello we sent.** Those
2928
+ * two decide whether the server's ANSWER was solicited, and a constant would
2929
+ * refuse the replayed document's own server. This decides whether a SIGNATURE
2930
+ * is one we would accept, and the two diverge in the direction that matters:
2931
+ * if the encoder ever failed to narrow what went out, reading the wire would
2932
+ * make us accept the wider set silently, while reading the option refuses and
2933
+ * the interop failure is visible. The caller's policy binds what we accept
2934
+ * even when the bytes disagree. BoGo holds the wire's half of it — narrow the
2935
+ * offer with `-verify-prefs` and `VerifyPreferences-Advertised` reads the
2936
+ * extension back.
2937
+ */
2938
+ const offeredSchemes = [...options.signatureSchemes ?? SUPPORTED_SIGNATURE_SCHEMES];
2939
+ if (offeredSchemes.length === 0) throw new Error("signatureSchemes must offer at least one scheme");
2940
+ if (new Set(offeredSchemes).size !== offeredSchemes.length) throw new Error("signatureSchemes must not repeat a scheme");
2941
+ let clientShare;
2942
+ let ch1MessageBytes;
2943
+ let sentSessionId;
2944
+ let ch1Random;
2945
+ /** The group our CURRENT share is on — reassigned only by a HelloRetryRequest. */
2946
+ let effectiveGroupName;
2947
+ /**
2948
+ * The session the ClientHello on the wire actually carries — `undefined` when
2949
+ * there is none to offer, when the one we hold is for another host or past its
2950
+ * lifetime, and when a HelloRetryRequest moves us to a hash it cannot bind
2951
+ * under. Everything downstream reads it as "did we ask to resume", so it has
2952
+ * to mean the message we sent rather than the one we considered sending.
2953
+ */
2954
+ let offeredSession;
2955
+ if (replay !== void 0) {
2956
+ const ch1 = replay.clientHelloMessages[0];
2957
+ const priv = replay.clientEphemeralPrivateKeys[0];
2958
+ if (ch1 === void 0 || priv === void 0) throw new Error("Replay missing ClientHello1 or private key");
2959
+ ch1MessageBytes = ch1;
2960
+ effectiveGroupName = "x25519";
2961
+ clientShare = await importPrivateShare(effectiveGroupName, priv);
2962
+ const decodedCh1 = decodeHandshakeMessage(ch1);
2963
+ if (!decodedCh1.ok || decodedCh1.value.kind !== "client_hello") throw new Error("Malformed replay ClientHello1");
2964
+ sentSessionId = decodedCh1.value.legacySessionId;
2965
+ ch1Random = decodedCh1.value.random;
2966
+ /**
2967
+ * A replayed trace can be a RESUMED one, and until §4 was driven through
2968
+ * here nothing had noticed: this branch was written for §3 and §5, both full
2969
+ * handshakes, and left `offeredSession` unset. The document's ClientHello
2970
+ * then went out carrying `pre_shared_key` while the state machine believed
2971
+ * it had offered no session, so the server's acceptance answered an offer
2972
+ * this side did not think it had made.
2973
+ *
2974
+ * Gated by `isSessionOfferable` exactly as the production branch is, rather
2975
+ * than taking the caller's session on trust — a replay that offers a session
2976
+ * the rules would have refused proves the wrong thing.
2977
+ */
2978
+ offeredSession = options.session !== void 0 && isSessionOfferable(options.session, serverName, expectedPeerName, now()) ? options.session : void 0;
2979
+ } else {
2980
+ effectiveGroupName = firstOfferedGroup;
2981
+ clientShare = await generateKeyShare(effectiveGroupName);
2982
+ sentSessionId = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32));
2983
+ ch1Random = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32));
2984
+ const offeredAt = now();
2985
+ offeredSession = options.session !== void 0 && isSessionOfferable(options.session, serverName, expectedPeerName, offeredAt) ? options.session : void 0;
2986
+ ch1MessageBytes = encodeProductionClientHello({
2987
+ serverName,
2988
+ keySharePublicKey: clientShare.publicKey,
2989
+ group: effectiveGroupName,
2990
+ supportedGroups: offeredGroups,
2991
+ signatureSchemes: offeredSchemes,
2992
+ legacySessionId: sentSessionId,
2993
+ random: ch1Random,
2994
+ psk: pskOffer(offeredSession, offeredAt)
2995
+ });
2996
+ if (offeredSession !== void 0) ch1MessageBytes = await bindClientHello(offeredSession, ch1MessageBytes, []);
2997
+ }
2998
+ /**
2999
+ * What we offered, read back off the ClientHello we actually SENT rather than
3000
+ * from the values beside it — the replay path sends RFC 8448's ClientHello,
3001
+ * whose offers are not ours, and a constant here would refuse the document's
3002
+ * own server. That applies to the GROUPS as much as to the extension codes:
3003
+ * `offeredGroups` is what this client would have offered, not what the bytes
3004
+ * on the wire did.
3005
+ */
3006
+ const [offeredExtensionCodes, offeredGroupCodes] = (() => {
3007
+ const decoded = decodeHandshakeMessage(ch1MessageBytes);
3008
+ if (!decoded.ok || decoded.value.kind !== "client_hello") throw new Error("our own ClientHello does not decode");
3009
+ const groups = decoded.value.extensions.find((e) => e.kind === "supported_groups");
3010
+ return [new Set(decoded.value.extensions.map(extensionTypeCode)), new Set(groups?.kind === "supported_groups" ? groups.groups : [])];
3011
+ })();
3012
+ compatibilityCcsPending = sentSessionId.length > 0;
3013
+ transcript.add(ch1MessageBytes);
3014
+ for (const record of clientHelloRecords(ch1MessageBytes, LEGACY_RECORD_VERSION.FIRST_CLIENT_HELLO)) if (!await writeToPeer(record)) return {
3015
+ ok: false,
3016
+ reason: { kind: "truncated" }
3017
+ };
3018
+ let hasSeenHrr = false;
3019
+ let negotiatedSuite;
3020
+ let hrrSelectedSuite;
3021
+ let serverShareKeyExchange;
3022
+ let realServerHelloBytes;
3023
+ /** The offered session, once the server has said it took it. */
3024
+ let resumedSession;
3025
+ while (realServerHelloBytes === void 0) {
3026
+ let extracted = extractNextHandshakeMessage();
3027
+ while (extracted.kind === "need-more") {
3028
+ const recResult = await reader.readRecord();
3029
+ if (!recResult.ok) {
3030
+ if (recResult.kind === "truncated") return {
3031
+ ok: false,
3032
+ reason: { kind: "truncated" }
3033
+ };
3034
+ return failAlert(recResult.description);
3035
+ }
3036
+ if (recResult.kind === "eof") return {
3037
+ ok: false,
3038
+ reason: { kind: "truncated" }
3039
+ };
3040
+ const record = recResult.record;
3041
+ if (record[0] === 20) continue;
3042
+ if (record[0] === 23) return failAlert("unexpected_message");
3043
+ const plainRes = openPlain(record);
3044
+ if (!plainRes.ok) return failAlert(plainRes.description);
3045
+ /**
3046
+ * A server that refuses the ClientHello says so with a cleartext alert —
3047
+ * `protocol_version` when it speaks no TLS 1.3, `handshake_failure` when
3048
+ * it shares no cipher. Answering with our own `unexpected_message` threw
3049
+ * away the only sentence in the exchange that says WHY, and a
3050
+ * self-hosted user's report of "unexpected message" is undiagnosable.
3051
+ */
3052
+ if (plainRes.type === "alert") {
3053
+ const alertRes = decodeAlert(plainRes.payload);
3054
+ if (alertRes.ok) return {
3055
+ ok: false,
3056
+ reason: {
3057
+ kind: "alert-received",
3058
+ alert: alertRes.alert
3059
+ }
3060
+ };
3061
+ if (alertRes.unknownDescriptionCode !== void 0) return {
3062
+ ok: false,
3063
+ reason: {
3064
+ kind: "alert-received-unknown",
3065
+ code: alertRes.unknownDescriptionCode
3066
+ }
3067
+ };
3068
+ return failAlert(alertRes.description);
3069
+ }
3070
+ if (plainRes.type !== "handshake") return failAlert("unexpected_message");
3071
+ handshakeBuffer.push(plainRes.payload);
3072
+ extracted = extractNextHandshakeMessage();
3073
+ }
3074
+ if (extracted.kind === "overflow") return failAlert("decode_error");
3075
+ const shBytes = extracted.bytes;
3076
+ const decoded = decodeHandshakeMessage(shBytes);
3077
+ if (!decoded.ok) return failAlert(decoded.description);
3078
+ const msg = decoded.value;
3079
+ if (msg.kind !== "server_hello") return failAlert("unexpected_message");
3080
+ if (msg.legacyCompressionMethod !== 0) return failAlert("illegal_parameter");
3081
+ if (!isSameBytes(msg.legacySessionIdEcho, sentSessionId)) return failAlert("illegal_parameter");
3082
+ const randomSuffix = msg.random.subarray(24);
3083
+ if (isSameBytes(randomSuffix, DOWNGRADE_SENTINEL_TLS_1_2) || isSameBytes(randomSuffix, DOWNGRADE_SENTINEL_TLS_1_1)) return failAlert("illegal_parameter");
3084
+ /**
3085
+ * supported_versions must say 0x0304 — and after a HelloRetryRequest it must
3086
+ * say the SAME thing it said there. RFC 9846 §4.2.4: "The value of
3087
+ * selected_version in the HelloRetryRequest supported_versions extension
3088
+ * MUST be retained in the ServerHello, and a client MUST abort the
3089
+ * handshake with an illegal_parameter alert if the value changes."
3090
+ *
3091
+ * Since 0x0304 is the only value this client ever accepts, "changed" and
3092
+ * "absent or not 0x0304" are the same test; only the alert differs, and
3093
+ * which alert is the whole of what the peer learns.
3094
+ */
3095
+ const versionsExt = msg.extensions.find((e) => e.kind === "supported_versions");
3096
+ const wrongVersionAlert = hasSeenHrr ? "illegal_parameter" : "protocol_version";
3097
+ if (versionsExt === void 0 || versionsExt.kind !== "supported_versions") return failAlert(wrongVersionAlert);
3098
+ if (versionsExt.versions[0] !== TLS_VERSION.V1_3) return failAlert(wrongVersionAlert);
3099
+ const serverHelloExtensionAlert = misplacedExtensionAlert(msg.extensions, /* @__PURE__ */ new Set([...offeredExtensionCodes, COOKIE]), isSameBytes(msg.random, HRR_MAGIC_RANDOM) ? PERMITTED_IN_HELLO_RETRY_REQUEST : PERMITTED_IN_SERVER_HELLO);
3100
+ if (serverHelloExtensionAlert !== null) return failAlert(serverHelloExtensionAlert);
3101
+ const selectedSuite = suiteFromCode(msg.cipherSuite);
3102
+ if (selectedSuite === void 0) return failAlert("illegal_parameter");
3103
+ if (hrrSelectedSuite !== void 0 && selectedSuite !== hrrSelectedSuite) return failAlert("illegal_parameter");
3104
+ negotiatedSuite = selectedSuite;
3105
+ transcript = rebindTranscript(negotiatedSuite, transcript);
3106
+ const keyShareExt = msg.extensions.find((e) => e.kind === "key_share");
3107
+ const cookieExt = msg.extensions.find((e) => e.kind === "cookie");
3108
+ if (isSameBytes(msg.random, HRR_MAGIC_RANDOM)) {
3109
+ if (hasSeenHrr) return failAlert("unexpected_message");
3110
+ /**
3111
+ * A HelloRetryRequest may ask for a different group, or for a cookie, or
3112
+ * for both — RFC 9846 §4.2.4 requires neither extension on its own. One
3113
+ * that asks for NOTHING is the empty retry, and there is nothing the
3114
+ * second ClientHello could say differently.
3115
+ */
3116
+ if (keyShareExt === void 0 && cookieExt === void 0) return failAlert("illegal_parameter");
3117
+ /**
3118
+ * A HelloRetryRequest ends the server's flight — the next thing on the
3119
+ * wire is our second ClientHello. Bytes still buffered behind it are the
3120
+ * start of a message the server means to finish AFTER our reply, which
3121
+ * §5.1 does not allow and which would be spliced onto the real
3122
+ * ServerHello when it arrives.
3123
+ */
3124
+ if (handshakeBuffer.some((chunk) => chunk.length > 0)) return failAlert("unexpected_message");
3125
+ hasSeenHrr = true;
3126
+ hrrSelectedSuite = negotiatedSuite;
3127
+ /**
3128
+ * RFC 9846 §4.2.4: in the updated ClientHello the client "SHOULD NOT offer
3129
+ * any pre-shared keys associated with a hash other than that of the
3130
+ * selected cipher suite". Offering one anyway would be worse than
3131
+ * pointless — the binder is a MAC under the PSK's own hash, so a server
3132
+ * that took it would then key the connection from a schedule the other
3133
+ * hash produced.
3134
+ */
3135
+ if (offeredSession !== void 0 && CIPHER_SUITES[offeredSession.suite].hash !== CIPHER_SUITES[negotiatedSuite].hash) offeredSession = void 0;
3136
+ if (keyShareExt !== void 0) {
3137
+ /**
3138
+ * RFC 9846 §4.3.8 puts TWO conditions on the retried group, and both are
3139
+ * `illegal_parameter`: it must be one we offered in `supported_groups`,
3140
+ * and it must NOT be the one we already sent a key share for.
3141
+ *
3142
+ * The second is what makes a retry a change. Without it a server can
3143
+ * bounce us back onto the group we already offered a share on — a round
3144
+ * trip that costs us a fresh key and gains the handshake nothing, which
3145
+ * §4.2.4 forbids in as many words: abort "if the HelloRetryRequest would
3146
+ * not result in any change in the ClientHello".
3147
+ */
3148
+ const selectedCode = keyShareExt.selectedGroup;
3149
+ const selectedGroup = selectedCode === void 0 ? void 0 : namedGroupFromCode(selectedCode);
3150
+ if (selectedCode === void 0 || !offeredGroupCodes.has(selectedCode) || selectedGroup === void 0 || selectedGroup === effectiveGroupName) return failAlert("illegal_parameter");
3151
+ effectiveGroupName = selectedGroup;
3152
+ }
3153
+ await transcript.replaceClientHello1WithMessageHash();
3154
+ transcript.add(shBytes);
3155
+ let ch2MessageBytes;
3156
+ if (replay !== void 0) {
3157
+ const ch2 = replay.clientHelloMessages[1];
3158
+ const priv2 = replay.clientEphemeralPrivateKeys[1];
3159
+ if (ch2 === void 0 || priv2 === void 0) throw new Error("Replay missing ClientHello2 or private key for HRR");
3160
+ ch2MessageBytes = ch2;
3161
+ clientShare = await importPrivateShare(effectiveGroupName, priv2);
3162
+ } else {
3163
+ if (keyShareExt !== void 0) clientShare = await generateKeyShare(effectiveGroupName);
3164
+ const cookie = cookieExt?.kind === "cookie" ? cookieExt.cookie : void 0;
3165
+ if (cookie !== void 0 && cookie.length > MAX_ECHOED_COOKIE_BYTES) return failAlert("illegal_parameter");
3166
+ ch2MessageBytes = encodeProductionClientHello({
3167
+ serverName,
3168
+ keySharePublicKey: clientShare.publicKey,
3169
+ group: effectiveGroupName,
3170
+ supportedGroups: offeredGroups,
3171
+ signatureSchemes: offeredSchemes,
3172
+ legacySessionId: sentSessionId,
3173
+ random: ch1Random,
3174
+ cookie,
3175
+ psk: pskOffer(offeredSession, now())
3176
+ });
3177
+ if (offeredSession !== void 0) ch2MessageBytes = await bindClientHello(offeredSession, ch2MessageBytes, transcript.getMessages());
3178
+ }
3179
+ /**
3180
+ * RFC 9846 D.4: in compatibility mode the client sends one dummy
3181
+ * ChangeCipherSpec immediately before its second flight — which is THIS
3182
+ * message when the server retried, and the Finished flight otherwise.
3183
+ * Sending it only before Finished left a retried handshake without the
3184
+ * record the peer was waiting on.
3185
+ */
3186
+ await sendCompatibilityCcsIfPending();
3187
+ transcript.add(ch2MessageBytes);
3188
+ for (const record of clientHelloRecords(ch2MessageBytes, LEGACY_RECORD_VERSION.STANDARD)) if (!await writeToPeer(record)) return {
3189
+ ok: false,
3190
+ reason: { kind: "truncated" }
3191
+ };
3192
+ continue;
3193
+ }
3194
+ /**
3195
+ * RFC 9846 §4.3.11, the server's half of a resumption. Three ways it can be
3196
+ * wrong and each is a different sentence of the RFC:
3197
+ *
3198
+ * - answering a `pre_shared_key` we did not send is §4.3's response to
3199
+ * nothing, and the retry that drops a PSK is exactly how a ClientHello
3200
+ * that offered one comes to be answered by a ServerHello that may not;
3201
+ * - `selected_identity` is an index into the list we sent, and this client
3202
+ * sends one identity, so 0 is the only value in range;
3203
+ * - the suite has to carry the PSK's own hash, or the connection is keyed
3204
+ * from a schedule the other hash produced.
3205
+ */
3206
+ const serverPskExt = msg.extensions.find((e) => e.kind === "pre_shared_key");
3207
+ if (serverPskExt !== void 0) {
3208
+ if (offeredSession === void 0) return failAlert("unsupported_extension");
3209
+ if (serverPskExt.kind !== "pre_shared_key" || serverPskExt.selectedIdentity !== 0) return failAlert("illegal_parameter");
3210
+ if (CIPHER_SUITES[offeredSession.suite].hash !== CIPHER_SUITES[selectedSuite].hash) return failAlert("illegal_parameter");
3211
+ resumedSession = offeredSession;
3212
+ }
3213
+ /**
3214
+ * A real ServerHello has to carry the share the key exchange runs on, and
3215
+ * WHICH alert that earns depends on whether a PSK was selected.
3216
+ *
3217
+ * §4.3.11 is one sentence with three clauses — `selected_identity` in range,
3218
+ * a suite whose Hash matches the PSK, "and that a server `key_share`
3219
+ * extension is present if required by the ClientHello
3220
+ * `psk_key_exchange_modes` extension. If these values are not consistent,
3221
+ * the client MUST abort the handshake with an `illegal_parameter` alert."
3222
+ * We offer `psk_dhe_ke` alone, so a selected PSK always requires the share.
3223
+ *
3224
+ * Without a PSK there is no such sentence and the extension is simply the
3225
+ * mandatory one §9.2 is about, which `missing_extension` names.
3226
+ */
3227
+ if (keyShareExt === void 0) return failAlert(resumedSession !== void 0 ? "illegal_parameter" : "missing_extension");
3228
+ if (keyShareExt.serverShare === void 0) return failAlert("illegal_parameter");
3229
+ if (keyShareExt.serverShare.group !== NAMED_GROUPS[effectiveGroupName]) return failAlert("illegal_parameter");
3230
+ serverShareKeyExchange = keyShareExt.serverShare.keyExchange;
3231
+ realServerHelloBytes = shBytes;
3232
+ }
3233
+ if (negotiatedSuite === void 0 || serverShareKeyExchange === void 0) throw new Error("ServerHello negotiation incomplete");
3234
+ /**
3235
+ * RFC 9846 §5.1: a handshake message may not span a key change, and the keys
3236
+ * change here. Bytes still in the buffer are the start of a message sent in
3237
+ * the clear that the server means to finish under the handshake keys — and
3238
+ * left alone they would be spliced onto the front of the decrypted flight.
3239
+ */
3240
+ if (handshakeBuffer.some((chunk) => chunk.length > 0)) return failAlert("unexpected_message");
3241
+ transcript.add(realServerHelloBytes);
3242
+ const shareLen = expectedKeyShareLength(effectiveGroupName);
3243
+ if (serverShareKeyExchange.length !== shareLen || effectiveGroupName !== "x25519" && serverShareKeyExchange[0] !== 4) return failAlert("illegal_parameter");
3244
+ let sharedSecret;
3245
+ try {
3246
+ sharedSecret = await deriveSharedSecret(effectiveGroupName, clientShare.privateKey, serverShareKeyExchange);
3247
+ } catch {
3248
+ return failAlert("illegal_parameter");
3249
+ }
3250
+ const early = await earlySecret(negotiatedSuite, resumedSession?.preSharedKey);
3251
+ const hsSecret = await handshakeSecret(negotiatedSuite, early, sharedSecret);
3252
+ const c_hs_traffic = await deriveSecret(negotiatedSuite, hsSecret, "c hs traffic", ...transcript.getMessages());
3253
+ const s_hs_traffic = await deriveSecret(negotiatedSuite, hsSecret, "s hs traffic", ...transcript.getMessages());
3254
+ const clientHsKeys = await trafficKeys(negotiatedSuite, c_hs_traffic);
3255
+ const serverHsKeys = await trafficKeys(negotiatedSuite, s_hs_traffic);
3256
+ let clientHsSeq = 0n;
3257
+ let serverHsSeq = 0n;
3258
+ clientWriteKeys = {
3259
+ key: clientHsKeys.key,
3260
+ iv: clientHsKeys.iv,
3261
+ seq: clientHsSeq
3262
+ };
3263
+ let eeReceived = false;
3264
+ let certReceived = false;
3265
+ let cvReceived = false;
3266
+ let finReceived = false;
3267
+ let certRequestContext = null;
3268
+ let leafDer;
3269
+ let intermediates = [];
3270
+ let cvScheme;
3271
+ let cvSignature;
3272
+ let transcriptHashAtCert;
3273
+ /**
3274
+ * Set by whichever path validation this connection actually performs — the
3275
+ * peer's Certificate on a full handshake, the stored chain on a resumed one
3276
+ * that re-checks. It stays `null` when neither runs, which is the resumption
3277
+ * that trusts what it stored.
3278
+ */
3279
+ let peerPublicKeyPin = null;
3280
+ let emptyRecordsSeen = 0;
3281
+ while (!finReceived) {
3282
+ let extracted = extractNextHandshakeMessage();
3283
+ while (extracted.kind === "need-more") {
3284
+ const recResult = await reader.readRecord();
3285
+ if (!recResult.ok) {
3286
+ if (recResult.kind === "truncated") return {
3287
+ ok: false,
3288
+ reason: { kind: "truncated" }
3289
+ };
3290
+ return failAlert(recResult.description);
3291
+ }
3292
+ if (recResult.kind === "eof") return {
3293
+ ok: false,
3294
+ reason: { kind: "truncated" }
3295
+ };
3296
+ const record = recResult.record;
3297
+ if (record[0] === 20) {
3298
+ emptyRecordsSeen += 1;
3299
+ if (emptyRecordsSeen > MAX_CONSECUTIVE_EMPTY_RECORDS) return failAlert("unexpected_message");
3300
+ continue;
3301
+ }
3302
+ if (record[0] !== 23) return failAlert("unexpected_message");
3303
+ emptyRecordsSeen = 0;
3304
+ const openRes = await openAead(serverHsKeys.key, serverHsKeys.iv, serverHsSeq, record);
3305
+ serverHsSeq += 1n;
3306
+ if (!openRes.ok) return failAlert(openRes.description);
3307
+ if (openRes.type === "alert") {
3308
+ const alertRes = decodeAlert(openRes.payload);
3309
+ if (alertRes.ok) return {
3310
+ ok: false,
3311
+ reason: {
3312
+ kind: "alert-received",
3313
+ alert: alertRes.alert
3314
+ }
3315
+ };
3316
+ if (alertRes.unknownDescriptionCode !== void 0) return {
3317
+ ok: false,
3318
+ reason: {
3319
+ kind: "alert-received-unknown",
3320
+ code: alertRes.unknownDescriptionCode
3321
+ }
3322
+ };
3323
+ return failAlert(alertRes.description);
3324
+ }
3325
+ if (openRes.type !== "handshake") return failAlert("unexpected_message");
3326
+ handshakeBuffer.push(openRes.payload);
3327
+ extracted = extractNextHandshakeMessage();
3328
+ }
3329
+ if (extracted.kind === "overflow") return failAlert("decode_error");
3330
+ const nextMsgBytes = extracted.bytes;
3331
+ const decoded = decodeHandshakeMessage(nextMsgBytes);
3332
+ if (!decoded.ok) return failAlert(decoded.description);
3333
+ const msg = decoded.value;
3334
+ if (!eeReceived) {
3335
+ if (msg.kind !== "encrypted_extensions") return failAlert("unexpected_message");
3336
+ const eeExtensionAlert = misplacedExtensionAlert(msg.extensions, offeredExtensionCodes, PERMITTED_IN_ENCRYPTED_EXTENSIONS);
3337
+ if (eeExtensionAlert !== null) return failAlert(eeExtensionAlert);
3338
+ eeReceived = true;
3339
+ transcript.add(nextMsgBytes);
3340
+ continue;
3341
+ }
3342
+ /**
3343
+ * A resumed handshake is authenticated by the PSK, so RFC 9846 §2.2 sends
3344
+ * neither Certificate nor CertificateVerify — EncryptedExtensions is
3345
+ * followed by Finished and nothing else. Skipping the two branches is what
3346
+ * makes a Certificate that arrives anyway an `unexpected_message`.
3347
+ *
3348
+ * **No chain arrives HERE, which is not the same as no chain being
3349
+ * validated.** The one the session stored is re-validated after this loop
3350
+ * ends, unless `reverifyOnResume` was turned off — see the check below the
3351
+ * `break`.
3352
+ */
3353
+ if (resumedSession === void 0 && !certReceived) {
3354
+ if (msg.kind === "certificate_request") {
3355
+ certRequestContext = msg.certificateRequestContext;
3356
+ transcript.add(nextMsgBytes);
3357
+ continue;
3358
+ }
3359
+ if (msg.kind !== "certificate") return failAlert("unexpected_message");
3360
+ if (msg.certificateList.length === 0) return failAlert("decode_error");
3361
+ const firstCert = msg.certificateList[0];
3362
+ if (firstCert === void 0) return failAlert("bad_certificate");
3363
+ const certificateExtensionAlert = msg.certificateList.map((entry) => misplacedExtensionAlert(entry.extensions, offeredExtensionCodes, PERMITTED_IN_CERTIFICATE_ENTRY)).find((alert) => alert !== null);
3364
+ if (certificateExtensionAlert != null) return failAlert(certificateExtensionAlert);
3365
+ leafDer = firstCert.certData;
3366
+ intermediates = msg.certificateList.slice(1).map((c) => c.certData);
3367
+ certReceived = true;
3368
+ transcript.add(nextMsgBytes);
3369
+ transcriptHashAtCert = await transcript.hash();
3370
+ continue;
3371
+ }
3372
+ if (resumedSession === void 0 && !cvReceived) {
3373
+ if (msg.kind !== "certificate_verify") return failAlert("unexpected_message");
3374
+ cvReceived = true;
3375
+ /**
3376
+ * RFC 9846 §4.5.2: "If the CertificateVerify message is sent by a server,
3377
+ * the signature algorithm MUST be one offered in the client's
3378
+ * `signature_algorithms` extension unless no valid certificate chain can
3379
+ * be produced without unsupported algorithms".
3380
+ *
3381
+ * Checked here, on the message, rather than left to `importLeafKey`'s
3382
+ * unknown-scheme case: that one answers "we cannot verify this", and it
3383
+ * cannot answer "we can verify it but never asked for it" — which is the
3384
+ * one that matters, because a scheme the client withheld is a scheme it
3385
+ * declined, and honouring it anyway would let a server pick an algorithm
3386
+ * out of the set we deliberately shrank.
3387
+ *
3388
+ * Deliberately AHEAD of `validatePath` below, which is where BoringSSL
3389
+ * differs — it validates the chain on the Certificate message, a flight
3390
+ * earlier. So a flight that is both signed with an unoffered scheme and
3391
+ * carries an untrusted chain reports `illegal_parameter` where BoringSSL
3392
+ * would report the certificate. The RFC names no order and no BoGo test
3393
+ * pairs the two faults, so this is not a divergence to record: it is a
3394
+ * message-level parameter check, and it is cheaper than the path build it
3395
+ * would otherwise precede for a signature that can never be accepted.
3396
+ */
3397
+ const offeredScheme = signatureSchemeFromCode(msg.scheme);
3398
+ if (offeredScheme === void 0 || !offeredSchemes.includes(offeredScheme)) return failAlert("illegal_parameter");
3399
+ cvScheme = offeredScheme;
3400
+ cvSignature = msg.signature;
3401
+ if (leafDer === void 0) throw new Error("Leaf certificate is undefined");
3402
+ const pathRes = await validatePeerChain({
3403
+ leafDer,
3404
+ intermediateDer: intermediates
3405
+ });
3406
+ if (!pathRes.ok) return failValidation(pathRes.reason, "peer-sent");
3407
+ let leafCert;
3408
+ try {
3409
+ leafCert = decodeCertificate(leafDer);
3410
+ } catch {
3411
+ return failAlert("bad_certificate");
3412
+ }
3413
+ if (transcriptHashAtCert === void 0) throw new Error("transcriptHashAtCert undefined");
3414
+ const cvVerifyRes = await verifyCertificateVerify({
3415
+ scheme: SIGNATURE_SCHEMES[cvScheme],
3416
+ signature: cvSignature,
3417
+ spkiDer: leafCert.subjectPublicKeyInfo.der,
3418
+ algorithmOid: leafCert.subjectPublicKeyInfo.algorithm.oid,
3419
+ transcriptHash: transcriptHashAtCert
3420
+ });
3421
+ if (!cvVerifyRes.ok) return failAlert(cvVerifyRes.description);
3422
+ /**
3423
+ * AFTER the signature, not after the path build. The pin names the key
3424
+ * that authenticated this peer, and until this line the peer has only
3425
+ * sent a chain — which is public, so sending one proves nothing. One
3426
+ * message of difference, and it is the message the whole learn/check
3427
+ * split is about.
3428
+ *
3429
+ * From the validated PATH rather than from `leafCert`, which
3430
+ * CertificateVerify just ran against. They are the same bytes —
3431
+ * `ValidatedPath` carries the leaf's SPKI DER precisely so `tls` need not
3432
+ * parse the certificate twice, and `YOZZ_VALIDATOR`'s path,
3433
+ * `decodeCertificate`'s field and `exportKey('spki')` were compared
3434
+ * directly and agree — and the path is the copy `pinnedValidator`
3435
+ * compares, so sourcing the learn from anywhere else could only introduce
3436
+ * a way for the two halves to disagree.
3437
+ */
3438
+ peerPublicKeyPin = await publicKeyPin(pathRes.path.leafSubjectPublicKeyInfoDer);
3439
+ transcript.add(nextMsgBytes);
3440
+ continue;
3441
+ }
3442
+ if (!finReceived) {
3443
+ if (msg.kind !== "finished") return failAlert("unexpected_message");
3444
+ finReceived = true;
3445
+ const sFinKey = await finishedKey(negotiatedSuite, s_hs_traffic);
3446
+ if (!await isVerifyDataValid(negotiatedSuite, sFinKey, await transcript.hash(), msg.verifyData)) return failAlert("decrypt_error");
3447
+ transcript.add(nextMsgBytes);
3448
+ if (handshakeBuffer.some((chunk) => chunk.length > 0)) return failAlert("unexpected_message");
3449
+ break;
3450
+ }
3451
+ }
3452
+ /**
3453
+ * The resumed handshake's certificate check, over the chain the session
3454
+ * stored (see `reverifyOnResume`). It runs HERE, one message later than
3455
+ * BoringSSL's `state_server_certificate_reverify`, which sits between
3456
+ * EncryptedExtensions and the server's Finished (`tls13_client.cc`). Ours
3457
+ * waits for that Finished, so the flight is proven to come from the party
3458
+ * holding the pre-shared key before we spend a path build on it. BoGo sees
3459
+ * the same thing either way — a fatal alert where the client's Finished was
3460
+ * due — which is why the placement is a choice rather than a divergence.
3461
+ *
3462
+ * The alert leaves protected, under the handshake key, and takes the
3463
+ * compatibility ChangeCipherSpec with it if one is still owed.
3464
+ */
3465
+ if (resumedSession !== void 0 && (options.reverifyOnResume ?? true)) {
3466
+ const reverified = await validatePeerChain(resumedSession.peerCertificateChain);
3467
+ if (!reverified.ok) return failValidation(reverified.reason, "session-stored");
3468
+ peerPublicKeyPin = await publicKeyPin(reverified.path.leafSubjectPublicKeyInfoDer);
3469
+ }
3470
+ const master = await masterSecret(negotiatedSuite, hsSecret);
3471
+ const c_ap_traffic = await deriveSecret(negotiatedSuite, master, "c ap traffic", ...transcript.getMessages());
3472
+ const s_ap_traffic = await deriveSecret(negotiatedSuite, master, "s ap traffic", ...transcript.getMessages());
3473
+ /**
3474
+ * RFC 9846 §7.1's `exporter_master_secret`, over ClientHello...server
3475
+ * Finished — the SAME transcript as the two traffic secrets above, and one
3476
+ * message short of `res master` below. It is derived here and kept because
3477
+ * the exporter outlives the handshake and the transcript does not.
3478
+ */
3479
+ const exporterMaster = await deriveSecret(negotiatedSuite, master, "exp master", ...transcript.getMessages());
3480
+ await sendCompatibilityCcsIfPending();
3481
+ if (certRequestContext !== null) {
3482
+ const emptyCertMsg = encodeHandshakeMessage({
3483
+ kind: "certificate",
3484
+ certificateRequestContext: certRequestContext,
3485
+ certificateList: []
3486
+ });
3487
+ transcript.add(emptyCertMsg);
3488
+ const sealedCertRecord = await sealAead(clientHsKeys.key, clientHsKeys.iv, clientHsSeq, "handshake", emptyCertMsg);
3489
+ clientHsSeq += 1n;
3490
+ clientWriteKeys = {
3491
+ key: clientHsKeys.key,
3492
+ iv: clientHsKeys.iv,
3493
+ seq: clientHsSeq
3494
+ };
3495
+ if (!await writeToPeer(sealedCertRecord)) return {
3496
+ ok: false,
3497
+ reason: { kind: "truncated" }
3498
+ };
3499
+ }
3500
+ const cFinKey = await finishedKey(negotiatedSuite, c_hs_traffic);
3501
+ const clientFinMsg = encodeHandshakeMessage({
3502
+ kind: "finished",
3503
+ verifyData: await verifyData(negotiatedSuite, cFinKey, await transcript.hash())
3504
+ });
3505
+ transcript.add(clientFinMsg);
3506
+ const sealedFinRecord = await sealAead(clientHsKeys.key, clientHsKeys.iv, clientHsSeq, "handshake", clientFinMsg);
3507
+ clientHsSeq += 1n;
3508
+ clientWriteKeys = {
3509
+ key: clientHsKeys.key,
3510
+ iv: clientHsKeys.iv,
3511
+ seq: clientHsSeq
3512
+ };
3513
+ if (!await writeToPeer(sealedFinRecord)) return {
3514
+ ok: false,
3515
+ reason: { kind: "truncated" }
3516
+ };
3517
+ /**
3518
+ * RFC 9846 §7.1's `resumption_master_secret`. It runs over the transcript
3519
+ * through the CLIENT's Finished — one message further than the application
3520
+ * traffic secrets above, which is the whole reason it is derived down here
3521
+ * rather than beside them. Every ticket the server later sends expands from
3522
+ * it, so it outlives the handshake and the transcript does not.
3523
+ */
3524
+ const resumptionSecret = await deriveSecret(negotiatedSuite, master, "res master", ...transcript.getMessages());
3525
+ /**
3526
+ * When the peer was proved, by which signature, and over which chain —
3527
+ * inherited whole on a resumed handshake, which proves none of the three. The
3528
+ * rule and its consequences live in `session.ts` beside the ceiling that
3529
+ * reads `authenticatedAt`, because that is the only place any of them can be
3530
+ * tested: no peer this package can drive mints a ticket on a resumed
3531
+ * connection (see the note where these reach `sessionFromTicket` below).
3532
+ */
3533
+ const { authenticatedAt, peerSignatureScheme, peerCertificateChain } = inheritedAuthentication(resumedSession, cvScheme === void 0 || leafDer === void 0 ? void 0 : {
3534
+ authenticatedAt: now(),
3535
+ peerSignatureScheme: cvScheme,
3536
+ peerCertificateChain: {
3537
+ leafDer,
3538
+ intermediateDer: intermediates
3539
+ }
3540
+ });
3541
+ let clientTrafficSecret = c_ap_traffic;
3542
+ let serverTrafficSecret = s_ap_traffic;
3543
+ let clientAppKeys = await trafficKeys(negotiatedSuite, clientTrafficSecret);
3544
+ let serverAppKeys = await trafficKeys(negotiatedSuite, serverTrafficSecret);
3545
+ let clientAppSeq = 0n;
3546
+ let serverAppSeq = 0n;
3547
+ /**
3548
+ * TLS 1.3 closes each direction on its own (RFC 9846 §6.1): a `close_notify`
3549
+ * says the SENDER is done writing, and says nothing about the receiver.
3550
+ * Holding one flag for both made the peer's goodbye close our write side —
3551
+ * so we never sent ours back, and a peer waiting for it waited forever.
3552
+ */
3553
+ let peerSentCloseNotify = false;
3554
+ let sentCloseNotify = false;
3555
+ clientWriteKeys = {
3556
+ key: clientAppKeys.key,
3557
+ iv: clientAppKeys.iv,
3558
+ seq: clientAppSeq
3559
+ };
3560
+ let writeQueue = Promise.resolve();
3561
+ const queueWrite = (action) => {
3562
+ const next = writeQueue.then(action, action);
3563
+ writeQueue = next.then(() => {}, () => {});
3564
+ return next;
3565
+ };
3566
+ const syncClientWriteKeys = () => {
3567
+ clientWriteKeys = {
3568
+ key: clientAppKeys.key,
3569
+ iv: clientAppKeys.iv,
3570
+ seq: clientAppSeq
3571
+ };
3572
+ };
3573
+ /**
3574
+ * Send the alert, then fail with it. Every established-connection failure goes
3575
+ * out through the write queue, because sealing outside it is how one AES-GCM
3576
+ * `(key, nonce)` came to cover two plaintexts.
3577
+ */
3578
+ const abortWith = async (description) => {
3579
+ await queueWrite(async () => {
3580
+ await sendFatalAlert(description);
3581
+ });
3582
+ return {
3583
+ ok: false,
3584
+ reason: {
3585
+ kind: "alert-sent",
3586
+ alert: {
3587
+ level: "fatal",
3588
+ description
3589
+ }
3590
+ }
3591
+ };
3592
+ };
3593
+ let emptyPostHandshakeRecords = 0;
3594
+ let warningAlertsSeen = 0;
3595
+ let keyUpdatesSeen = 0;
3596
+ let sessionTicketsSeen = 0;
3597
+ /**
3598
+ * A `KeyUpdate` asking us to update in return is answered ONCE, on the next
3599
+ * thing we write — not once per request.
3600
+ *
3601
+ * RFC 9846 §4.7.3 requires the response but sets no deadline, and a peer can
3602
+ * put several requests in flight before any of them is answered. Answering
3603
+ * each one turns one cheap record into an unbounded reply stream, and a peer
3604
+ * strict about unsolicited KeyUpdates (BoGo is, and so is BoringSSL) drops
3605
+ * the connection on the second.
3606
+ *
3607
+ * Owing it to the WRITE path is also what keeps the key rotation inside the
3608
+ * write queue, where every client-write key and sequence mutation has to
3609
+ * happen: sealing one outside it is how a single AES-GCM (key, nonce) came to
3610
+ * cover two plaintexts once already.
3611
+ */
3612
+ let keyUpdateResponseOwed = false;
3613
+ const payKeyUpdateResponse = async () => {
3614
+ if (!keyUpdateResponseOwed) return;
3615
+ keyUpdateResponseOwed = false;
3616
+ const kuMsg = encodeHandshakeMessage({
3617
+ kind: "key_update",
3618
+ requestUpdate: false
3619
+ });
3620
+ const sealedKu = await sealAead(clientAppKeys.key, clientAppKeys.iv, clientAppSeq, "handshake", kuMsg);
3621
+ clientAppSeq += 1n;
3622
+ await transport.write(sealedKu);
3623
+ clientTrafficSecret = await hkdfExpandLabel(negotiatedSuite, clientTrafficSecret, "traffic upd", /* @__PURE__ */ new Uint8Array(0), CIPHER_SUITES[negotiatedSuite].hashLength);
3624
+ clientAppKeys = await trafficKeys(negotiatedSuite, clientTrafficSecret);
3625
+ clientAppSeq = 0n;
3626
+ syncClientWriteKeys();
3627
+ };
3628
+ return {
3629
+ ok: true,
3630
+ connection: {
3631
+ read: async () => {
3632
+ while (true) {
3633
+ if (peerSentCloseNotify) return {
3634
+ ok: true,
3635
+ kind: "closed"
3636
+ };
3637
+ let extracted = extractNextHandshakeMessage();
3638
+ if (extracted.kind === "message") {} else if (extracted.kind === "overflow") return abortWith("decode_error");
3639
+ else {
3640
+ const recResult = await reader.readRecord();
3641
+ if (!recResult.ok) {
3642
+ if (recResult.kind === "truncated") return {
3643
+ ok: false,
3644
+ reason: { kind: "truncated" }
3645
+ };
3646
+ return abortWith(recResult.description);
3647
+ }
3648
+ if (recResult.kind === "eof") return {
3649
+ ok: false,
3650
+ reason: { kind: "truncated" }
3651
+ };
3652
+ const record = recResult.record;
3653
+ if (record[0] !== 23) return abortWith("unexpected_message");
3654
+ const openRes = await openAead(serverAppKeys.key, serverAppKeys.iv, serverAppSeq, record);
3655
+ serverAppSeq += 1n;
3656
+ if (!openRes.ok) return abortWith(openRes.description);
3657
+ if (openRes.type === "application_data") {
3658
+ if (openRes.payload.length === 0) {
3659
+ emptyPostHandshakeRecords += 1;
3660
+ if (emptyPostHandshakeRecords > MAX_CONSECUTIVE_EMPTY_RECORDS) return abortWith("unexpected_message");
3661
+ continue;
3662
+ }
3663
+ emptyPostHandshakeRecords = 0;
3664
+ warningAlertsSeen = 0;
3665
+ keyUpdatesSeen = 0;
3666
+ sessionTicketsSeen = 0;
3667
+ return {
3668
+ ok: true,
3669
+ kind: "data",
3670
+ bytes: openRes.payload
3671
+ };
3672
+ }
3673
+ if (openRes.type === "alert") {
3674
+ const alertRes = decodeAlert(openRes.payload);
3675
+ if (!alertRes.ok) {
3676
+ if (alertRes.unknownDescriptionCode !== void 0) return {
3677
+ ok: false,
3678
+ reason: {
3679
+ kind: "alert-received-unknown",
3680
+ code: alertRes.unknownDescriptionCode
3681
+ }
3682
+ };
3683
+ return abortWith(alertRes.description);
3684
+ }
3685
+ if (alertRes.alert.description === "close_notify") {
3686
+ peerSentCloseNotify = true;
3687
+ return {
3688
+ ok: true,
3689
+ kind: "closed"
3690
+ };
3691
+ }
3692
+ if (alertRes.alert.description === "user_canceled") {
3693
+ warningAlertsSeen += 1;
3694
+ if (warningAlertsSeen > MAX_WARNING_ALERTS) return abortWith("unexpected_message");
3695
+ continue;
3696
+ }
3697
+ return {
3698
+ ok: false,
3699
+ reason: {
3700
+ kind: "alert-received",
3701
+ alert: alertRes.alert
3702
+ }
3703
+ };
3704
+ }
3705
+ if (openRes.type !== "handshake") return abortWith("unexpected_message");
3706
+ handshakeBuffer.push(openRes.payload);
3707
+ extracted = extractNextHandshakeMessage();
3708
+ while (extracted.kind === "need-more") {
3709
+ const more = await reader.readRecord();
3710
+ if (!more.ok) {
3711
+ if (more.kind === "truncated") return {
3712
+ ok: false,
3713
+ reason: { kind: "truncated" }
3714
+ };
3715
+ return abortWith(more.description);
3716
+ }
3717
+ if (more.kind === "eof") return {
3718
+ ok: false,
3719
+ reason: { kind: "truncated" }
3720
+ };
3721
+ if (more.record[0] !== 23) return abortWith("unexpected_message");
3722
+ const moreOpen = await openAead(serverAppKeys.key, serverAppKeys.iv, serverAppSeq, more.record);
3723
+ serverAppSeq += 1n;
3724
+ if (!moreOpen.ok) return abortWith(moreOpen.description);
3725
+ if (moreOpen.type !== "handshake") return abortWith("unexpected_message");
3726
+ handshakeBuffer.push(moreOpen.payload);
3727
+ extracted = extractNextHandshakeMessage();
3728
+ }
3729
+ if (extracted.kind === "overflow") return abortWith("decode_error");
3730
+ }
3731
+ if (extracted.kind !== "message") continue;
3732
+ const decoded = decodeHandshakeMessage(extracted.bytes);
3733
+ if (!decoded.ok) return abortWith(decoded.description);
3734
+ if (decoded.value.kind === "new_session_ticket") {
3735
+ sessionTicketsSeen += 1;
3736
+ if (sessionTicketsSeen > MAX_CONSECUTIVE_SESSION_TICKETS) return abortWith("unexpected_message");
3737
+ const { onSession } = options;
3738
+ if (onSession !== void 0) {
3739
+ const session = await sessionFromTicket({
3740
+ serverName,
3741
+ expectedPeerName,
3742
+ suite: negotiatedSuite,
3743
+ resumptionSecret,
3744
+ receivedAt: now(),
3745
+ authenticatedAt,
3746
+ /**
3747
+ * These three are what a RENEWAL writes down, and reaching them
3748
+ * took the package's first §4 replay through the state machine.
3749
+ * No peer this package can DRIVE mints a ticket on a resumed
3750
+ * connection: Node's OpenSSL issues two on the full handshake and
3751
+ * none on the resumption, BoGo runs all 106 of its resumption
3752
+ * tests at `-resume-count 1` so no third connection offers one
3753
+ * back, and RFC 8448 §4 publishes no NewSessionTicket.
3754
+ *
3755
+ * So `session.test.ts` replays §4 and seals §3's ticket under §4's
3756
+ * OWN published server application key — which works because the
3757
+ * server derives that key over ClientHello..server Finished,
3758
+ * before the `EndOfEarlyData` this client never sends. The
3759
+ * decryption is the delivery mechanism and the proof of the key
3760
+ * schedule at once. Point `inheritedAuthentication` at a fresh
3761
+ * authentication and that test fails.
3762
+ */
3763
+ peerSignatureScheme,
3764
+ peerCertificateChain,
3765
+ ticket: decoded.value.ticket,
3766
+ ticketNonce: decoded.value.ticketNonce,
3767
+ ticketAgeAdd: decoded.value.ticketAgeAdd,
3768
+ ticketLifetime: decoded.value.ticketLifetime
3769
+ });
3770
+ /**
3771
+ * The caller's storage is not on the connection's critical path. A
3772
+ * ticket is an optimisation — losing one costs a resumption, and
3773
+ * letting a `localStorage` quota error out of here would cost the
3774
+ * connection AND break `TlsReadResult`, which is total everywhere
3775
+ * else in this file.
3776
+ */
3777
+ if (session !== void 0) try {
3778
+ await onSession(session);
3779
+ } catch {}
3780
+ }
3781
+ continue;
3782
+ }
3783
+ if (decoded.value.kind === "key_update") {
3784
+ keyUpdatesSeen += 1;
3785
+ if (keyUpdatesSeen > MAX_KEY_UPDATES) return abortWith("unexpected_message");
3786
+ const hashLen = CIPHER_SUITES[negotiatedSuite].hashLength;
3787
+ serverTrafficSecret = await hkdfExpandLabel(negotiatedSuite, serverTrafficSecret, "traffic upd", /* @__PURE__ */ new Uint8Array(0), hashLen);
3788
+ serverAppKeys = await trafficKeys(negotiatedSuite, serverTrafficSecret);
3789
+ serverAppSeq = 0n;
3790
+ if (decoded.value.requestUpdate) keyUpdateResponseOwed = true;
3791
+ continue;
3792
+ }
3793
+ return abortWith("unexpected_message");
3794
+ }
3795
+ },
3796
+ write: async (plaintext) => {
3797
+ if (sentCloseNotify) return {
3798
+ ok: false,
3799
+ reason: {
3800
+ kind: "alert-sent",
3801
+ alert: {
3802
+ level: "warning",
3803
+ description: "close_notify"
3804
+ }
3805
+ }
3806
+ };
3807
+ const run = async () => {
3808
+ await payKeyUpdateResponse();
3809
+ const hashLen = CIPHER_SUITES[negotiatedSuite].hashLength;
3810
+ if (clientAppSeq >= 2n ** 24n) {
3811
+ const kuMsg = encodeHandshakeMessage({
3812
+ kind: "key_update",
3813
+ requestUpdate: false
3814
+ });
3815
+ const sealedKu = await sealAead(clientAppKeys.key, clientAppKeys.iv, clientAppSeq, "handshake", kuMsg);
3816
+ clientAppSeq += 1n;
3817
+ await transport.write(sealedKu);
3818
+ clientTrafficSecret = await hkdfExpandLabel(negotiatedSuite, clientTrafficSecret, "traffic upd", /* @__PURE__ */ new Uint8Array(0), hashLen);
3819
+ clientAppKeys = await trafficKeys(negotiatedSuite, clientTrafficSecret);
3820
+ clientAppSeq = 0n;
3821
+ }
3822
+ for (let offset = 0; offset < plaintext.length; offset += MAX_RECORD_PLAINTEXT) {
3823
+ const sealed = await sealAead(clientAppKeys.key, clientAppKeys.iv, clientAppSeq, "application_data", plaintext.subarray(offset, offset + MAX_RECORD_PLAINTEXT));
3824
+ clientAppSeq += 1n;
3825
+ syncClientWriteKeys();
3826
+ await transport.write(sealed);
3827
+ }
3828
+ return { ok: true };
3829
+ };
3830
+ return queueWrite(run);
3831
+ },
3832
+ close: async () => {
3833
+ if (sentCloseNotify) return { ok: true };
3834
+ sentCloseNotify = true;
3835
+ const run = async () => {
3836
+ await payKeyUpdateResponse();
3837
+ const alertPayload = encodeAlert({
3838
+ level: "warning",
3839
+ description: "close_notify"
3840
+ });
3841
+ const sealedAlert = await sealAead(clientAppKeys.key, clientAppKeys.iv, clientAppSeq, "alert", alertPayload);
3842
+ clientAppSeq += 1n;
3843
+ syncClientWriteKeys();
3844
+ try {
3845
+ await transport.write(sealedAlert);
3846
+ return { ok: true };
3847
+ } catch {
3848
+ return {
3849
+ ok: false,
3850
+ reason: { kind: "truncated" }
3851
+ };
3852
+ }
3853
+ };
3854
+ return queueWrite(run);
3855
+ },
3856
+ /**
3857
+ * Not queued and not stateful — it reads a secret fixed at the end of the
3858
+ * handshake and touches no sequence number, so it cannot race a write the
3859
+ * way `close` and the KeyUpdate response can.
3860
+ */
3861
+ exportKeyingMaterial: (label, context, length) => exportKeyingMaterial(negotiatedSuite, exporterMaster, label, context, length)
3862
+ },
3863
+ negotiatedGroup: effectiveGroupName,
3864
+ isResumed: resumedSession !== void 0,
3865
+ isHelloRetryRequested: hasSeenHrr,
3866
+ peerSignatureScheme,
3867
+ peerPublicKeyPin
3868
+ };
3869
+ };
3870
+ const startTls = (options) => runHandshake(options);
3871
+ //#endregion
3872
+ export { CIPHER_SUITES, NAMED_GROUPS, SIGNATURE_SCHEMES, SUPPORTED_GROUPS, SUPPORTED_SIGNATURE_SCHEMES, deriveSecret, earlySecret, finishedKey, handshakeSecret, hkdfExpandLabel, hkdfExtract, isVerifyDataValid, masterSecret, namedGroupFromCode, pinnedValidator, publicKeyPin, signatureSchemeFromCode, startTls, trafficKeys, transcriptHash, verifyData };