@yozz.app/tls 0.1.3 → 0.1.4

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.
Files changed (3) hide show
  1. package/dist/index.d.mts +85 -371
  2. package/dist/index.mjs +285 -1147
  3. package/package.json +7 -7
package/dist/index.mjs CHANGED
@@ -24,17 +24,7 @@ const NAMED_GROUPS = {
24
24
  secp384r1: 24,
25
25
  x25519: 29
26
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
- */
27
+ /** In offer order; the first carries the key share. P-384 is required: `posteo.de` refuses X25519 and P-256. */
38
28
  const SUPPORTED_GROUPS = [
39
29
  "x25519",
40
30
  "secp256r1",
@@ -51,20 +41,8 @@ const SIGNATURE_SCHEMES = {
51
41
  ed25519: 2055
52
42
  };
53
43
  /**
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.
44
+ * The security boundary, not a preference: RFC 9846 §4.5.2 lets a server sign a CertificateVerify
45
+ * only with a scheme offered here. Ed25519 is offered though BoringSSL disables it by default.
68
46
  */
69
47
  const SUPPORTED_SIGNATURE_SCHEMES = [
70
48
  "ecdsa_secp256r1_sha256",
@@ -87,13 +65,6 @@ const CERTIFICATE_SIGNATURE_SCHEMES = {
87
65
  algorithmOid: "1.2.840.10045.4.3.3",
88
66
  curveOid: "1.3.132.0.34"
89
67
  },
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
68
  ecdsa_secp521r1_sha512: {
98
69
  code: 1539,
99
70
  algorithmOid: "1.2.840.10045.4.3.4",
@@ -115,12 +86,7 @@ const CERTIFICATE_SIGNATURE_SCHEMES = {
115
86
  curveOid: null
116
87
  }
117
88
  };
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
- */
89
+ /** ECDSA first, for smaller chains. RSA-PKCS1 must be here: most of the WebPKI is signed with it. */
124
90
  const OFFERED_CERTIFICATE_SIGNATURE_SCHEMES = [
125
91
  "ecdsa_secp256r1_sha256",
126
92
  "ecdsa_secp384r1_sha384",
@@ -143,11 +109,7 @@ const EXTENSION_TYPES = {
143
109
  psk_key_exchange_modes: 45,
144
110
  key_share: 51
145
111
  };
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
- */
112
+ /** RFC 9846 §4.3.9. Only `psk_dhe_ke` is offered: `psk_ke` gives up forward secrecy. */
151
113
  const PSK_KEY_EXCHANGE_MODES = {
152
114
  psk_ke: 0,
153
115
  psk_dhe_ke: 1
@@ -191,13 +153,9 @@ const TLS_VERSION = {
191
153
  V1_2: 771,
192
154
  V1_3: 772
193
155
  };
194
- /**
195
- * RFC 9846 §4.2.3: SHA-256("HelloRetryRequest")
196
- */
156
+ /** RFC 9846 §4.2.3: SHA-256("HelloRetryRequest"). */
197
157
  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
- */
158
+ /** RFC 9846 §4.2.3: the last 8 octets of ServerHello.random. */
201
159
  const DOWNGRADE_SENTINEL_TLS_1_2 = Uint8Array.of(68, 79, 87, 78, 71, 82, 68, 1);
202
160
  const DOWNGRADE_SENTINEL_TLS_1_1 = Uint8Array.of(68, 79, 87, 78, 71, 82, 68, 0);
203
161
  //#endregion
@@ -220,7 +178,10 @@ const decodeAlert = (bytes) => {
220
178
  ok: false,
221
179
  description: "decode_error"
222
180
  };
223
- const level = levelCode === 1 ? "warning" : levelCode === 2 ? "fatal" : void 0;
181
+ const level = (() => {
182
+ if (levelCode === 1) return "warning";
183
+ if (levelCode === 2) return "fatal";
184
+ })();
224
185
  if (level === void 0) return {
225
186
  ok: false,
226
187
  description: "illegal_parameter"
@@ -253,21 +214,7 @@ const VALIDATION_FAILURE_ALERTS = {
253
214
  "name-mismatch": "certificate_unknown",
254
215
  "no-path-to-trust-anchor": "unknown_ca",
255
216
  "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
217
  "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
218
  "rejected-by-policy": "certificate_unknown"
272
219
  };
273
220
  const alertForValidationFailure = (failure) => ({
@@ -276,9 +223,6 @@ const alertForValidationFailure = (failure) => ({
276
223
  });
277
224
  //#endregion
278
225
  //#region src/bytes.ts
279
- /**
280
- * Byte-level encoding and decoding primitives for TLS records and messages.
281
- */
282
226
  const concat$1 = (...parts) => {
283
227
  const joined = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
284
228
  let offset = 0;
@@ -323,9 +267,6 @@ const writeUint24 = (value) => {
323
267
  };
324
268
  //#endregion
325
269
  //#region src/handshake-messages.ts
326
- /**
327
- * TLS 1.3 Handshake Message Codec (RFC 9846 §4).
328
- */
329
270
  const encodeExtensions = (extensions) => {
330
271
  const parts = [];
331
272
  let declaredTotalLen;
@@ -408,9 +349,7 @@ const encodeExtensions = (extensions) => {
408
349
  if (ext.maxEarlyDataSize !== void 0) body = Uint8Array.of(ext.maxEarlyDataSize >>> 24 & 255, ext.maxEarlyDataSize >>> 16 & 255, ext.maxEarlyDataSize >>> 8 & 255, ext.maxEarlyDataSize & 255);
409
350
  else body = /* @__PURE__ */ new Uint8Array(0);
410
351
  break;
411
- case "unknown":
412
- body = ext.data;
413
- break;
352
+ case "unknown": body = ext.data;
414
353
  }
415
354
  parts.push(writeUint16(extTypeCode), writeUint16(declaredExtLen ?? body.length), body);
416
355
  }
@@ -461,46 +400,47 @@ const decodeExtensions = (bytes, context = "other", isHrr = false, allowPreBinde
461
400
  ok: false,
462
401
  description: "decode_error"
463
402
  };
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 {
403
+ if (typeCode === EXTENSION_TYPES.server_name) {
404
+ if (extData.length === 0) extensions.push({
405
+ kind: "server_name",
406
+ serverNames: []
407
+ });
408
+ else {
409
+ if (extData.length < 2) return {
482
410
  ok: false,
483
411
  description: "decode_error"
484
412
  };
485
- const nameType = readUint8(extData, nOffset);
486
- const nameLen = readUint16(extData, nOffset + 1);
487
- nOffset += 3;
488
- if (nOffset + nameLen > extData.length) return {
413
+ const listLen = readUint16(extData, 0);
414
+ if (extData.length !== 2 + listLen) return {
489
415
  ok: false,
490
416
  description: "decode_error"
491
417
  };
492
- if (nameType === 0) {
493
- const nameBytes = extData.subarray(nOffset, nOffset + nameLen);
494
- serverNames.push(new TextDecoder().decode(nameBytes));
418
+ let nOffset = 2;
419
+ const serverNames = [];
420
+ while (nOffset < extData.length) {
421
+ if (nOffset + 3 > extData.length) return {
422
+ ok: false,
423
+ description: "decode_error"
424
+ };
425
+ const nameType = readUint8(extData, nOffset);
426
+ const nameLen = readUint16(extData, nOffset + 1);
427
+ nOffset += 3;
428
+ if (nOffset + nameLen > extData.length) return {
429
+ ok: false,
430
+ description: "decode_error"
431
+ };
432
+ if (nameType === 0) {
433
+ const nameBytes = extData.subarray(nOffset, nOffset + nameLen);
434
+ serverNames.push(new TextDecoder().decode(nameBytes));
435
+ }
436
+ nOffset += nameLen;
495
437
  }
496
- nOffset += nameLen;
438
+ extensions.push({
439
+ kind: "server_name",
440
+ serverNames
441
+ });
497
442
  }
498
- extensions.push({
499
- kind: "server_name",
500
- serverNames
501
- });
502
- }
503
- else if (typeCode === EXTENSION_TYPES.supported_groups) {
443
+ } else if (typeCode === EXTENSION_TYPES.supported_groups) {
504
444
  if (extData.length < 2) return {
505
445
  ok: false,
506
446
  description: "decode_error"
@@ -533,15 +473,6 @@ const decodeExtensions = (bytes, context = "other", isHrr = false, allowPreBinde
533
473
  schemes
534
474
  });
535
475
  } 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
476
  if (extData.length < 2) return {
546
477
  ok: false,
547
478
  description: "decode_error"
@@ -561,97 +492,99 @@ const decodeExtensions = (bytes, context = "other", isHrr = false, allowPreBinde
561
492
  kind: "padding",
562
493
  length: extData.length
563
494
  });
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 {
495
+ else if (isHello && typeCode === EXTENSION_TYPES.supported_versions) {
496
+ if (isServerHello) {
497
+ if (extData.length !== 2) return {
633
498
  ok: false,
634
499
  description: "decode_error"
635
500
  };
636
- const group = readUint16(extData, sOffset);
637
- const kLen = readUint16(extData, sOffset + 2);
638
- sOffset += 4;
639
- if (sOffset + kLen > extData.length) return {
501
+ extensions.push({
502
+ kind: "supported_versions",
503
+ versions: [readUint16(extData, 0)],
504
+ isServerHello: true
505
+ });
506
+ } else {
507
+ if (extData.length < 1) return {
640
508
  ok: false,
641
509
  description: "decode_error"
642
510
  };
643
- clientShares.push({
644
- group,
645
- keyExchange: extData.subarray(sOffset, sOffset + kLen)
511
+ const listLen = readUint8(extData, 0);
512
+ if (extData.length !== 1 + listLen || listLen % 2 !== 0) return {
513
+ ok: false,
514
+ description: "decode_error"
515
+ };
516
+ const versions = [];
517
+ for (let i = 1; i < extData.length; i += 2) versions.push(readUint16(extData, i));
518
+ extensions.push({
519
+ kind: "supported_versions",
520
+ versions,
521
+ isServerHello: false
646
522
  });
647
- sOffset += kLen;
648
523
  }
649
- extensions.push({
650
- kind: "key_share",
651
- clientShares
652
- });
653
- }
654
- else if (isHello && typeCode === EXTENSION_TYPES.cookie) {
524
+ } else if (isHello && typeCode === EXTENSION_TYPES.key_share) {
525
+ if (isHrr) {
526
+ if (extData.length !== 2) return {
527
+ ok: false,
528
+ description: "decode_error"
529
+ };
530
+ extensions.push({
531
+ kind: "key_share",
532
+ selectedGroup: readUint16(extData, 0)
533
+ });
534
+ } else if (isServerHello) {
535
+ if (extData.length < 4) return {
536
+ ok: false,
537
+ description: "decode_error"
538
+ };
539
+ const group = readUint16(extData, 0);
540
+ const kLen = readUint16(extData, 2);
541
+ if (extData.length !== 4 + kLen) return {
542
+ ok: false,
543
+ description: "decode_error"
544
+ };
545
+ extensions.push({
546
+ kind: "key_share",
547
+ serverShare: {
548
+ group,
549
+ keyExchange: extData.subarray(4, 4 + kLen)
550
+ }
551
+ });
552
+ } else {
553
+ if (extData.length < 2) return {
554
+ ok: false,
555
+ description: "decode_error"
556
+ };
557
+ const listLen = readUint16(extData, 0);
558
+ if (extData.length !== 2 + listLen) return {
559
+ ok: false,
560
+ description: "decode_error"
561
+ };
562
+ let sOffset = 2;
563
+ const clientShares = [];
564
+ while (sOffset < extData.length) {
565
+ if (sOffset + 4 > extData.length) return {
566
+ ok: false,
567
+ description: "decode_error"
568
+ };
569
+ const group = readUint16(extData, sOffset);
570
+ const kLen = readUint16(extData, sOffset + 2);
571
+ sOffset += 4;
572
+ if (sOffset + kLen > extData.length) return {
573
+ ok: false,
574
+ description: "decode_error"
575
+ };
576
+ clientShares.push({
577
+ group,
578
+ keyExchange: extData.subarray(sOffset, sOffset + kLen)
579
+ });
580
+ sOffset += kLen;
581
+ }
582
+ extensions.push({
583
+ kind: "key_share",
584
+ clientShares
585
+ });
586
+ }
587
+ } else if (isHello && typeCode === EXTENSION_TYPES.cookie) {
655
588
  if (extData.length < 2) return {
656
589
  ok: false,
657
590
  description: "decode_error"
@@ -669,84 +602,85 @@ const decodeExtensions = (bytes, context = "other", isHrr = false, allowPreBinde
669
602
  kind: "cookie",
670
603
  cookie: extData.subarray(2)
671
604
  });
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 {
605
+ } else if (isHello && typeCode === EXTENSION_TYPES.pre_shared_key) {
606
+ if (isServerHello) {
607
+ if (extData.length !== 2) return {
702
608
  ok: false,
703
609
  description: "decode_error"
704
610
  };
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
611
+ extensions.push({
612
+ kind: "pre_shared_key",
613
+ selectedIdentity: readUint16(extData, 0)
712
614
  });
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 {
615
+ } else {
616
+ if (extData.length < 4) return {
722
617
  ok: false,
723
618
  description: "decode_error"
724
619
  };
725
- const binderListLen = readUint16(extData, idOffset);
726
- idOffset += 2;
727
- if (idOffset + binderListLen !== extData.length) return {
620
+ const idListLen = readUint16(extData, 0);
621
+ let idOffset = 2;
622
+ if (idOffset + idListLen > extData.length) return {
728
623
  ok: false,
729
624
  description: "decode_error"
730
625
  };
731
- const binders = [];
732
- while (idOffset < extData.length) {
733
- const bLen = readUint8(extData, idOffset);
734
- idOffset += 1;
735
- if (idOffset + bLen > extData.length) return {
626
+ const identities = [];
627
+ const idEnd = 2 + idListLen;
628
+ while (idOffset < idEnd) {
629
+ if (idOffset + 6 > idEnd) return {
736
630
  ok: false,
737
631
  description: "decode_error"
738
632
  };
739
- binders.push(extData.subarray(idOffset, idOffset + bLen));
740
- idOffset += bLen;
633
+ const idLen = readUint16(extData, idOffset);
634
+ idOffset += 2;
635
+ if (idOffset + idLen + 4 > idEnd) return {
636
+ ok: false,
637
+ description: "decode_error"
638
+ };
639
+ const identity = extData.subarray(idOffset, idOffset + idLen);
640
+ idOffset += idLen;
641
+ const age = (extData[idOffset] ?? 0) << 24 | (extData[idOffset + 1] ?? 0) << 16 | (extData[idOffset + 2] ?? 0) << 8 | (extData[idOffset + 3] ?? 0);
642
+ idOffset += 4;
643
+ identities.push({
644
+ identity,
645
+ obfuscatedTicketAge: age >>> 0
646
+ });
741
647
  }
742
- extensions.push({
648
+ if (idOffset === extData.length && dataLen === extData.length + 35) extensions.push({
743
649
  kind: "pre_shared_key",
744
650
  identities,
745
- binders
651
+ binders: [],
652
+ truncatedPreBinder: true
746
653
  });
654
+ else {
655
+ if (idOffset + 2 > extData.length) return {
656
+ ok: false,
657
+ description: "decode_error"
658
+ };
659
+ const binderListLen = readUint16(extData, idOffset);
660
+ idOffset += 2;
661
+ if (idOffset + binderListLen !== extData.length) return {
662
+ ok: false,
663
+ description: "decode_error"
664
+ };
665
+ const binders = [];
666
+ while (idOffset < extData.length) {
667
+ const bLen = readUint8(extData, idOffset);
668
+ idOffset += 1;
669
+ if (idOffset + bLen > extData.length) return {
670
+ ok: false,
671
+ description: "decode_error"
672
+ };
673
+ binders.push(extData.subarray(idOffset, idOffset + bLen));
674
+ idOffset += bLen;
675
+ }
676
+ extensions.push({
677
+ kind: "pre_shared_key",
678
+ identities,
679
+ binders
680
+ });
681
+ }
747
682
  }
748
- }
749
- else if (context === "client_hello" && typeCode === EXTENSION_TYPES.psk_key_exchange_modes) {
683
+ } else if (context === "client_hello" && typeCode === EXTENSION_TYPES.psk_key_exchange_modes) {
750
684
  if (extData.length < 2) return {
751
685
  ok: false,
752
686
  description: "decode_error"
@@ -760,14 +694,15 @@ const decodeExtensions = (bytes, context = "other", isHrr = false, allowPreBinde
760
694
  kind: "psk_key_exchange_modes",
761
695
  modes: [...extData.subarray(1)]
762
696
  });
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({
697
+ } else if (typeCode === EXTENSION_TYPES.early_data) {
698
+ if (extData.length === 4) {
699
+ const maxEarlyDataSize = (extData[0] ?? 0) << 24 | (extData[1] ?? 0) << 16 | (extData[2] ?? 0) << 8 | (extData[3] ?? 0);
700
+ extensions.push({
701
+ kind: "early_data",
702
+ maxEarlyDataSize: maxEarlyDataSize >>> 0
703
+ });
704
+ } else extensions.push({ kind: "early_data" });
705
+ } else extensions.push({
771
706
  kind: "unknown",
772
707
  typeCode,
773
708
  data: extData
@@ -818,10 +753,13 @@ const encodeHandshakeMessage = (msg) => {
818
753
  body = concat$1(contextBlock, writeUint24(listBytes.length), listBytes);
819
754
  break;
820
755
  }
821
- case "certificate_request":
756
+ case "certificate_request": {
822
757
  typeCode = HANDSHAKE_TYPES.certificate_request;
823
- body = concat$1(concat$1(writeUint8(msg.certificateRequestContext.length), msg.certificateRequestContext), encodeExtensions(msg.extensions));
758
+ const contextBlock = concat$1(writeUint8(msg.certificateRequestContext.length), msg.certificateRequestContext);
759
+ const extBlock = encodeExtensions(msg.extensions);
760
+ body = concat$1(contextBlock, extBlock);
824
761
  break;
762
+ }
825
763
  case "certificate_verify":
826
764
  typeCode = HANDSHAKE_TYPES.certificate_verify;
827
765
  body = concat$1(writeUint16(msg.scheme), writeUint16(msg.signature.length), msg.signature);
@@ -830,10 +768,16 @@ const encodeHandshakeMessage = (msg) => {
830
768
  typeCode = HANDSHAKE_TYPES.finished;
831
769
  body = msg.verifyData;
832
770
  break;
833
- case "new_session_ticket":
771
+ case "new_session_ticket": {
834
772
  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));
773
+ const lifeBytes = Uint8Array.of(msg.ticketLifetime >>> 24 & 255, msg.ticketLifetime >>> 16 & 255, msg.ticketLifetime >>> 8 & 255, msg.ticketLifetime & 255);
774
+ const ageBytes = Uint8Array.of(msg.ticketAgeAdd >>> 24 & 255, msg.ticketAgeAdd >>> 16 & 255, msg.ticketAgeAdd >>> 8 & 255, msg.ticketAgeAdd & 255);
775
+ const nonceBlock = concat$1(writeUint8(msg.ticketNonce.length), msg.ticketNonce);
776
+ const ticketBlock = concat$1(writeUint16(msg.ticket.length), msg.ticket);
777
+ const extBlock = encodeExtensions(msg.extensions);
778
+ body = concat$1(lifeBytes, ageBytes, nonceBlock, ticketBlock, extBlock);
836
779
  break;
780
+ }
837
781
  case "key_update":
838
782
  typeCode = HANDSHAKE_TYPES.key_update;
839
783
  body = Uint8Array.of(msg.requestUpdate ? 1 : 0);
@@ -841,7 +785,6 @@ const encodeHandshakeMessage = (msg) => {
841
785
  case "end_of_early_data":
842
786
  typeCode = HANDSHAKE_TYPES.end_of_early_data;
843
787
  body = /* @__PURE__ */ new Uint8Array(0);
844
- break;
845
788
  }
846
789
  return concat$1(writeUint8(typeCode), writeUint24(declaredLength ?? body.length), body);
847
790
  };
@@ -930,15 +873,6 @@ const decodeHandshakeMessage = (bytes) => {
930
873
  description: "decode_error"
931
874
  };
932
875
  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
876
  if (legacyVersion !== 771) return {
943
877
  ok: false,
944
878
  description: "protocol_version"
@@ -1224,49 +1158,11 @@ const decodeHandshakeMessage = (bytes) => {
1224
1158
  };
1225
1159
  }
1226
1160
  };
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
- */
1161
+ /** The tail of the message, since `pre_shared_key` must be last (RFC 9846 §4.3.11): list length, entry length, binder. */
1235
1162
  const binderListLength = (binderLength) => 3 + binderLength;
1236
1163
  /**
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.
1164
+ * RFC 7685 §4: a ClientHello of 256..511 bytes is padded to 512. The extension's own 4-byte header
1165
+ * counts, so a 509-byte message takes an empty one and lands at 513.
1270
1166
  */
1271
1167
  const PADDING_TARGET = 512;
1272
1168
  const PADDING_RANGE_START = 256;
@@ -1292,19 +1188,10 @@ const encodeProductionClientHello = (options) => {
1292
1188
  kind: "signature_algorithms",
1293
1189
  schemes: (options.signatureSchemes ?? SUPPORTED_SIGNATURE_SCHEMES).map((name) => SIGNATURE_SCHEMES[name])
1294
1190
  },
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
1191
  {
1305
1192
  kind: "signature_algorithms_cert",
1306
1193
  schemes: OFFERED_CERTIFICATE_SIGNATURE_SCHEMES.map((name) => CERTIFICATE_SIGNATURE_SCHEMES[name].code)
1307
- }),
1194
+ },
1308
1195
  {
1309
1196
  kind: "supported_versions",
1310
1197
  versions: [TLS_VERSION.V1_3],
@@ -1322,12 +1209,6 @@ const encodeProductionClientHello = (options) => {
1322
1209
  kind: "cookie",
1323
1210
  cookie: options.cookie
1324
1211
  });
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
1212
  extensions.push({
1332
1213
  kind: "psk_key_exchange_modes",
1333
1214
  modes: [PSK_KEY_EXCHANGE_MODES.psk_dhe_ke]
@@ -1340,13 +1221,6 @@ const encodeProductionClientHello = (options) => {
1340
1221
  }],
1341
1222
  binders: [new Uint8Array(options.psk.binderLength)]
1342
1223
  });
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
1224
  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
1225
  const build = (withExtensions) => encodeHandshakeMessage({
1352
1226
  kind: "client_hello",
@@ -1360,11 +1234,6 @@ const encodeProductionClientHello = (options) => {
1360
1234
  const unpadded = build(extensions);
1361
1235
  const padding = paddingFor(unpadded.length);
1362
1236
  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
1237
  const insertAt = extensions.at(-1)?.kind === "pre_shared_key" ? -1 : extensions.length;
1369
1238
  return build(extensions.toSpliced(insertAt, 0, {
1370
1239
  kind: "padding",
@@ -1373,12 +1242,7 @@ const encodeProductionClientHello = (options) => {
1373
1242
  };
1374
1243
  //#endregion
1375
1244
  //#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
- */
1245
+ /** `posteo.de` takes only the second. ChaCha20 is absent: WebCrypto has none. */
1382
1246
  const CIPHER_SUITES = {
1383
1247
  TLS_AES_128_GCM_SHA256: {
1384
1248
  code: 4865,
@@ -1404,21 +1268,13 @@ const concat = (...parts) => {
1404
1268
  }
1405
1269
  return joined;
1406
1270
  };
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
- */
1271
+ /** WebCrypto's types want a buffer proven not to be shared, which a view cannot prove. */
1412
1272
  const nonShared$3 = (bytes) => new Uint8Array(bytes);
1413
1273
  const hmac = async (hash, key, message) => new Uint8Array(await crypto.subtle.sign("HMAC", await crypto.subtle.importKey("raw", nonShared$3(key), {
1414
1274
  name: "HMAC",
1415
1275
  hash
1416
1276
  }, 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
- */
1277
+ /** RFC 5869 §2.1. WebCrypto refuses a zero-length HMAC key, so an empty salt becomes HashLen zeros. */
1422
1278
  const hkdfExtract = (suite, salt, ikm) => {
1423
1279
  const { hash, hashLength } = CIPHER_SUITES[suite];
1424
1280
  return hmac(hash, salt.length === 0 ? new Uint8Array(hashLength) : salt, ikm);
@@ -1435,72 +1291,31 @@ const hkdfExpand = async (suite, prk, info, length) => {
1435
1291
  }
1436
1292
  return output;
1437
1293
  };
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
- */
1294
+ /** RFC 9846 §7.1 `HkdfLabel`. */
1444
1295
  const hkdfLabel = (label, context, length) => {
1445
1296
  const prefixed = new TextEncoder().encode(`tls13 ${label}`);
1446
1297
  if (prefixed.length < 7 || prefixed.length > 255) throw new Error(`HkdfLabel label must be 7..255 octets, got ${prefixed.length}: "${label}"`);
1447
1298
  if (context.length > 255) throw new Error("HkdfLabel context is over 255 octets");
1448
1299
  return concat(Uint8Array.of(length >> 8, length & 255), Uint8Array.of(prefixed.length), prefixed, Uint8Array.of(context.length), context);
1449
1300
  };
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
- */
1301
+ /** `async` so a rejected label rejects the promise rather than throwing synchronously. */
1455
1302
  const hkdfExpandLabel = async (suite, secret, label, context, length) => hkdfExpand(suite, secret, hkdfLabel(label, context, length), length);
1456
1303
  /**
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`.
1304
+ * RFC 9846 §7.1 `Derive-Secret`, taking MESSAGES and hashing them as the RFC's signature does.
1305
+ * Pass nothing for the empty transcript; for an empty context (the `finished` key) use `hkdfExpandLabel`.
1466
1306
  */
1467
1307
  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
1308
  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
- */
1309
+ /** The three Extracts. `Derive-Secret(., "derived", "")` between them hashes the EMPTY transcript. */
1476
1310
  const earlySecret = (suite, psk) => hkdfExtract(suite, /* @__PURE__ */ new Uint8Array(0), psk ?? new Uint8Array(CIPHER_SUITES[suite].hashLength));
1477
1311
  const handshakeSecret = async (suite, early, sharedSecret) => hkdfExtract(suite, await deriveSecret(suite, early, "derived"), sharedSecret);
1478
1312
  const masterSecret = async (suite, handshake) => hkdfExtract(suite, await deriveSecret(suite, handshake, "derived"), new Uint8Array(CIPHER_SUITES[suite].hashLength));
1479
1313
  /**
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.
1314
+ * RFC 9846 §7.5 TLS-Exporter. The only caller that reaches `hkdfExpand`'s multi-block loop, since
1315
+ * BoGo asks for 1024 octets.
1501
1316
  */
1502
1317
  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. */
1318
+ /** RFC 9846 §7.3. */
1504
1319
  const trafficKeys = async (suite, secret) => {
1505
1320
  const { keyLength, ivLength } = CIPHER_SUITES[suite];
1506
1321
  const [key, iv] = await Promise.all([hkdfExpandLabel(suite, secret, "key", /* @__PURE__ */ new Uint8Array(0), keyLength), hkdfExpandLabel(suite, secret, "iv", /* @__PURE__ */ new Uint8Array(0), ivLength)]);
@@ -1509,25 +1324,16 @@ const trafficKeys = async (suite, secret) => {
1509
1324
  iv
1510
1325
  };
1511
1326
  };
1512
- /** RFC 9846 §4.5.3. The base key is the sender's handshake traffic secret. */
1327
+ /** RFC 9846 §4.5.3. */
1513
1328
  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
1329
  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
- */
1330
+ /** Through `subtle.verify`, which is constant-time. */
1521
1331
  const isVerifyDataValid = async (suite, key, transcript, received) => crypto.subtle.verify("HMAC", await crypto.subtle.importKey("raw", nonShared$3(key), {
1522
1332
  name: "HMAC",
1523
1333
  hash: CIPHER_SUITES[suite].hash
1524
1334
  }, false, ["verify"]), nonShared$3(received), nonShared$3(transcript));
1525
1335
  //#endregion
1526
1336
  //#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
1337
  const nonShared$2 = (bytes) => new Uint8Array(bytes);
1532
1338
  const toBase64Url = (bytes) => {
1533
1339
  let binary = "";
@@ -1650,7 +1456,6 @@ const bigIntToBytes$1 = (value, byteLength) => {
1650
1456
  }
1651
1457
  return bytes;
1652
1458
  };
1653
- /** The generation parameters per group, so the switch below has one arm to read. */
1654
1459
  const GENERATION_ALGORITHMS = {
1655
1460
  x25519: { name: "X25519" },
1656
1461
  secp256r1: {
@@ -1683,7 +1488,8 @@ const importPrivateShare = async (group, privateKeyBytes) => {
1683
1488
  };
1684
1489
  }
1685
1490
  case "secp256r1": {
1686
- const [x, y] = ecScalarMult(bytesToBigInt(privateKeyBytes), P256.G, P256.p);
1491
+ const d = bytesToBigInt(privateKeyBytes);
1492
+ const [x, y] = ecScalarMult(d, P256.G, P256.p);
1687
1493
  const xBytes = bigIntToBytes$1(x, 32);
1688
1494
  const yBytes = bigIntToBytes$1(y, 32);
1689
1495
  const publicKey = concat$1(Uint8Array.of(4), xBytes, yBytes);
@@ -1704,7 +1510,8 @@ const importPrivateShare = async (group, privateKeyBytes) => {
1704
1510
  };
1705
1511
  }
1706
1512
  case "secp384r1": {
1707
- const [x, y] = ecScalarMult(bytesToBigInt(privateKeyBytes), P384.G, P384.p);
1513
+ const d = bytesToBigInt(privateKeyBytes);
1514
+ const [x, y] = ecScalarMult(d, P384.G, P384.p);
1708
1515
  const xBytes = bigIntToBytes$1(x, 48);
1709
1516
  const yBytes = bigIntToBytes$1(y, 48);
1710
1517
  const publicKey = concat$1(Uint8Array.of(4), xBytes, yBytes);
@@ -1762,48 +1569,15 @@ const deriveSharedSecret = async (group, privateKey, peerPublicKeyBytes) => {
1762
1569
  };
1763
1570
  //#endregion
1764
1571
  //#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
- */
1572
+ /** RFC 7469 §2.1.1: base64 of SHA-256 over the SPKI DER. A string, so it survives the caller's JSON store. */
1776
1573
  const publicKeyPin = async (subjectPublicKeyInfoDer) => {
1777
1574
  const digest = await crypto.subtle.digest("SHA-256", new Uint8Array(subjectPublicKeyInfoDer));
1778
1575
  return btoa(String.fromCharCode(...new Uint8Array(digest)));
1779
1576
  };
1780
1577
  /**
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.
1578
+ * The inner validator runs first and its failure is returned untouched. A mismatch is
1579
+ * `rejected-by-policy`: the chain is fine. `pin` is required; a caller without one passes the
1580
+ * unwrapped validator. One instance per connection.
1807
1581
  */
1808
1582
  const pinnedValidator = ({ validator, pin }) => ({
1809
1583
  name: `${validator.name}+spki-pin`,
@@ -1819,17 +1593,10 @@ const pinnedValidator = ({ validator, pin }) => ({
1819
1593
  });
1820
1594
  //#endregion
1821
1595
  //#region src/record.ts
1822
- /**
1823
- * TLS 1.3 Record Layer (RFC 9846 §5): plaintext and AEAD framing, sealing, and opening.
1824
- */
1825
1596
  const nonShared$1 = (bytes) => new Uint8Array(bytes);
1826
1597
  /** 2^14 of content, plus the one byte naming its type (RFC 9846 §5.2). */
1827
1598
  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
- */
1599
+ /** RFC 9846 §5.1. */
1833
1600
  const MAX_RECORD_PLAINTEXT = 16384;
1834
1601
  const sealPlain = (type, payload, legacyVersion = LEGACY_RECORD_VERSION.STANDARD) => {
1835
1602
  if (payload.length > 16384) throw new Error("record_overflow");
@@ -1896,12 +1663,13 @@ const sealAead = async (key, iv, seq, type, payload, paddingZeros = 0) => {
1896
1663
  const aad = Uint8Array.of(CONTENT_TYPES.application_data, 3, 3, ciphertextLength >> 8 & 255, ciphertextLength & 255);
1897
1664
  const nonce = buildNonce(iv, seq);
1898
1665
  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({
1666
+ const ciphertextWithTag = new Uint8Array(await crypto.subtle.encrypt({
1900
1667
  name: "AES-GCM",
1901
1668
  iv: nonce,
1902
1669
  additionalData: aad,
1903
1670
  tagLength: 128
1904
- }, cryptoKey, nonShared$1(innerPlaintext))));
1671
+ }, cryptoKey, nonShared$1(innerPlaintext)));
1672
+ return concat$1(aad, ciphertextWithTag);
1905
1673
  };
1906
1674
  const openAead = async (key, iv, seq, record) => {
1907
1675
  if (record.length < 5) return {
@@ -1921,14 +1689,6 @@ const openAead = async (key, iv, seq, record) => {
1921
1689
  ok: false,
1922
1690
  description: "decode_error"
1923
1691
  };
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
1692
  if ((v0 << 8 | v1) !== LEGACY_RECORD_VERSION.STANDARD) return {
1933
1693
  ok: false,
1934
1694
  description: "decode_error"
@@ -1964,12 +1724,6 @@ const openAead = async (key, iv, seq, record) => {
1964
1724
  description: "bad_record_mac"
1965
1725
  };
1966
1726
  }
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
1727
  if (innerPlaintext.length > MAX_INNER_PLAINTEXT) return {
1974
1728
  ok: false,
1975
1729
  description: "record_overflow"
@@ -2052,86 +1806,14 @@ var RecordReader = class {
2052
1806
  };
2053
1807
  //#endregion
2054
1808
  //#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
- */
1809
+ /** RFC 9846 §4.7.1: 7 days, applied on the way in rather than as an alert. */
2085
1810
  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
- */
1811
+ /** RFC 9846 §4.7.1's recommended limit on renewed keying material. v1 checks no revocation, so a week. */
2098
1812
  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
- */
1813
+ /** A ticket must ride back inside a uint16 extension block. Real tickets are a few hundred octets. */
2115
1814
  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
- */
1815
+ /** RFC 9846 §4.7.1's `PSK`, or `undefined` for a ticket that could never be used. */
2126
1816
  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
1817
  if (ticketLifetime === 0 || ticket.length > MAX_TICKET_BYTES || !isWithinAuthenticationCeiling(authenticatedAt, receivedAt)) return;
2136
1818
  return {
2137
1819
  serverName,
@@ -2147,70 +1829,23 @@ const sessionFromTicket = async ({ serverName, expectedPeerName, suite, resumpti
2147
1829
  preSharedKey: await hkdfExpandLabel(suite, resumptionSecret, "resumption", ticketNonce, CIPHER_SUITES[suite].hashLength)
2148
1830
  };
2149
1831
  };
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
- */
1832
+ /** `| undefined`: a rehydrated value wearing a type. */
2155
1833
  const isDerChain = (chain) => chain?.leafDer instanceof Uint8Array && Array.isArray(chain.intermediateDer) && chain.intermediateDer.every((der) => der instanceof Uint8Array);
2156
1834
  /**
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.
1835
+ * A revived session's type is a claim: JSON turns a scheme into an arbitrary string and a byte
1836
+ * array into an object. Called from `startTls` before a byte goes out; throws because a broken
1837
+ * store is the caller's bug, not the peer's.
2179
1838
  */
2180
1839
  const assertUsableSession = (session) => {
2181
1840
  if (!SUPPORTED_SIGNATURE_SCHEMES.includes(session.peerSignatureScheme)) throw new Error("the stored session names no signature scheme this client implements");
2182
1841
  if (!isDerChain(session.peerCertificateChain)) throw new Error("the stored session carries no usable peer certificate chain");
2183
1842
  };
2184
1843
  /**
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`.
1844
+ * A resumed handshake verifies no signature, so a ticket it mints inherits the original
1845
+ * authentication. A fresh instant here would defeat `MAX_AUTHENTICATION_AGE_SECONDS` silently.
2205
1846
  */
2206
1847
  const inheritedAuthentication = (resumed, verifiedNow) => {
2207
1848
  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
1849
  assertUsableSession(resumed);
2215
1850
  const { authenticatedAt, peerSignatureScheme, peerCertificateChain } = resumed;
2216
1851
  return {
@@ -2227,70 +1862,32 @@ const isWithinAuthenticationCeiling = (authenticatedAt, now) => {
2227
1862
  return age >= 0 && age < MAX_AUTHENTICATION_AGE_SECONDS * 1e3;
2228
1863
  };
2229
1864
  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
- */
1865
+ /** RFC 9846 §4.3.11.1: age in milliseconds plus the server's offset, modulo 2^32. */
2235
1866
  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
- */
1867
+ /** `asciiLower`, not `toLowerCase`: KELVIN SIGN folds to `k` under Unicode, which would widen a security check. */
2246
1868
  const isSamePeerName = (a, b) => {
2247
1869
  if (a === null || b === null) return a === b;
2248
1870
  if (a.kind !== b.kind) return false;
2249
1871
  return a.kind === "dns" ? asciiLower(a.value) === asciiLower(b.value) : a.value === b.value;
2250
1872
  };
2251
1873
  /**
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.
1874
+ * Identity first: a resumed handshake proves nothing about the peer. Then two clocks, the
1875
+ * ticket's own age and the age of the certificate check behind it. A negative age would encode
1876
+ * as a huge `obfuscated_ticket_age`.
2266
1877
  */
2267
1878
  const isSessionOfferable = (session, serverName, expectedPeerName, now) => {
2268
1879
  const age = ticketAgeMs(session, now);
2269
1880
  return asciiLower(session.serverName) === asciiLower(serverName) && isSamePeerName(session.expectedPeerName, expectedPeerName) && age >= 0 && age < session.lifetimeSeconds * 1e3 && isWithinAuthenticationCeiling(session.authenticatedAt, now);
2270
1881
  };
2271
1882
  /**
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.
1883
+ * RFC 9846 §4.3.11.2, over the transcript ending in a truncated ClientHello. After a
1884
+ * HelloRetryRequest that is three messages, hence the whole transcript rather than one.
2280
1885
  */
2281
1886
  const pskBinder = async (session, ...transcript) => {
2282
1887
  const binderKey = await deriveSecret(session.suite, await earlySecret(session.suite, session.preSharedKey), "res binder");
2283
1888
  return verifyData(session.suite, await finishedKey(session.suite, binderKey), await transcriptHash(session.suite, ...transcript));
2284
1889
  };
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
- */
1890
+ /** The binder covers the message it travels in, so the bytes exist first with the binder zeroed. */
2294
1891
  const bindClientHello = async (session, clientHello, precedingMessages) => {
2295
1892
  const binderLength = CIPHER_SUITES[session.suite].hashLength;
2296
1893
  const truncated = clientHello.subarray(0, clientHello.length - binderListLength(binderLength));
@@ -2301,9 +1898,6 @@ const bindClientHello = async (session, clientHello, precedingMessages) => {
2301
1898
  };
2302
1899
  //#endregion
2303
1900
  //#region src/transcript.ts
2304
- /**
2305
- * TLS 1.3 Running Transcript and Transcript Hash (RFC 9846 §4.1).
2306
- */
2307
1901
  var Transcript = class {
2308
1902
  suite;
2309
1903
  messages = [];
@@ -2314,11 +1908,7 @@ var Transcript = class {
2314
1908
  add(message) {
2315
1909
  this.messages.push(message);
2316
1910
  }
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
- */
1911
+ /** RFC 9846 §4.1: after a HelloRetryRequest, ClientHello1 becomes `message_hash || uint24(Hash.length) || Hash(ClientHello1)`. */
2322
1912
  async replaceClientHello1WithMessageHash() {
2323
1913
  if (this.hasReplacedClientHello1) throw new Error("replaceClientHello1WithMessageHash may be called only once");
2324
1914
  if (this.messages.length === 0) throw new Error("No ClientHello1 in transcript to replace");
@@ -2338,9 +1928,6 @@ var Transcript = class {
2338
1928
  };
2339
1929
  //#endregion
2340
1930
  //#region src/verify.ts
2341
- /**
2342
- * CertificateVerify signature verification and leaf public key importing (RFC 9846 §4.5.2).
2343
- */
2344
1931
  const nonShared = (bytes) => new Uint8Array(bytes);
2345
1932
  const parseOid = (bytes) => {
2346
1933
  if (bytes.length === 0) return "";
@@ -2400,7 +1987,9 @@ const derToP1363 = (derSignature, scalarLength) => {
2400
1987
  if (rNode === void 0 || sNode === void 0) throw new Error("ECDSA signature missing r or s");
2401
1988
  const rBigInt = decodeInteger(rNode);
2402
1989
  const sBigInt = decodeInteger(sNode);
2403
- return concat$1(bigIntToBytes(rBigInt, scalarLength), bigIntToBytes(sBigInt, scalarLength));
1990
+ const rBytes = bigIntToBytes(rBigInt, scalarLength);
1991
+ const sBytes = bigIntToBytes(sBigInt, scalarLength);
1992
+ return concat$1(rBytes, sBytes);
2404
1993
  };
2405
1994
  const importLeafKey = async (spkiDer, scheme, algorithmOid) => {
2406
1995
  const algorithm = getSpkiAlgorithm(spkiDer);
@@ -2409,12 +1998,6 @@ const importLeafKey = async (spkiDer, scheme, algorithmOid) => {
2409
1998
  ok: false,
2410
1999
  description: "bad_certificate"
2411
2000
  };
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
2001
  const requiredCurveOid = SCHEME_CURVE_OIDS[scheme];
2419
2002
  if (requiredCurveOid !== void 0 && algorithm !== void 0 && algorithm.curveOid !== requiredCurveOid) return {
2420
2003
  ok: false,
@@ -2506,7 +2089,9 @@ const importLeafKey = async (spkiDer, scheme, algorithmOid) => {
2506
2089
  const verifyCertificateVerify = async (options) => {
2507
2090
  const keyRes = await importLeafKey(options.spkiDer, options.scheme, options.algorithmOid);
2508
2091
  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);
2092
+ const prefix = (/* @__PURE__ */ new Uint8Array(64)).fill(32);
2093
+ const context = new TextEncoder().encode("TLS 1.3, server CertificateVerify");
2094
+ const signedData = concat$1(prefix, context, Uint8Array.of(0), options.transcriptHash);
2510
2095
  let isValid = false;
2511
2096
  try {
2512
2097
  switch (options.scheme) {
@@ -2566,68 +2151,22 @@ const verifyCertificateVerify = async (options) => {
2566
2151
  };
2567
2152
  //#endregion
2568
2153
  //#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
- */
2154
+ /** The uint24 allows 16 MB. BoGo's `LargeMessage` sends a 23 KB chain and expects it to arrive. */
2581
2155
  const MAX_HANDSHAKE_MESSAGE_BODY = 65536;
2582
2156
  /**
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.
2157
+ * TLS 1.3 bounds none of these; the numbers are BoringSSL's, which BoGo pins from both sides. They
2158
+ * count CONSECUTIVE occurrences and reset on delivered application data, so a long IMAP session
2159
+ * survives its 33rd rekey.
2600
2160
  */
2601
2161
  const MAX_CONSECUTIVE_EMPTY_RECORDS = 32;
2602
2162
  const MAX_WARNING_ALERTS = 4;
2603
2163
  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
- */
2164
+ /** BoGo pins none; each ticket costs an HKDF expansion and a call into the caller's store. Real servers send two. */
2614
2165
  const MAX_CONSECUTIVE_SESSION_TICKETS = 32;
2615
2166
  /**
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.
2167
+ * RFC 9846 §4.3: a response to an offer we never made is `unsupported_extension`; an offered one in
2168
+ * a message where it is not defined is `illegal_parameter`. Matched by type code, since every
2169
+ * unmodelled extension decodes alike. §4.3's table plus RFC 8449's `record_size_limit`.
2631
2170
  */
2632
2171
  const PERMITTED_IN_SERVER_HELLO = [
2633
2172
  41,
@@ -2649,49 +2188,15 @@ const PERMITTED_IN_ENCRYPTED_EXTENSIONS = [
2649
2188
  const PERMITTED_IN_CERTIFICATE_ENTRY = [5, 18];
2650
2189
  /** A HelloRetryRequest's cookie answers no offer — the server starts that one. */
2651
2190
  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
- */
2191
+ /** A cookie near its legal maximum makes a ClientHello the uint16 extension block cannot encode. Real cookies are tens of bytes. */
2668
2192
  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
- */
2193
+ /** RFC 9846 Table 1 gives HRR its own column: `pre_shared_key` is `CH, SH` only. */
2680
2194
  const PERMITTED_IN_HELLO_RETRY_REQUEST = [
2681
2195
  43,
2682
2196
  COOKIE,
2683
2197
  51
2684
2198
  ];
2685
2199
  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
2200
  const misplacedExtensionAlert = (extensions, offered, permitted) => {
2696
2201
  for (const extension of extensions) {
2697
2202
  const code = extensionTypeCode(extension);
@@ -2710,14 +2215,7 @@ const suiteFromCode = (code) => {
2710
2215
  if (code === 4865) return "TLS_AES_128_GCM_SHA256";
2711
2216
  if (code === 4866) return "TLS_AES_256_GCM_SHA384";
2712
2217
  };
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
- */
2218
+ /** The caller passes the instant: a retried ClientHello wants a fresh reading (RFC 9846 §4.2.2). */
2721
2219
  const pskOffer = (session, now) => session === void 0 ? void 0 : {
2722
2220
  identity: session.ticket,
2723
2221
  obfuscatedTicketAge: obfuscatedTicketAge(session, now),
@@ -2728,54 +2226,16 @@ const rebindTranscript = (suite, previous) => {
2728
2226
  for (const message of previous.getMessages()) next.add(message);
2729
2227
  return next;
2730
2228
  };
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
- */
2229
+ /** RFC 9846 §5.1 allows fragmenting, and a peer-sized ticket or cookie can overrun one record. */
2748
2230
  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
- */
2231
+ /** Exported for `replay.ts` only, which `index.test.ts` enforces. */
2755
2232
  const runHandshake = async (options, replay) => {
2756
2233
  const { transport, serverName } = options;
2757
2234
  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
2235
  const expectedPeerName = options.expectedPeerName !== void 0 ? options.expectedPeerName : {
2764
2236
  kind: "dns",
2765
2237
  value: serverName
2766
2238
  };
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
2239
  if (options.session !== void 0) assertUsableSession(options.session);
2780
2240
  let transcript = new Transcript();
2781
2241
  const reader = new RecordReader(() => transport.read());
@@ -2796,29 +2256,12 @@ const runHandshake = async (options, replay) => {
2796
2256
  };
2797
2257
  };
2798
2258
  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
2259
  let compatibilityCcsPending = false;
2811
2260
  const sendCompatibilityCcsIfPending = async () => {
2812
2261
  if (!compatibilityCcsPending) return;
2813
2262
  compatibilityCcsPending = false;
2814
2263
  await transport.write(sealPlain("change_cipher_spec", Uint8Array.of(1)));
2815
2264
  };
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
2265
  const writeToPeer = async (record) => {
2823
2266
  try {
2824
2267
  await transport.write(record);
@@ -2860,13 +2303,6 @@ const runHandshake = async (options, replay) => {
2860
2303
  }
2861
2304
  };
2862
2305
  };
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
2306
  const validatePeerChain = (chain) => options.validator.validatePath({
2871
2307
  peerCertificateDer: chain.leafDer,
2872
2308
  untrustedIntermediateDer: chain.intermediateDer,
@@ -2890,51 +2326,10 @@ const runHandshake = async (options, replay) => {
2890
2326
  }
2891
2327
  };
2892
2328
  };
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
2329
  const offeredGroups = [...options.supportedGroups ?? SUPPORTED_GROUPS];
2906
2330
  const firstOfferedGroup = offeredGroups[0];
2907
2331
  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
2332
  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
2333
  const offeredSchemes = [...options.signatureSchemes ?? SUPPORTED_SIGNATURE_SCHEMES];
2939
2334
  if (offeredSchemes.length === 0) throw new Error("signatureSchemes must offer at least one scheme");
2940
2335
  if (new Set(offeredSchemes).size !== offeredSchemes.length) throw new Error("signatureSchemes must not repeat a scheme");
@@ -2942,15 +2337,9 @@ const runHandshake = async (options, replay) => {
2942
2337
  let ch1MessageBytes;
2943
2338
  let sentSessionId;
2944
2339
  let ch1Random;
2945
- /** The group our CURRENT share is on — reassigned only by a HelloRetryRequest. */
2340
+ /** Reassigned only by a HelloRetryRequest. */
2946
2341
  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
- */
2342
+ /** The session the ClientHello on the wire carries; a HelloRetryRequest onto another hash unsets it. */
2954
2343
  let offeredSession;
2955
2344
  if (replay !== void 0) {
2956
2345
  const ch1 = replay.clientHelloMessages[0];
@@ -2963,18 +2352,6 @@ const runHandshake = async (options, replay) => {
2963
2352
  if (!decodedCh1.ok || decodedCh1.value.kind !== "client_hello") throw new Error("Malformed replay ClientHello1");
2964
2353
  sentSessionId = decodedCh1.value.legacySessionId;
2965
2354
  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
2355
  offeredSession = options.session !== void 0 && isSessionOfferable(options.session, serverName, expectedPeerName, now()) ? options.session : void 0;
2979
2356
  } else {
2980
2357
  effectiveGroupName = firstOfferedGroup;
@@ -2995,14 +2372,6 @@ const runHandshake = async (options, replay) => {
2995
2372
  });
2996
2373
  if (offeredSession !== void 0) ch1MessageBytes = await bindClientHello(offeredSession, ch1MessageBytes, []);
2997
2374
  }
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
2375
  const [offeredExtensionCodes, offeredGroupCodes] = (() => {
3007
2376
  const decoded = decodeHandshakeMessage(ch1MessageBytes);
3008
2377
  if (!decoded.ok || decoded.value.kind !== "client_hello") throw new Error("our own ClientHello does not decode");
@@ -3042,13 +2411,6 @@ const runHandshake = async (options, replay) => {
3042
2411
  if (record[0] === 23) return failAlert("unexpected_message");
3043
2412
  const plainRes = openPlain(record);
3044
2413
  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
2414
  if (plainRes.type === "alert") {
3053
2415
  const alertRes = decodeAlert(plainRes.payload);
3054
2416
  if (alertRes.ok) return {
@@ -3081,17 +2443,6 @@ const runHandshake = async (options, replay) => {
3081
2443
  if (!isSameBytes(msg.legacySessionIdEcho, sentSessionId)) return failAlert("illegal_parameter");
3082
2444
  const randomSuffix = msg.random.subarray(24);
3083
2445
  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
2446
  const versionsExt = msg.extensions.find((e) => e.kind === "supported_versions");
3096
2447
  const wrongVersionAlert = hasSeenHrr ? "illegal_parameter" : "protocol_version";
3097
2448
  if (versionsExt === void 0 || versionsExt.kind !== "supported_versions") return failAlert(wrongVersionAlert);
@@ -3107,44 +2458,12 @@ const runHandshake = async (options, replay) => {
3107
2458
  const cookieExt = msg.extensions.find((e) => e.kind === "cookie");
3108
2459
  if (isSameBytes(msg.random, HRR_MAGIC_RANDOM)) {
3109
2460
  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
2461
  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
2462
  if (handshakeBuffer.some((chunk) => chunk.length > 0)) return failAlert("unexpected_message");
3125
2463
  hasSeenHrr = true;
3126
2464
  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
2465
  if (offeredSession !== void 0 && CIPHER_SUITES[offeredSession.suite].hash !== CIPHER_SUITES[negotiatedSuite].hash) offeredSession = void 0;
3136
2466
  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
2467
  const selectedCode = keyShareExt.selectedGroup;
3149
2468
  const selectedGroup = selectedCode === void 0 ? void 0 : namedGroupFromCode(selectedCode);
3150
2469
  if (selectedCode === void 0 || !offeredGroupCodes.has(selectedCode) || selectedGroup === void 0 || selectedGroup === effectiveGroupName) return failAlert("illegal_parameter");
@@ -3176,13 +2495,6 @@ const runHandshake = async (options, replay) => {
3176
2495
  });
3177
2496
  if (offeredSession !== void 0) ch2MessageBytes = await bindClientHello(offeredSession, ch2MessageBytes, transcript.getMessages());
3178
2497
  }
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
2498
  await sendCompatibilityCcsIfPending();
3187
2499
  transcript.add(ch2MessageBytes);
3188
2500
  for (const record of clientHelloRecords(ch2MessageBytes, LEGACY_RECORD_VERSION.STANDARD)) if (!await writeToPeer(record)) return {
@@ -3191,18 +2503,6 @@ const runHandshake = async (options, replay) => {
3191
2503
  };
3192
2504
  continue;
3193
2505
  }
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
2506
  const serverPskExt = msg.extensions.find((e) => e.kind === "pre_shared_key");
3207
2507
  if (serverPskExt !== void 0) {
3208
2508
  if (offeredSession === void 0) return failAlert("unsupported_extension");
@@ -3210,20 +2510,6 @@ const runHandshake = async (options, replay) => {
3210
2510
  if (CIPHER_SUITES[offeredSession.suite].hash !== CIPHER_SUITES[selectedSuite].hash) return failAlert("illegal_parameter");
3211
2511
  resumedSession = offeredSession;
3212
2512
  }
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
2513
  if (keyShareExt === void 0) return failAlert(resumedSession !== void 0 ? "illegal_parameter" : "missing_extension");
3228
2514
  if (keyShareExt.serverShare === void 0) return failAlert("illegal_parameter");
3229
2515
  if (keyShareExt.serverShare.group !== NAMED_GROUPS[effectiveGroupName]) return failAlert("illegal_parameter");
@@ -3231,12 +2517,6 @@ const runHandshake = async (options, replay) => {
3231
2517
  realServerHelloBytes = shBytes;
3232
2518
  }
3233
2519
  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
2520
  if (handshakeBuffer.some((chunk) => chunk.length > 0)) return failAlert("unexpected_message");
3241
2521
  transcript.add(realServerHelloBytes);
3242
2522
  const shareLen = expectedKeyShareLength(effectiveGroupName);
@@ -3270,12 +2550,7 @@ const runHandshake = async (options, replay) => {
3270
2550
  let cvScheme;
3271
2551
  let cvSignature;
3272
2552
  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
- */
2553
+ /** Set by whichever path validation ran; `null` on a resumption that trusts what it stored. */
3279
2554
  let peerPublicKeyPin = null;
3280
2555
  let emptyRecordsSeen = 0;
3281
2556
  while (!finReceived) {
@@ -3339,17 +2614,6 @@ const runHandshake = async (options, replay) => {
3339
2614
  transcript.add(nextMsgBytes);
3340
2615
  continue;
3341
2616
  }
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
2617
  if (resumedSession === void 0 && !certReceived) {
3354
2618
  if (msg.kind === "certificate_request") {
3355
2619
  certRequestContext = msg.certificateRequestContext;
@@ -3372,28 +2636,6 @@ const runHandshake = async (options, replay) => {
3372
2636
  if (resumedSession === void 0 && !cvReceived) {
3373
2637
  if (msg.kind !== "certificate_verify") return failAlert("unexpected_message");
3374
2638
  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
2639
  const offeredScheme = signatureSchemeFromCode(msg.scheme);
3398
2640
  if (offeredScheme === void 0 || !offeredSchemes.includes(offeredScheme)) return failAlert("illegal_parameter");
3399
2641
  cvScheme = offeredScheme;
@@ -3419,22 +2661,6 @@ const runHandshake = async (options, replay) => {
3419
2661
  transcriptHash: transcriptHashAtCert
3420
2662
  });
3421
2663
  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
2664
  peerPublicKeyPin = await publicKeyPin(pathRes.path.leafSubjectPublicKeyInfoDer);
3439
2665
  transcript.add(nextMsgBytes);
3440
2666
  continue;
@@ -3449,19 +2675,6 @@ const runHandshake = async (options, replay) => {
3449
2675
  break;
3450
2676
  }
3451
2677
  }
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
2678
  if (resumedSession !== void 0 && (options.reverifyOnResume ?? true)) {
3466
2679
  const reverified = await validatePeerChain(resumedSession.peerCertificateChain);
3467
2680
  if (!reverified.ok) return failValidation(reverified.reason, "session-stored");
@@ -3470,12 +2683,6 @@ const runHandshake = async (options, replay) => {
3470
2683
  const master = await masterSecret(negotiatedSuite, hsSecret);
3471
2684
  const c_ap_traffic = await deriveSecret(negotiatedSuite, master, "c ap traffic", ...transcript.getMessages());
3472
2685
  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
2686
  const exporterMaster = await deriveSecret(negotiatedSuite, master, "exp master", ...transcript.getMessages());
3480
2687
  await sendCompatibilityCcsIfPending();
3481
2688
  if (certRequestContext !== null) {
@@ -3498,9 +2705,10 @@ const runHandshake = async (options, replay) => {
3498
2705
  };
3499
2706
  }
3500
2707
  const cFinKey = await finishedKey(negotiatedSuite, c_hs_traffic);
2708
+ const clientVerifyData = await verifyData(negotiatedSuite, cFinKey, await transcript.hash());
3501
2709
  const clientFinMsg = encodeHandshakeMessage({
3502
2710
  kind: "finished",
3503
- verifyData: await verifyData(negotiatedSuite, cFinKey, await transcript.hash())
2711
+ verifyData: clientVerifyData
3504
2712
  });
3505
2713
  transcript.add(clientFinMsg);
3506
2714
  const sealedFinRecord = await sealAead(clientHsKeys.key, clientHsKeys.iv, clientHsSeq, "handshake", clientFinMsg);
@@ -3514,22 +2722,7 @@ const runHandshake = async (options, replay) => {
3514
2722
  ok: false,
3515
2723
  reason: { kind: "truncated" }
3516
2724
  };
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
2725
  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
2726
  const { authenticatedAt, peerSignatureScheme, peerCertificateChain } = inheritedAuthentication(resumedSession, cvScheme === void 0 || leafDer === void 0 ? void 0 : {
3534
2727
  authenticatedAt: now(),
3535
2728
  peerSignatureScheme: cvScheme,
@@ -3544,12 +2737,6 @@ const runHandshake = async (options, replay) => {
3544
2737
  let serverAppKeys = await trafficKeys(negotiatedSuite, serverTrafficSecret);
3545
2738
  let clientAppSeq = 0n;
3546
2739
  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
2740
  let peerSentCloseNotify = false;
3554
2741
  let sentCloseNotify = false;
3555
2742
  clientWriteKeys = {
@@ -3570,11 +2757,6 @@ const runHandshake = async (options, replay) => {
3570
2757
  seq: clientAppSeq
3571
2758
  };
3572
2759
  };
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
2760
  const abortWith = async (description) => {
3579
2761
  await queueWrite(async () => {
3580
2762
  await sendFatalAlert(description);
@@ -3594,21 +2776,6 @@ const runHandshake = async (options, replay) => {
3594
2776
  let warningAlertsSeen = 0;
3595
2777
  let keyUpdatesSeen = 0;
3596
2778
  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
2779
  let keyUpdateResponseOwed = false;
3613
2780
  const payKeyUpdateResponse = async () => {
3614
2781
  if (!keyUpdateResponseOwed) return;
@@ -3743,23 +2910,6 @@ const runHandshake = async (options, replay) => {
3743
2910
  resumptionSecret,
3744
2911
  receivedAt: now(),
3745
2912
  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
2913
  peerSignatureScheme,
3764
2914
  peerCertificateChain,
3765
2915
  ticket: decoded.value.ticket,
@@ -3767,13 +2917,6 @@ const runHandshake = async (options, replay) => {
3767
2917
  ticketAgeAdd: decoded.value.ticketAgeAdd,
3768
2918
  ticketLifetime: decoded.value.ticketLifetime
3769
2919
  });
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
2920
  if (session !== void 0) try {
3778
2921
  await onSession(session);
3779
2922
  } catch {}
@@ -3853,11 +2996,6 @@ const runHandshake = async (options, replay) => {
3853
2996
  };
3854
2997
  return queueWrite(run);
3855
2998
  },
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
2999
  exportKeyingMaterial: (label, context, length) => exportKeyingMaterial(negotiatedSuite, exporterMaster, label, context, length)
3862
3000
  },
3863
3001
  negotiatedGroup: effectiveGroupName,