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