tunnelfetch 1.0.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.
Files changed (96) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +617 -0
  3. package/README.zh-CN.md +470 -0
  4. package/package.json +74 -0
  5. package/src/client/cookies.js +429 -0
  6. package/src/client/decode.js +346 -0
  7. package/src/client/redirect.js +249 -0
  8. package/src/client.js +704 -0
  9. package/src/errors.js +181 -0
  10. package/src/http1/chunked.js +289 -0
  11. package/src/http1/index.js +10 -0
  12. package/src/http1/request.js +143 -0
  13. package/src/http1/response.js +493 -0
  14. package/src/http2/connection.js +1170 -0
  15. package/src/http2/constants.js +129 -0
  16. package/src/http2/frames.js +291 -0
  17. package/src/http2/hpack.js +420 -0
  18. package/src/http2/huffman.js +203 -0
  19. package/src/http2/index.js +21 -0
  20. package/src/index.js +46 -0
  21. package/src/pool.js +256 -0
  22. package/src/proxy/direct.js +62 -0
  23. package/src/proxy/http-connect.js +206 -0
  24. package/src/proxy/index.js +197 -0
  25. package/src/proxy/socks5.js +344 -0
  26. package/src/tls/aead.js +263 -0
  27. package/src/tls/connect.js +407 -0
  28. package/src/tls/constants.js +334 -0
  29. package/src/tls/extensions.js +376 -0
  30. package/src/tls/handshake-messages.js +901 -0
  31. package/src/tls/handshake.js +568 -0
  32. package/src/tls/handshake12.js +507 -0
  33. package/src/tls/index.js +44 -0
  34. package/src/tls/keyschedule.js +473 -0
  35. package/src/tls/record.js +872 -0
  36. package/src/tls/tickets.js +145 -0
  37. package/src/tls/transcript.js +101 -0
  38. package/src/tls/wire.js +224 -0
  39. package/src/transport.js +296 -0
  40. package/src/trust/der.js +551 -0
  41. package/src/trust/index.js +375 -0
  42. package/src/trust/name.js +235 -0
  43. package/src/trust/ocsp.js +759 -0
  44. package/src/trust/path.js +595 -0
  45. package/src/trust/roots.js +454 -0
  46. package/src/trust/x509.js +902 -0
  47. package/src/util/bytes.js +470 -0
  48. package/src/util/deadline.js +266 -0
  49. package/src/warmup-fixture.js +85 -0
  50. package/src/warmup.js +243 -0
  51. package/types/client/cookies.d.ts +159 -0
  52. package/types/client/decode.d.ts +54 -0
  53. package/types/client/redirect.d.ts +96 -0
  54. package/types/client.d.ts +323 -0
  55. package/types/errors.d.ts +141 -0
  56. package/types/http1/chunked.d.ts +48 -0
  57. package/types/http1/index.d.ts +3 -0
  58. package/types/http1/request.d.ts +44 -0
  59. package/types/http1/response.d.ts +183 -0
  60. package/types/http2/connection.d.ts +282 -0
  61. package/types/http2/constants.d.ts +95 -0
  62. package/types/http2/frames.d.ts +116 -0
  63. package/types/http2/hpack.d.ts +99 -0
  64. package/types/http2/huffman.d.ts +21 -0
  65. package/types/http2/index.d.ts +5 -0
  66. package/types/index.d.ts +17 -0
  67. package/types/pool.d.ts +135 -0
  68. package/types/proxy/direct.d.ts +26 -0
  69. package/types/proxy/http-connect.d.ts +37 -0
  70. package/types/proxy/index.d.ts +62 -0
  71. package/types/proxy/socks5.d.ts +47 -0
  72. package/types/tls/aead.d.ts +67 -0
  73. package/types/tls/connect.d.ts +280 -0
  74. package/types/tls/constants.d.ts +275 -0
  75. package/types/tls/extensions.d.ts +195 -0
  76. package/types/tls/handshake-messages.d.ts +430 -0
  77. package/types/tls/handshake.d.ts +90 -0
  78. package/types/tls/handshake12.d.ts +35 -0
  79. package/types/tls/index.d.ts +9 -0
  80. package/types/tls/keyschedule.d.ts +272 -0
  81. package/types/tls/record.d.ts +361 -0
  82. package/types/tls/tickets.d.ts +66 -0
  83. package/types/tls/transcript.d.ts +52 -0
  84. package/types/tls/wire.d.ts +106 -0
  85. package/types/transport.d.ts +222 -0
  86. package/types/trust/der.d.ts +239 -0
  87. package/types/trust/index.d.ts +194 -0
  88. package/types/trust/name.d.ts +33 -0
  89. package/types/trust/ocsp.d.ts +138 -0
  90. package/types/trust/path.d.ts +139 -0
  91. package/types/trust/roots.d.ts +36 -0
  92. package/types/trust/x509.d.ts +401 -0
  93. package/types/util/bytes.d.ts +183 -0
  94. package/types/util/deadline.d.ts +133 -0
  95. package/types/warmup-fixture.d.ts +11 -0
  96. package/types/warmup.d.ts +45 -0
