@yozz.app/tls 0.1.2 → 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.
- package/dist/index.d.mts +85 -371
- package/dist/index.mjs +285 -1147
- 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
|
-
*
|
|
55
|
-
*
|
|
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 =
|
|
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)
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
|
486
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
438
|
+
extensions.push({
|
|
439
|
+
kind: "server_name",
|
|
440
|
+
serverNames
|
|
441
|
+
});
|
|
497
442
|
}
|
|
498
|
-
|
|
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)
|
|
565
|
-
if (
|
|
566
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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)
|
|
673
|
-
if (
|
|
674
|
-
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
-
|
|
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
|
|
726
|
-
idOffset
|
|
727
|
-
if (idOffset +
|
|
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
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
idOffset
|
|
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
|
-
|
|
740
|
-
idOffset +=
|
|
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)
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
1458
|
-
*
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
*
|
|
1782
|
-
* is
|
|
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
|
-
|
|
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
|
-
*
|
|
2158
|
-
*
|
|
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
|
-
*
|
|
2186
|
-
*
|
|
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
|
-
*
|
|
2253
|
-
*
|
|
2254
|
-
*
|
|
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
|
|
2273
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
2584
|
-
*
|
|
2585
|
-
*
|
|
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
|
-
*
|
|
2617
|
-
*
|
|
2618
|
-
*
|
|
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
|
-
/**
|
|
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:
|
|
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,
|