@@ -0,0 +1,568 @@
1
+ // TLS 1.3 client handshake driver.
2
+ //
3
+ // This is the file that has to be right. On this runtime the platform will not verify a tunnelled
4
+ // peer for us — it checks the certificate against whatever hostname was passed to connect(), which
5
+ // inside a proxy tunnel is the proxy, so the platform's own answer is structurally the wrong
6
+ // question. Everything protecting the caller from a hostile proxy therefore happens here and in
7
+ // the trust layer, and nothing may reach the application before it has.
8
+ //
9
+ // Three ordering rules are load-bearing:
10
+ //
11
+ // 1. The certificate chain is validated, and CertificateVerify checked against the very key that
12
+ // validation blessed, BEFORE the client's Finished is sent and before any application byte
13
+ // moves in either direction. No code path returns a usable duplex otherwise.
14
+ // 2. Every message is folded into the transcript at the exact point RFC 8446 says. The
15
+ // transcript is what binds the certificate to this connection; an off-by-one-message
16
+ // transcript still looks like a working handshake against an honest server and provides no
17
+ // security at all against a dishonest one.
18
+ // 3. The transcript's hash is chosen by the cipher suite, which is not known until ServerHello.
19
+ // So ClientHello is held as raw bytes and the transcript is constructed once, with the right
20
+ // hash, rather than started under a guess and rebuilt — rebuilding cannot reproduce the
21
+ // HelloRetryRequest substitution, and a transcript that is subtly wrong only after an HRR is
22
+ // the kind of bug that survives every test against a server that never sends one.
23
+ //
24
+ // The ClientHello/ServerHello preamble that rules 3 rests on lives in connect.js, shared with the
25
+ // 1.2 driver so that one hello can offer both versions; continueTls13 below is everything that
26
+ // happens after the ServerHello routed the connection here. handshakeTls13 remains the
27
+ // single-version entry: it is connectTls with the offer pinned to [TLS 1.3].
28
+ //
29
+ // Verification is injected rather than imported so this module has no opinion about trust policy,
30
+ // and so the handshake can be exercised offline against a scripted peer.
31
+
32
+ import { TlsError, TlsUnsupportedError, codes, hex8, hex16 } from '../errors.js';
33
+ import { EXTENSION, HANDSHAKE_TYPE, SUPPORTED_GROUPS, TLS13 } from './constants.js';
34
+ import {
35
+ applicationTrafficSecrets,
36
+ deriveHandshakeSecret,
37
+ deriveMasterSecret,
38
+ earlySecret,
39
+ finishedVerifyData,
40
+ handshakeTrafficSecrets,
41
+ resumptionMasterSecret,
42
+ resumptionPsk,
43
+ } from './keyschedule.js';
44
+ import {
45
+ buildClientHello,
46
+ certificateVerifyContent,
47
+ checkAlpn,
48
+ checkFinished,
49
+ checkSessionIdEcho,
50
+ deriveSharedSecret,
51
+ generateKeyShare,
52
+ negotiateCipher,
53
+ negotiateVersion,
54
+ parseCertificate13,
55
+ parseCertificateVerify,
56
+ parseHelloRetryRequest,
57
+ parseNewSessionTicket,
58
+ parseServerHello,
59
+ selectServerKeyShare,
60
+ setPskBinder,
61
+ verifyHandshakeSignature,
62
+ } from './handshake-messages.js';
63
+ import {
64
+ decodeExtensionBlock,
65
+ decodeServerPreSharedKey,
66
+ describeVersion,
67
+ rejectUnofferedExtensions,
68
+ } from './extensions.js';
69
+ import { Builder, Cursor, handshakeMessage } from './wire.js';
70
+ import { connectTls } from './connect.js';
71
+
72
+ const HS_NAME = Object.fromEntries(Object.entries(HANDSHAKE_TYPE).map(([k, v]) => [v, k]));
73
+
74
+ /**
75
+ * Only one key share is offered by default. A second costs a key generation and 30-odd bytes for
76
+ * a group the server is unlikely to prefer; a HelloRetryRequest recovers the rare case at the
77
+ * cost of one round trip.
78
+ */
79
+ export const DEFAULT_OFFER_GROUPS = [SUPPORTED_GROUPS[0]];
80
+
81
+ const describeType = (t) => `${hex8(t)} (${HS_NAME[t] ?? 'unknown'})`;
82
+
83
+ /**
84
+ * The injected trust decision. Must throw to reject; resolves with the validated leaf, whose
85
+ * SPKI is the only key a driver will accept a handshake signature from. `details.ocspResponse`
86
+ * is the peer's stapled DER OCSPResponse when it sent one — delivered here, at the same moment
87
+ * as the chain, because revocation is part of deciding whether to believe the certificate and
88
+ * must be settled before anything of ours goes on the wire.
89
+ * @typedef {(chain: Uint8Array[], hostname: string,
90
+ * details?: { ocspResponse: Uint8Array | null })
91
+ * => Promise<{ spki: { spkiDer: Uint8Array } }>} VerifyPeer
92
+ */
93
+
94
+ /**
95
+ * Driver context assembled by connect.js after the ServerHello routed the connection: the
96
+ * record layer, the transcript (created under the negotiated suite's hash, ClientHello already
97
+ * folded in), the ClientHello metadata, the parsed ServerHello with its raw bytes, and the
98
+ * offer that produced them. Both continue* drivers consume exactly this shape.
99
+ * @typedef {object} HandshakeContext
100
+ * @property {import('./record.js').RecordLayer} record
101
+ * @property {import('./transcript.js').Transcript} transcript
102
+ * @property {import('./handshake-messages.js').ClientHello} hello
103
+ * @property {import('./handshake-messages.js').ServerHello} serverHello
104
+ * @property {Uint8Array} rawServerHello
105
+ * @property {number} suite
106
+ * @property {import('./constants.js').CipherParams} params
107
+ * @property {string} hostname
108
+ * @property {VerifyPeer} verifyPeer
109
+ * @property {import('./connect.js').TlsOptions} options
110
+ * @property {import('./connect.js').TlsDeps} deps
111
+ * @property {{ versions: number[], ciphers: number[], groups: number[], offerGroups: number[],
112
+ * alpn: string[], keyShares: import('./handshake-messages.js').KeyShare[],
113
+ * psk: OfferedPsk | null }} offer
114
+ */
115
+
116
+ /**
117
+ * The resumption PSK as prepared by connect.js: the caller's offer plus the secrets derived
118
+ * from it once (Early Secret, binder key) so neither hello build nor acceptance re-derives.
119
+ * @typedef {object} OfferedPsk
120
+ * @property {Uint8Array} identity
121
+ * @property {Uint8Array} psk
122
+ * @property {import('./keyschedule.js').ScheduleHash} hash
123
+ * @property {() => number} obfuscatedTicketAge
124
+ * @property {object | null} peer
125
+ * @property {Uint8Array} earlySecret
126
+ * @property {Uint8Array} binderKey
127
+ * @property {number} binderLen
128
+ */
129
+
130
+ /** Demand a specific handshake message, turning every other outcome into a named failure. */
131
+ function expect(msg, type, where) {
132
+ if (msg === null) {
133
+ throw new TlsError(
134
+ codes.TLS_TRUNCATED,
135
+ `server closed the connection during the handshake while ${where} was expected`,
136
+ { expected: HS_NAME[type] ?? type },
137
+ );
138
+ }
139
+ if (msg.ccs) {
140
+ throw new TlsError(codes.TLS_RECORD, `change_cipher_spec arrived where ${where} was expected`, {
141
+ expected: HS_NAME[type] ?? type,
142
+ });
143
+ }
144
+ if (msg.type !== type) {
145
+ throw new TlsError(
146
+ codes.TLS_HANDSHAKE,
147
+ `server sent handshake type ${describeType(msg.type)} where ${where} was expected`,
148
+ { got: msg.type, expected: type },
149
+ );
150
+ }
151
+ return msg;
152
+ }
153
+
154
+ /**
155
+ * Run a TLS 1.3 handshake over a byte duplex and return the plaintext duplex above it.
156
+ *
157
+ * @param {object} args
158
+ * @param {import('./connect.js').ByteDuplex} args.transport
159
+ * @param {string} args.hostname the identity the certificate must prove, and the SNI sent
160
+ * @param {VerifyPeer} args.verifyPeer
161
+ * Must throw to reject. Resolves with the validated leaf; its SPKI is the only key this
162
+ * handshake will accept a CertificateVerify signature from.
163
+ * @param {import('./connect.js').TlsOptions} [args.options] `versions` is ignored: this entry
164
+ * pins the offer to [TLS 1.3]
165
+ * @param {import('./connect.js').TlsDeps} [args.deps]
166
+ * @returns {Promise<import('./connect.js').TlsSession>}
167
+ */
168
+ export async function handshakeTls13({ transport, hostname, verifyPeer, options = {}, deps = {} }) {
169
+ if (typeof verifyPeer !== 'function') {
170
+ // Refusing to start is the only safe default. A missing verifier must never read as "skip".
171
+ throw new TlsError(
172
+ codes.CONFIG_INVALID,
173
+ 'handshakeTls13 requires a verifyPeer function; there is no unverified mode',
174
+ );
175
+ }
176
+ // The offer is pinned to [TLS 1.3] no matter what options.versions says: callers of THIS
177
+ // function chose the version by choosing the function, and a widened offer sneaking in through
178
+ // options would silently change which downgrade guards apply.
179
+ return connectTls({
180
+ transport,
181
+ hostname,
182
+ verifyPeer,
183
+ deps,
184
+ options: { ...options, versions: [TLS13] },
185
+ });
186
+ }
187
+
188
+ /**
189
+ * Continue a TLS 1.3 handshake from the first ServerHello (which may be a HelloRetryRequest).
190
+ * Called by connect.js once negotiation routed the connection here.
191
+ * @param {HandshakeContext} ctx
192
+ * @returns {Promise<import('./connect.js').TlsSession>}
193
+ */
194
+ export async function continueTls13(ctx) {
195
+ const { record, transcript, hostname, verifyPeer, options, deps } = ctx;
196
+ const { versions, ciphers, groups, offerGroups, alpn } = ctx.offer;
197
+ let { hello, serverHello: sh, rawServerHello, suite, params } = ctx;
198
+ let { keyShares } = ctx.offer;
199
+ let hash = params.hash;
200
+ // The PSK the CURRENT hello carries. Starts as what connect.js offered in ClientHello1 and
201
+ // can only narrow: a HelloRetryRequest that pins a suite of a different hash removes it from
202
+ // ClientHello2 (s4.1.4), and every acceptance check below runs against this variable — never
203
+ // against the original offer — so a server cannot select what the live hello does not carry.
204
+ let offeredPsk = ctx.offer.psk ?? null;
205
+
206
+ // --- HelloRetryRequest ---------------------------------------------------------------------
207
+ if (sh.isHelloRetryRequest) {
208
+ const { group, cookie } = parseHelloRetryRequest(sh, { offeredGroups: groups });
209
+ if (offerGroups.includes(group)) {
210
+ throw new TlsError(
211
+ codes.TLS_HANDSHAKE,
212
+ `HelloRetryRequest demanded group ${hex16(group)}, for which a key share was already ` +
213
+ 'sent; the server is looping',
214
+ { group },
215
+ );
216
+ }
217
+ checkSessionIdEcho(sh, hello.legacySessionId);
218
+
219
+ await transcript.replaceWithMessageHash();
220
+ transcript.update(rawServerHello);
221
+
222
+ // s4.1.4: the second hello SHOULD NOT offer a PSK whose hash differs from the suite the
223
+ // HelloRetryRequest just pinned — the server could never legally select it, and computing
224
+ // its binder would need a second transcript under the other hash. Dropping it here is what
225
+ // makes a later pre_shared_key in the real ServerHello provably a violation.
226
+ if (offeredPsk && offeredPsk.hash !== params.hash) offeredPsk = null;
227
+
228
+ keyShares = [await generateKeyShare(group, deps)];
229
+ // RFC 8446 s4.1.2: ClientHello2 reuses the random and the legacy session id verbatim — and
230
+ // everything else about the offer, including the full VERSION list. If both versions were
231
+ // offered, ClientHello2 offers both again; changing the extension set between hellos is not
232
+ // among the modifications s4.1.2 permits (recomputing obfuscated_ticket_age and the binder
233
+ // IS: s4.1.2 lists "pre_shared_key" as one of the fields a second hello updates), and a
234
+ // strict server checks.
235
+ hello = buildClientHello({
236
+ hostname,
237
+ keyShares,
238
+ groups,
239
+ alpn,
240
+ ciphers,
241
+ versions,
242
+ random: hello.clientRandom,
243
+ legacySessionId: hello.legacySessionId,
244
+ extraExtensions: cookie ? [cookieExtension(cookie)] : [],
245
+ psk: offeredPsk && {
246
+ identity: offeredPsk.identity,
247
+ obfuscatedTicketAge: offeredPsk.obfuscatedTicketAge(),
248
+ binderLen: offeredPsk.binderLen,
249
+ },
250
+ randomBytes: deps.randomBytes,
251
+ });
252
+ if (offeredPsk) {
253
+ // The ClientHello2 binder covers Transcript-Hash(message_hash(CH1) || HRR ||
254
+ // Truncate(CH2)) (s4.2.11.2). The transcript object holds exactly the first two at this
255
+ // point, and hashWith appends the truncation without ever folding it in — the transcript
256
+ // proper receives the full patched hello below. The suite's hash and the PSK's hash are
257
+ // equal here by the drop above, so one digest serves both bookkeepings.
258
+ const truncatedHash = await transcript.hashWith(
259
+ hello.message.subarray(0, hello.truncatedLength));
260
+ setPskBinder(hello,
261
+ await finishedVerifyData(offeredPsk.hash, offeredPsk.binderKey, truncatedHash));
262
+ }
263
+ transcript.update(hello.message);
264
+ await record.writeHandshake([hello.message]);
265
+
266
+ const second = expect(
267
+ await record.nextHandshakeMessage(),
268
+ HANDSHAKE_TYPE.server_hello,
269
+ 'ServerHello',
270
+ );
271
+ rawServerHello = second.raw;
272
+ sh = parseServerHello(second.body);
273
+ if (sh.isHelloRetryRequest) {
274
+ throw new TlsError(codes.TLS_HANDSHAKE, 'server sent a second HelloRetryRequest');
275
+ }
276
+ // RFC 8446 s4.1.4: the real ServerHello must keep the suite the HelloRetryRequest named,
277
+ // otherwise the transcript hash we already committed to would be the wrong one.
278
+ const again = negotiateCipher(sh, { offeredCiphers: ciphers, version: TLS13 });
279
+ if (again.suite !== suite) {
280
+ throw new TlsError(
281
+ codes.TLS_CIPHER_UNSUPPORTED,
282
+ `ServerHello selected ${hex16(again.suite)} after HelloRetryRequest selected ${hex16(suite)}`,
283
+ { hrr: suite, serverHello: again.suite },
284
+ );
285
+ }
286
+ ({ suite, params } = again);
287
+ hash = params.hash;
288
+
289
+ // The HelloRetryRequest pinned this connection to 1.3, but the real ServerHello must still
290
+ // say so itself. negotiateVersion runs with the full offered list so the downgrade guards
291
+ // stay live; when 1.2 was also offered it can legitimately RETURN TLS 1.2 for a hello with
292
+ // no supported_versions, and that answer — a version change across the retry — is a splice.
293
+ const version = negotiateVersion(sh, { offeredVersions: versions });
294
+ if (version !== TLS13) {
295
+ throw new TlsUnsupportedError(
296
+ codes.TLS_VERSION_UNSUPPORTED,
297
+ `server negotiated ${describeVersion(version)} in the ServerHello after its ` +
298
+ 'HelloRetryRequest; a HelloRetryRequest exists only in TLS 1.3, so the connection ' +
299
+ 'cannot continue at any other version',
300
+ { version },
301
+ );
302
+ }
303
+ }
304
+
305
+ // --- negotiation ---------------------------------------------------------------------------
306
+ // The non-HRR ServerHello already went through negotiateVersion in connect.js with the full
307
+ // offered list — that is what routed the connection here.
308
+ checkSessionIdEcho(sh, hello.legacySessionId);
309
+ transcript.update(rawServerHello);
310
+
311
+ // Did the server take the PSK? Every check is against the CURRENT hello's offer (s4.2.11:
312
+ // "Clients MUST verify that the server's selected_identity is within the range supplied by
313
+ // the client [and] that the server selected a cipher suite indicating a Hash associated with
314
+ // the PSK"). Fail closed on each: continuing after any of these means client and server
315
+ // disagree about which secret protects the connection.
316
+ let acceptedPsk = null;
317
+ const pskExt = sh.extensions.get(EXTENSION.pre_shared_key);
318
+ if (pskExt !== undefined) {
319
+ if (!offeredPsk) {
320
+ throw new TlsError(
321
+ codes.TLS_PSK,
322
+ 'ServerHello selected a pre-shared key, but the ClientHello it answers offered none',
323
+ );
324
+ }
325
+ const selected = decodeServerPreSharedKey(pskExt);
326
+ if (selected !== 0) {
327
+ // Exactly one identity is ever offered, so the only selectable index is 0.
328
+ throw new TlsError(
329
+ codes.TLS_PSK,
330
+ `ServerHello selected pre-shared key identity ${selected}, but only one identity ` +
331
+ '(index 0) was offered',
332
+ { selected },
333
+ );
334
+ }
335
+ if (params.hash !== offeredPsk.hash) {
336
+ throw new TlsError(
337
+ codes.TLS_PSK,
338
+ `ServerHello accepted the offered PSK but selected cipher suite ${hex16(suite)} ` +
339
+ `(${params.hash}), while the PSK was minted under ${offeredPsk.hash}; a PSK may only ` +
340
+ 'be used with the hash it was derived for (RFC 8446 s4.2.11)',
341
+ { suite, suiteHash: params.hash, pskHash: offeredPsk.hash },
342
+ );
343
+ }
344
+ acceptedPsk = offeredPsk;
345
+ }
346
+ // No pre_shared_key in the ServerHello means the server declined: the FULL handshake — with
347
+ // Certificate, CertificateVerify, and chain validation — continues on this same connection.
348
+ // No reconnect, no re-offer, no downgrade dance; declining costs nothing but the bytes.
349
+
350
+ // Only psk_dhe_ke is ever offered (s4.2.9), so a key exchange happens whether or not the PSK
351
+ // was taken; selectServerKeyShare fails closed on a ServerHello without key_share.
352
+ const server = selectServerKeyShare(sh, keyShares);
353
+ const shared = await deriveSharedSecret(server.group, server.privateKey, server.keyExchange);
354
+
355
+ // s7.1: with a PSK in use the Early Secret is extracted from it; otherwise from zeros. The
356
+ // accepted offer already carries that extraction (connect.js derived it for the binder), and
357
+ // its hash equals the negotiated hash by the acceptance check above.
358
+ const early = acceptedPsk ? acceptedPsk.earlySecret : await earlySecret(hash);
359
+ const handshakeSecret = await deriveHandshakeSecret(hash, early, shared);
360
+ const hsSecrets = await handshakeTrafficSecrets(hash, handshakeSecret, await transcript.hash());
361
+
362
+ // From here the server speaks encrypted. Our send direction stays plaintext until the client
363
+ // Finished, which is why the send key is installed later rather than here.
364
+ await record.setReceiveKeys({ cipher: suite, secret: hsSecrets.server });
365
+
366
+ // --- server flight -------------------------------------------------------------------------
367
+ const ee = expect(
368
+ await record.nextHandshakeMessage(),
369
+ HANDSHAKE_TYPE.encrypted_extensions,
370
+ 'EncryptedExtensions',
371
+ );
372
+ transcript.update(ee.raw);
373
+ const eeCursor = new Cursor(ee.body, 'EncryptedExtensions');
374
+ const eeExts = decodeExtensionBlock(eeCursor.vector(2, 'extensions'), 'EncryptedExtensions');
375
+ eeCursor.end('EncryptedExtensions');
376
+ rejectUnofferedExtensions(eeExts, hello.offeredExtensions, 'EncryptedExtensions');
377
+ if (eeExts.has(EXTENSION.status_request)) {
378
+ // We DID offer status_request, so the unoffered-extension check above cannot catch this —
379
+ // but RFC 8446 s4.2 places the server's answer in the leaf's CertificateEntry, never in
380
+ // EncryptedExtensions, and an extension in a message it is not specified for is a fatal
381
+ // illegal_parameter, not a tolerable relocation. Accepting a staple from here would also
382
+ // move it outside the certificate it is supposed to be bound to.
383
+ throw new TlsError(
384
+ codes.TLS_HANDSHAKE,
385
+ 'server sent status_request in EncryptedExtensions; in TLS 1.3 a stapled certificate ' +
386
+ "status belongs in the leaf's CertificateEntry (RFC 8446 s4.4.2.1)",
387
+ { extension: EXTENSION.status_request },
388
+ );
389
+ }
390
+ const alpnProtocol = checkAlpn(eeExts, alpn, 'EncryptedExtensions');
391
+
392
+ let next = await record.nextHandshakeMessage();
393
+ let certificateRequested = false;
394
+ let peer;
395
+ if (acceptedPsk) {
396
+ // Resumed: the server authenticates by proving it holds the PSK — its Finished MAC, under
397
+ // keys extracted from that PSK, is the proof — and s4.3.2 forbids it from sending
398
+ // CertificateRequest (and s2.2 from re-authenticating with a certificate) in this handshake.
399
+ // A server that sends either after selecting the PSK is not confused about framing, it is
400
+ // violating the PSK contract, so the error names that rather than a generic wrong-type.
401
+ if (next && !next.ccs &&
402
+ (next.type === HANDSHAKE_TYPE.certificate ||
403
+ next.type === HANDSHAKE_TYPE.certificate_request)) {
404
+ throw new TlsError(
405
+ codes.TLS_PSK,
406
+ `server resumed with the offered pre-shared key but then sent ${describeType(next.type)}; ` +
407
+ 'a PSK handshake must not carry certificate messages (RFC 8446 s2.2, s4.3.2)',
408
+ { type: next.type },
409
+ );
410
+ }
411
+ // The certificate validated in the ORIGINAL handshake vouches for this connection, exactly
412
+ // as far as the ticket store's keying lets it: the store binds tickets to the full trust
413
+ // configuration, so this peer was validated under the same policy this caller asked for.
414
+ peer = acceptedPsk.peer;
415
+ } else {
416
+ if (next && !next.ccs && next.type === HANDSHAKE_TYPE.certificate_request) {
417
+ // We hold no client certificate, but the protocol still demands an answer: an empty
418
+ // Certificate message and no CertificateVerify (RFC 8446 s4.4.2).
419
+ certificateRequested = true;
420
+ transcript.update(next.raw);
421
+ next = await record.nextHandshakeMessage();
422
+ }
423
+
424
+ const certMsg = expect(next, HANDSHAKE_TYPE.certificate, 'Certificate');
425
+ transcript.update(certMsg.raw);
426
+ // The leaf's CertificateEntry may carry a stapled OCSP response (RFC 8446 s4.4.2.1); it rides
427
+ // to the trust layer alongside the chain and is judged there, under the same fail-closed rules.
428
+ const { chain, ocspResponse } = parseCertificate13(certMsg.body, {
429
+ offeredExtensions: hello.offeredExtensions,
430
+ });
431
+ const transcriptThroughCertificate = await transcript.hash();
432
+
433
+ const cv = expect(
434
+ await record.nextHandshakeMessage(),
435
+ HANDSHAKE_TYPE.certificate_verify,
436
+ 'CertificateVerify',
437
+ );
438
+ const { algorithm, signature } = parseCertificateVerify(cv.body);
439
+ transcript.update(cv.raw);
440
+
441
+ // Trust first. The signature check below is only meaningful once the key performing it has been
442
+ // tied by the trust layer to a chain we accept for this hostname; done the other way round it
443
+ // merely proves that whoever holds the socket also holds a key, which is no evidence at all.
444
+ peer = await verifyPeer(chain, hostname, { ocspResponse });
445
+ const spki = peer?.spki?.spkiDer;
446
+ if (!spki) {
447
+ throw new TlsError(
448
+ codes.CONFIG_INVALID,
449
+ 'verifyPeer must resolve with the validated leaf certificate, including spki.spkiDer',
450
+ );
451
+ }
452
+ await verifyHandshakeSignature({
453
+ scheme: algorithm,
454
+ spki,
455
+ signature,
456
+ content: certificateVerifyContent(transcriptThroughCertificate, true),
457
+ });
458
+
459
+ next = await record.nextHandshakeMessage();
460
+ }
461
+
462
+ const sf = expect(next, HANDSHAKE_TYPE.finished, 'server Finished');
463
+ const expectedFinished = await finishedVerifyData(hash, hsSecrets.server, await transcript.hash());
464
+ checkFinished(sf.body, expectedFinished);
465
+ transcript.update(sf.raw);
466
+
467
+ // Application secrets are bound to the transcript through the server Finished (RFC 8446 s7.1),
468
+ // so they are derived here, before anything the client sends is folded in.
469
+ const masterSecret = await deriveMasterSecret(hash, handshakeSecret);
470
+ // No exporter interface is exposed, so exporter_master_secret would be dead work every handshake.
471
+ const appSecrets = await applicationTrafficSecrets(hash, masterSecret, await transcript.hash(),
472
+ { exporter: false });
473
+
474
+ // --- client flight -------------------------------------------------------------------------
475
+ if (options.compatibilityCcs !== false) await record.writeChangeCipherSpec();
476
+ await record.setSendKeys({ cipher: suite, secret: hsSecrets.client });
477
+
478
+ const clientFlight = [];
479
+ if (certificateRequested) {
480
+ const empty = handshakeMessage(
481
+ HANDSHAKE_TYPE.certificate,
482
+ new Builder().vector(1, new Uint8Array(0)).vector(3, new Uint8Array(0)).build(),
483
+ );
484
+ clientFlight.push(empty);
485
+ transcript.update(empty);
486
+ }
487
+ const clientFinished = handshakeMessage(
488
+ HANDSHAKE_TYPE.finished,
489
+ await finishedVerifyData(hash, hsSecrets.client, await transcript.hash()),
490
+ );
491
+ clientFlight.push(clientFinished);
492
+ // Our own Finished joins the transcript too: resumption_master_secret is derived from the
493
+ // transcript THROUGH the client Finished (s7.1), so without this line every PSK minted from a
494
+ // ticket would be wrong — and wrong in a way a loopback test with the same omission on both
495
+ // sides would never notice.
496
+ transcript.update(clientFinished);
497
+ await record.writeHandshake(clientFlight);
498
+
499
+ await record.setSendKeys({ cipher: suite, secret: appSecrets.client });
500
+ await record.setReceiveKeys({ cipher: suite, secret: appSecrets.server });
501
+ record.markHandshakeComplete();
502
+
503
+ if (options.onSessionTicket) {
504
+ // NewSessionTicket arrives under application keys at the server's leisure; the record layer
505
+ // surfaces it here. Everything is derived lazily on the first ticket so a connection whose
506
+ // server never sends one pays nothing, and resumption_master_secret is computed once then
507
+ // reused — one server flight routinely carries two tickets. Parse or derivation failures
508
+ // propagate out of the read path and fail the connection: a peer whose post-handshake
509
+ // messages are malformed does not get to keep talking (same stance as KeyUpdate).
510
+ //
511
+ // 0-RTT, stated as a decision and not an omission: a ticket may advertise early_data, and
512
+ // this client records but never uses it. Early data is replayable by design — an attacker
513
+ // who captures the flight can replay it, and the server may accept both copies — which is
514
+ // unsafe for exactly the requests a proxy client carries (a caller's POST must not be
515
+ // executable twice by a third party). No option enables it.
516
+ let resMaster = null;
517
+ record.setPostHandshake(async ({ body }) => {
518
+ const t = parseNewSessionTicket(body);
519
+ resMaster ??= await resumptionMasterSecret(hash, masterSecret, await transcript.hash());
520
+ options.onSessionTicket({
521
+ identity: t.ticket,
522
+ psk: await resumptionPsk(hash, resMaster, t.nonce),
523
+ hash,
524
+ cipherSuite: suite,
525
+ lifetimeSec: t.lifetimeSec,
526
+ ageAdd: t.ageAdd,
527
+ maxEarlyDataSize: t.maxEarlyDataSize,
528
+ alpnProtocol: alpnProtocol ?? null,
529
+ peer,
530
+ });
531
+ });
532
+ }
533
+
534
+ // The duplex is created on first access, not eagerly: consumers that drive the record layer
535
+ // directly (record.readAppData/writeAppData) never need the platform-stream wrappers, and the
536
+ // runtime this package targets forbids even CONSTRUCTING platform streams in global scope —
537
+ // where the opt-in warmup replay runs. Memoized, so every consumer sees one stable pair.
538
+ let duplex = null;
539
+ const lazyDuplex = () => (duplex ??= record.plaintextDuplex());
540
+ return {
541
+ get readable() {
542
+ return lazyDuplex().readable;
543
+ },
544
+ get writable() {
545
+ return lazyDuplex().writable;
546
+ },
547
+ record,
548
+ peer,
549
+ info: {
550
+ version: TLS13,
551
+ cipherSuite: suite,
552
+ group: server.group,
553
+ alpnProtocol: alpnProtocol ?? null,
554
+ certificateRequested,
555
+ hostname,
556
+ // True only when the server took the offered PSK. A declined offer reports false: the
557
+ // connection ran the full handshake and was authenticated by certificate, and a caller
558
+ // reading this field is usually asking "was the chain re-validated on this connection".
559
+ resumed: acceptedPsk !== null,
560
+ },
561
+ close: () => record.close(),
562
+ };
563
+ }
564
+
565
+ /** A HelloRetryRequest cookie must be echoed verbatim in ClientHello2 (RFC 8446 s4.2.2). */
566
+ function cookieExtension(cookie) {
567
+ return new Builder().u16(EXTENSION.cookie).vector(2, cookie).build();
568
+ }