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,263 @@
1
+ // AEAD record protection: nonce construction, inner-plaintext framing, both TLS versions.
2
+ //
3
+ // AES-GCM is the only AEAD here (see constants.js for why), driven through WebCrypto. The
4
+ // runtime's AES-GCM rejects on tag mismatch rather than returning unauthenticated bytes, which
5
+ // is what makes the fail-closed contract below possible: decrypt() either returns authenticated
6
+ // plaintext or throws TLS_RECORD, never garbage.
7
+ //
8
+ // Sequence numbers are the caller's state (per direction, reset to zero on every key change —
9
+ // record.js owns the counters), but their interpretation is fixed here:
10
+ //
11
+ // TLS 1.3 (RFC 8446 s5.3): nonce = static_iv XOR seq, with seq left-padded to iv length.
12
+ // TLS 1.2 (RFC 5288): nonce = 4-byte implicit salt || 8-byte explicit nonce; the
13
+ // explicit part travels on the wire. We send seq as the explicit
14
+ // nonce (the SHOULD of RFC 5288) but accept whatever the peer sent,
15
+ // because the AAD — not the nonce — carries the implicit counter.
16
+ //
17
+ // Sequence numbers are 64-bit. They are handled as BigInt precisely because a Number-based
18
+ // counter would silently lose precision past 2^53 and eventually reuse a nonce, which with GCM
19
+ // forfeits both confidentiality and integrity. Reaching 2^64-1 throws instead of wrapping.
20
+
21
+ import { TlsError, TlsUnsupportedError, codes, hex16 } from '../errors.js';
22
+ import { concat, u8, u16 } from '../util/bytes.js';
23
+ import {
24
+ CIPHER_PARAMS, CIPHER_NAME, MAX_PLAINTEXT, LEGACY_VERSION, TLS12, TLS13,
25
+ } from './constants.js';
26
+
27
+ const SEQ_MAX = (1n << 64n) - 1n; // the value at which the counter must not be used
28
+
29
+ /** @param {number | bigint} seq */
30
+ function checkSeq(seq) {
31
+ const s = typeof seq === 'bigint' ? seq : BigInt(seq);
32
+ if (s < 0n) throw new TlsError(codes.CONFIG_INVALID, `negative sequence number ${s}`);
33
+ if (s >= SEQ_MAX) {
34
+ // One shy of the full space: at 2^64-1 the *next* record would wrap to nonce 0. Refusing
35
+ // the last value makes "increment then check" ordering bugs unable to reuse a nonce.
36
+ throw new TlsError(codes.TLS_RECORD,
37
+ 'record sequence number reached 2^64-1; the connection must rekey, not wrap',
38
+ { seq: s.toString() });
39
+ }
40
+ return s;
41
+ }
42
+
43
+ /**
44
+ * TLS 1.3 per-record nonce: the 64-bit sequence number left-padded to the IV length, XORed
45
+ * with the static IV. Exported so the tests can pin the construction independently of a full
46
+ * encrypt round trip.
47
+ * @param {Uint8Array} iv
48
+ * @param {number | bigint} seq
49
+ * @returns {Uint8Array}
50
+ */
51
+ export function buildNonce(iv, seq) {
52
+ let s = checkSeq(seq);
53
+ const nonce = iv.slice();
54
+ for (let i = nonce.byteLength - 1; i >= nonce.byteLength - 8 && i >= 0; i--) {
55
+ nonce[i] ^= Number(s & 0xffn);
56
+ s >>= 8n;
57
+ }
58
+ return nonce;
59
+ }
60
+
61
+ /** The 64-bit sequence number as 8 big-endian bytes (TLS 1.2 explicit nonce and AAD). */
62
+ function seq64(seq) {
63
+ let s = checkSeq(seq);
64
+ const out = new Uint8Array(8);
65
+ for (let i = 7; i >= 0; i--) {
66
+ out[i] = Number(s & 0xffn);
67
+ s >>= 8n;
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * Record protection for one direction under one key. `encrypt` returns the encrypted record
74
+ * body ready for framing; `decrypt` either returns authenticated plaintext (with the inner
75
+ * content type under 1.3, the header type under 1.2) or throws TLS_RECORD — never garbage.
76
+ * @typedef {object} Aead
77
+ * @property {number} version
78
+ * @property {(seq: number | bigint, type: number, plaintext: Uint8Array,
79
+ * opts?: { padding?: number }) => Promise<Uint8Array>} encrypt
80
+ * @property {(seq: number | bigint, body: Uint8Array, header: Uint8Array)
81
+ * => Promise<{ type: number, plaintext: Uint8Array }>} decrypt
82
+ */
83
+
84
+ /**
85
+ * @typedef {object} AeadOptions
86
+ * @property {number} [version] `TLS13` (default) or `TLS12`; picks nonce and AAD construction
87
+ * @property {number} cipher cipher suite id, must have CIPHER_PARAMS
88
+ * @property {Uint8Array} key
89
+ * @property {Uint8Array} iv the 12-byte static IV for TLS 1.3, the 4-byte implicit salt for
90
+ * TLS 1.2
91
+ */
92
+
93
+ /**
94
+ * Create record protection for one direction under one key. A new key (handshake -> application,
95
+ * KeyUpdate) means a new instance; sequence numbers restart with it.
96
+ *
97
+ * @param {AeadOptions} opts
98
+ * @returns {Promise<Aead>}
99
+ */
100
+ export async function createAead({ version = TLS13, cipher, key, iv }) {
101
+ const params = CIPHER_PARAMS[cipher];
102
+ if (!params || params.hash === undefined) {
103
+ throw new TlsUnsupportedError(codes.TLS_CIPHER_UNSUPPORTED,
104
+ `cipher suite ${hex16(cipher)} (${CIPHER_NAME[cipher] ?? 'unknown'}) has no AEAD parameters`,
105
+ { cipher });
106
+ }
107
+ if (version !== TLS12 && version !== TLS13) {
108
+ throw new TlsError(codes.CONFIG_INVALID, `AEAD version ${hex16(version)} is not TLS 1.2/1.3`,
109
+ { version });
110
+ }
111
+ const { keyLen, ivLen, tagLen, fixedIvLen } = params;
112
+ const wantIv = version === TLS13 ? ivLen : fixedIvLen;
113
+ if (key.byteLength !== keyLen) {
114
+ throw new TlsError(codes.CONFIG_INVALID,
115
+ `key is ${key.byteLength} bytes; ${CIPHER_NAME[cipher]} needs ${keyLen}`, { cipher });
116
+ }
117
+ if (iv.byteLength !== wantIv) {
118
+ throw new TlsError(codes.CONFIG_INVALID,
119
+ `iv is ${iv.byteLength} bytes; ${CIPHER_NAME[cipher]} needs ${wantIv} for this version`,
120
+ { cipher, version });
121
+ }
122
+ const gcmKey = await crypto.subtle.importKey('raw', key, { name: 'AES-GCM' }, false,
123
+ ['encrypt', 'decrypt']);
124
+ const staticIv = iv.slice(); // defensive copy: the caller may zero or reuse its buffer
125
+
126
+ const tagFailure = () => new TlsError(codes.TLS_RECORD,
127
+ 'AEAD authentication failed: record tag or additional data did not verify', {});
128
+
129
+ if (version === TLS13) {
130
+ return {
131
+ version,
132
+ /**
133
+ * Returns the encrypted record body (ciphertext + tag). The matching wire header is
134
+ * always `17 03 03 <len>`; the AAD here is built from the same rule, so a caller that
135
+ * frames differently will fail to interoperate rather than succeed unauthenticated.
136
+ */
137
+ async encrypt(seq, type, plaintext, { padding = 0 } = {}) {
138
+ if (!Number.isInteger(padding) || padding < 0) {
139
+ throw new TlsError(codes.CONFIG_INVALID,
140
+ `padding ${padding} is not a non-negative integer`);
141
+ }
142
+ if (plaintext.byteLength > MAX_PLAINTEXT) {
143
+ throw new TlsError(codes.CONFIG_INVALID,
144
+ `plaintext of ${plaintext.byteLength} bytes exceeds ${MAX_PLAINTEXT}; fragment first`,
145
+ { length: plaintext.byteLength });
146
+ }
147
+ // TLSInnerPlaintext = content || content_type || zero padding, capped at 2^14 + 1.
148
+ const room = MAX_PLAINTEXT - plaintext.byteLength;
149
+ const pad = Math.min(padding, room);
150
+ const inner = new Uint8Array(plaintext.byteLength + 1 + pad);
151
+ inner.set(plaintext, 0);
152
+ inner[plaintext.byteLength] = type;
153
+ const aad = concat([u8(23), u16(LEGACY_VERSION), u16(inner.byteLength + tagLen)]);
154
+ const nonce = buildNonce(staticIv, seq);
155
+ const ct = await crypto.subtle.encrypt(
156
+ { name: 'AES-GCM', iv: nonce, additionalData: aad, tagLength: tagLen * 8 },
157
+ gcmKey, inner);
158
+ return new Uint8Array(ct);
159
+ },
160
+
161
+ /**
162
+ * @param {Uint8Array} body ciphertext + tag as read from the wire
163
+ * @param {Uint8Array} header the 5 record-header bytes — they ARE the AAD (RFC 8446 s5.2)
164
+ */
165
+ async decrypt(seq, body, header) {
166
+ if (header.byteLength !== 5) {
167
+ throw new TlsError(codes.CONFIG_INVALID,
168
+ `AAD must be the 5 header bytes, got ${header.byteLength}`);
169
+ }
170
+ // Minimum: tag plus one byte, because a valid inner plaintext holds at least the type.
171
+ if (body.byteLength < tagLen + 1) {
172
+ throw new TlsError(codes.TLS_RECORD,
173
+ `encrypted record body of ${body.byteLength} bytes is shorter than ` +
174
+ `tag+1 (${tagLen + 1})`,
175
+ { length: body.byteLength });
176
+ }
177
+ const nonce = buildNonce(staticIv, seq);
178
+ let inner;
179
+ try {
180
+ inner = new Uint8Array(await crypto.subtle.decrypt(
181
+ { name: 'AES-GCM', iv: nonce, additionalData: header, tagLength: tagLen * 8 },
182
+ gcmKey, body));
183
+ } catch {
184
+ throw tagFailure();
185
+ }
186
+ // Strip the zero padding to expose the real content type (RFC 8446 s5.4). Scanning
187
+ // from the end is not constant time, but padding length is not secret from an attacker
188
+ // who can measure it anyway via record timing; the RFC imposes no such requirement.
189
+ let i = inner.byteLength - 1;
190
+ while (i >= 0 && inner[i] === 0) i--;
191
+ if (i < 0) {
192
+ throw new TlsError(codes.TLS_RECORD,
193
+ 'record plaintext is all padding with no content type byte', {});
194
+ }
195
+ const type = inner[i];
196
+ const plaintext = inner.subarray(0, i);
197
+ if (plaintext.byteLength > MAX_PLAINTEXT) {
198
+ throw new TlsError(codes.TLS_RECORD,
199
+ `record plaintext of ${plaintext.byteLength} bytes exceeds ${MAX_PLAINTEXT}`,
200
+ { length: plaintext.byteLength });
201
+ }
202
+ return { type, plaintext };
203
+ },
204
+ };
205
+ }
206
+
207
+ // ------------------------------------------------------------------ TLS 1.2 (RFC 5288)
208
+ return {
209
+ version,
210
+ /** Returns explicit_nonce(8) || ciphertext || tag — the GenericAEADCipher fragment. */
211
+ async encrypt(seq, type, plaintext, { padding = 0 } = {}) {
212
+ if (padding !== 0) {
213
+ // No inner-plaintext padding exists in 1.2; silently dropping the request would let a
214
+ // caller believe it is hiding lengths when it is not.
215
+ throw new TlsError(codes.CONFIG_INVALID, 'record padding is a TLS 1.3 feature', {});
216
+ }
217
+ if (plaintext.byteLength > MAX_PLAINTEXT) {
218
+ throw new TlsError(codes.CONFIG_INVALID,
219
+ `plaintext of ${plaintext.byteLength} bytes exceeds ${MAX_PLAINTEXT}; fragment first`,
220
+ { length: plaintext.byteLength });
221
+ }
222
+ const explicit = seq64(seq);
223
+ const nonce = concat([staticIv, explicit]);
224
+ const aad = concat([explicit, u8(type), u16(TLS12), u16(plaintext.byteLength)]);
225
+ const ct = new Uint8Array(await crypto.subtle.encrypt(
226
+ { name: 'AES-GCM', iv: nonce, additionalData: aad, tagLength: tagLen * 8 },
227
+ gcmKey, plaintext));
228
+ return concat([explicit, ct]);
229
+ },
230
+
231
+ /**
232
+ * The AAD is seq || type || version || plaintext-length (RFC 5246 s6.2.3.3). seq is OUR
233
+ * receive counter, not the peer's explicit nonce — that is what makes reordering and
234
+ * replay detectable. type/version are taken from the received header so the AAD binds
235
+ * exactly what the wire claimed.
236
+ */
237
+ async decrypt(seq, body, header) {
238
+ if (header.byteLength !== 5) {
239
+ throw new TlsError(codes.CONFIG_INVALID,
240
+ `header must be 5 bytes, got ${header.byteLength}`);
241
+ }
242
+ if (body.byteLength < 8 + tagLen) {
243
+ throw new TlsError(codes.TLS_RECORD,
244
+ `encrypted record body of ${body.byteLength} bytes is shorter than ` +
245
+ `nonce+tag (${8 + tagLen})`,
246
+ { length: body.byteLength });
247
+ }
248
+ const explicit = body.subarray(0, 8);
249
+ const nonce = concat([staticIv, explicit]);
250
+ const ptLen = body.byteLength - 8 - tagLen;
251
+ const aad = concat([seq64(seq), header.subarray(0, 3), u16(ptLen)]);
252
+ let plaintext;
253
+ try {
254
+ plaintext = new Uint8Array(await crypto.subtle.decrypt(
255
+ { name: 'AES-GCM', iv: nonce, additionalData: aad, tagLength: tagLen * 8 },
256
+ gcmKey, body.subarray(8)));
257
+ } catch {
258
+ throw tagFailure();
259
+ }
260
+ return { type: header[0], plaintext };
261
+ },
262
+ };
263
+ }
@@ -0,0 +1,407 @@
1
+ // TLS version negotiation: one ClientHello, one ServerHello, one dispatch.
2
+ //
3
+ // This is the entry point that offers TLS 1.3 and TLS 1.2 together, the way every real client
4
+ // does: a single ClientHello carrying supported_versions [1.3, 1.2], both cipher-suite sets, a
5
+ // key_share for 1.3, and the 1.2 compatibility extensions (extended_master_secret,
6
+ // ec_point_formats, renegotiation_info). The server picks; we continue down the matching driver
7
+ // on the SAME connection.
8
+ //
9
+ // What this file must never become is "try 1.3, and on failure reconnect at 1.2". That insecure
10
+ // fallback dance is the one browsers tore out (POODLE was its harvest): an attacker who can
11
+ // inject a TCP reset or a handshake_failure alert gets to choose the client's version. Here a
12
+ // failure is a failure — there is no code path that dials again, at any version, for any reason.
13
+ //
14
+ // Offering 1.2 next to 1.3 is exactly the configuration RFC 8446's downgrade protections exist
15
+ // for, and both run in this file's negotiation with the REAL offered list:
16
+ //
17
+ // * the s4.1.3 sentinel in ServerHello.random — a 1.3-capable server pushed down to 1.2 by a
18
+ // stripped ClientHello brands its random, and negotiateVersion aborts on the brand;
19
+ // * the "selected version was not offered" check, which is what keeps 1.0/1.1 out entirely.
20
+ //
21
+ // The transcript rule from the drivers carries over unchanged and is the reason the preamble is
22
+ // shaped the way it is: the transcript hash is chosen by the negotiated cipher suite, which is
23
+ // only known from the ServerHello. So the ClientHello is held as raw bytes and the transcript is
24
+ // constructed here exactly once, under the right hash — never started under a guess and rebuilt,
25
+ // because rebuilding cannot reproduce the HelloRetryRequest substitution the 1.3 driver may have
26
+ // to perform on it.
27
+
28
+ import { TlsError, TlsUnsupportedError, codes, hex8 } from '../errors.js';
29
+ import { RecordLayer } from './record.js';
30
+ import { Transcript } from './transcript.js';
31
+ import {
32
+ ALPN_HTTP11,
33
+ CIPHER_PARAMS,
34
+ HANDSHAKE_TYPE,
35
+ SUPPORTED_GROUPS,
36
+ TLS12,
37
+ TLS12_CIPHERS,
38
+ TLS13,
39
+ TLS13_CIPHERS,
40
+ } from './constants.js';
41
+ import {
42
+ buildClientHello,
43
+ generateKeyShare,
44
+ negotiateCipher,
45
+ negotiateVersion,
46
+ parseServerHello,
47
+ setPskBinder,
48
+ } from './handshake-messages.js';
49
+ import { describeVersion } from './extensions.js';
50
+ import {
51
+ earlySecret,
52
+ finishedVerifyData,
53
+ hashLength,
54
+ resumptionBinderKey,
55
+ } from './keyschedule.js';
56
+ import { DEFAULT_OFFER_GROUPS, continueTls13 } from './handshake.js';
57
+ import { continueTls12, refuseHelloRequest } from './handshake12.js';
58
+
59
+ const DEFAULT_VERSIONS = [TLS13, TLS12];
60
+
61
+ /**
62
+ * Validate and canonicalise the offer list. Newest first, always: supported_versions is a
63
+ * preference list, and there is no configuration in which preferring 1.2 while also offering
64
+ * 1.3 is anything but a downgrade written into the offer itself.
65
+ */
66
+ function normalizeVersions(input) {
67
+ if (input === undefined) return DEFAULT_VERSIONS;
68
+ if (!Array.isArray(input) || input.length === 0) {
69
+ throw new TlsError(
70
+ codes.CONFIG_INVALID,
71
+ 'options.versions must be a non-empty array of TLS versions to offer',
72
+ );
73
+ }
74
+ const out = [];
75
+ for (const v of input) {
76
+ if (v !== TLS13 && v !== TLS12) {
77
+ throw new TlsUnsupportedError(
78
+ codes.TLS_VERSION_UNSUPPORTED,
79
+ `cannot offer ${describeVersion(v)}: only TLS 1.3 and TLS 1.2 are implemented. ` +
80
+ 'TLS 1.0 and 1.1 have no AEAD cipher suites, and SSL is long dead.',
81
+ { version: v },
82
+ );
83
+ }
84
+ if (!out.includes(v)) out.push(v);
85
+ }
86
+ return out.sort((a, b) => b - a);
87
+ }
88
+
89
+ /**
90
+ * Demand that the first handshake message is a ServerHello. The one type refused BY NAME is
91
+ * HelloRequest, and only when 1.2 is on the table: a 1.2 server may legally emit one at any
92
+ * time, and the refusal must say "renegotiation" rather than "unknown type" (handshake12.js
93
+ * owns that stance and its wording).
94
+ */
95
+ function expectServerHello(msg, offers12) {
96
+ if (msg === null) {
97
+ throw new TlsError(
98
+ codes.TLS_TRUNCATED,
99
+ 'server closed the connection during the handshake while ServerHello was expected',
100
+ { expected: 'server_hello' },
101
+ );
102
+ }
103
+ if (msg.ccs) {
104
+ // In no version of TLS is a key change legal before the ServerHello; even 1.3's
105
+ // compatibility CCS is specified to follow the server's first handshake message.
106
+ throw new TlsError(codes.TLS_RECORD, 'change_cipher_spec arrived where ServerHello was expected', {
107
+ expected: 'server_hello',
108
+ });
109
+ }
110
+ if (offers12 && msg.type === 0) refuseHelloRequest(); // 0 = HelloRequest, deliberately unnamed
111
+ if (msg.type !== HANDSHAKE_TYPE.server_hello) {
112
+ throw new TlsError(
113
+ codes.TLS_HANDSHAKE,
114
+ `server sent handshake type ${hex8(msg.type)} where ServerHello was expected`,
115
+ { got: msg.type, expected: HANDSHAKE_TYPE.server_hello },
116
+ );
117
+ }
118
+ return msg;
119
+ }
120
+
121
+ /**
122
+ * A byte duplex: what every layer in this package consumes and produces.
123
+ * @typedef {{ readable: ReadableStream<Uint8Array>,
124
+ * writable: WritableStream<Uint8Array> }} ByteDuplex
125
+ */
126
+
127
+ /**
128
+ * Handshake knobs. Every one of these narrows what is offered; none can widen it beyond what
129
+ * `constants.js` permits, so no option here can talk the client into a suite it refuses.
130
+ *
131
+ * @typedef {object} TlsOptions
132
+ * @property {number[]} [versions] versions to offer, from `TLS13` / `TLS12`. Default both.
133
+ * @property {string[]} [alpn] ALPN protocols to offer. Default `['http/1.1']`.
134
+ * @property {number[]} [groups] supported_groups, in preference order.
135
+ * @property {number[]} [offerGroups] groups to send an actual key_share for. Default the first
136
+ * supported group; a HelloRetryRequest recovers any other choice at the cost of a round trip.
137
+ * @property {number[]} [ciphers] cipher suites to offer, in preference order.
138
+ * @property {Uint8Array} [clientRandom] fixed ClientHello.random, for reproducible handshakes.
139
+ * @property {Uint8Array} [legacySessionId] fixed legacy_session_id, likewise.
140
+ * @property {boolean} [compatibilityCcs] send the middlebox-compatibility ChangeCipherSpec.
141
+ * Default true.
142
+ * @property {number} [maxHandshakeMessage] per-message cap; certificate chains dominate sizing.
143
+ * @property {number} [maxKeyUpdates] received KeyUpdates tolerated before it is called a flood.
144
+ * @property {number} [maxTranscriptBytes] cap on buffered handshake transcript.
145
+ * @property {ResumptionOffer} [psk] offer this resumption PSK (TLS 1.3 only; requires 1.3 in
146
+ * the offered versions). The server may decline, in which case the full handshake continues
147
+ * on this same connection — there is no reconnect at any layer.
148
+ * @property {(ticket: CapturedTicket) => void} [onSessionTicket] receive each NewSessionTicket
149
+ * this connection yields, already reduced to a usable PSK per RFC 8446 s7.1. Without this the
150
+ * tickets are read and discarded, exactly as before.
151
+ */
152
+
153
+ /**
154
+ * A resumption PSK ready to offer, as produced by the ticket store from a CapturedTicket.
155
+ * `obfuscatedTicketAge` is a closure, not a number, because the age must be current at the
156
+ * moment each hello is BUILT — a HelloRetryRequest builds a second hello later — and because
157
+ * clock policy belongs to the store, not to this layer (which otherwise never reads a clock).
158
+ * `peer` rides along opaquely: it is whatever the original session's verifyPeer resolved with,
159
+ * and a resumed session (which has no Certificate message to verify) reports it as its own —
160
+ * sound only because the ticket store keys tickets by the full trust configuration.
161
+ * @typedef {object} ResumptionOffer
162
+ * @property {Uint8Array} identity the ticket
163
+ * @property {Uint8Array} psk
164
+ * @property {import('./keyschedule.js').ScheduleHash} hash the hash the PSK was minted under
165
+ * @property {() => number} obfuscatedTicketAge uint32 per RFC 8446 s4.2.11.1
166
+ * @property {object} [peer]
167
+ */
168
+
169
+ /**
170
+ * What a NewSessionTicket becomes by the time a caller sees it: the wire fields that govern
171
+ * offering (lifetime, age_add) plus the derived PSK and everything needed to check a future
172
+ * selection against it. `maxEarlyDataSize` is recorded for honesty but never acted on: 0-RTT
173
+ * is deliberately not implemented (see the driver's note).
174
+ * @typedef {object} CapturedTicket
175
+ * @property {Uint8Array} identity
176
+ * @property {Uint8Array} psk
177
+ * @property {import('./keyschedule.js').ScheduleHash} hash
178
+ * @property {number} cipherSuite
179
+ * @property {number} lifetimeSec
180
+ * @property {number} ageAdd
181
+ * @property {number | null} maxEarlyDataSize
182
+ * @property {string | null} alpnProtocol
183
+ * @property {object} peer
184
+ */
185
+
186
+ /**
187
+ * Injectable nondeterminism. Supplying these makes a handshake byte-for-byte reproducible, which
188
+ * is what allows a recorded session to be replayed in an offline test.
189
+ * @typedef {object} TlsDeps
190
+ * @property {(n: number) => Uint8Array} [randomBytes]
191
+ * @property {(algorithm: object, group: number) => Promise<CryptoKeyPair>} [generateKeyPair]
192
+ */
193
+
194
+ /**
195
+ * What a completed handshake reports about itself.
196
+ * @typedef {object} TlsSessionInfo
197
+ * @property {number} version negotiated version, `0x0304` or `0x0303`
198
+ * @property {number} cipherSuite negotiated suite
199
+ * @property {number} group negotiated key-exchange group
200
+ * @property {string | null} alpnProtocol
201
+ * @property {string} hostname the identity the certificate was required to prove
202
+ * @property {boolean} [extendedMasterSecret] TLS 1.2 only: whether RFC 7627 was in effect
203
+ * @property {boolean} [resumed] TLS 1.3 only: the server accepted the offered resumption PSK,
204
+ * so no certificate crossed the wire on THIS connection; the identity is the one validated
205
+ * by the original handshake the ticket came from
206
+ */
207
+
208
+ /**
209
+ * A live TLS session: a plaintext duplex plus what was negotiated to get it.
210
+ * @typedef {object} TlsSession
211
+ * @property {ReadableStream<Uint8Array>} readable
212
+ * @property {WritableStream<Uint8Array>} writable
213
+ * @property {import('./record.js').RecordLayer} record
214
+ * @property {object} peer whatever `verifyPeer` resolved with: the validated leaf
215
+ * @property {TlsSessionInfo} info
216
+ * @property {() => Promise<void>} close
217
+ */
218
+
219
+ /**
220
+ * Run a TLS handshake over a byte duplex, negotiating the version, and return the plaintext
221
+ * duplex above it. The default offer is [TLS 1.3, TLS 1.2]; `options.versions` narrows it.
222
+ *
223
+ * @param {object} args
224
+ * @param {ByteDuplex} args.transport
225
+ * @param {string} args.hostname the identity the certificate must prove, and the SNI sent
226
+ * @param {import('./handshake.js').VerifyPeer} args.verifyPeer
227
+ * Must throw to reject. Resolves with the validated leaf; its SPKI is the only key either
228
+ * driver will accept a handshake signature from. Receives the peer's stapled OCSP response,
229
+ * when there is one, as its third argument.
230
+ * @param {TlsOptions} [args.options]
231
+ * @param {TlsDeps} [args.deps]
232
+ * @returns {Promise<TlsSession>}
233
+ */
234
+ export async function connectTls({ transport, hostname, verifyPeer, options = {}, deps = {} }) {
235
+ if (typeof verifyPeer !== 'function') {
236
+ // Refusing to start is the only safe default. A missing verifier must never read as "skip".
237
+ throw new TlsError(
238
+ codes.CONFIG_INVALID,
239
+ 'connectTls requires a verifyPeer function; there is no unverified mode',
240
+ );
241
+ }
242
+ const versions = normalizeVersions(options.versions);
243
+ const record = new RecordLayer(transport, {
244
+ maxHandshakeMessage: options.maxHandshakeMessage,
245
+ maxKeyUpdates: options.maxKeyUpdates,
246
+ });
247
+ // Until the ServerHello picks, the record layer speaks the LOWEST version offered. The two
248
+ // semantics that differ before any ServerHello are alert tolerance and CCS handling, and the
249
+ // 1.2 discipline is right for both while 1.2 is a version we are willing to end up on: a 1.2
250
+ // server may send warning alerts (unrecognized_name, famously) that must not kill an offer
251
+ // that includes 1.2, and a CCS before ServerHello is fatal in every version — surfaced as an
252
+ // event here and refused by expectServerHello above. Once the ServerHello lands the version
253
+ // is pinned for real, before any key is installed.
254
+ record.setVersion(versions.includes(TLS12) ? TLS12 : TLS13);
255
+ try {
256
+ return await drive({ record, hostname, verifyPeer, options, deps, versions });
257
+ } catch (err) {
258
+ // Leaving a peer waiting on a half-open connection is a real interop problem, and the alert
259
+ // is the only signal a server operator gets about why we hung up. Best effort: the transport
260
+ // may already be gone, and the original error is what the caller needs.
261
+ try {
262
+ await record.abort();
263
+ } catch {
264
+ /* transport already unusable */
265
+ }
266
+ throw err;
267
+ }
268
+ }
269
+
270
+ async function drive({ record, hostname, verifyPeer, options, deps, versions }) {
271
+ const offers13 = versions.includes(TLS13);
272
+ const offers12 = versions.includes(TLS12);
273
+ const alpn = options.alpn ?? [ALPN_HTTP11];
274
+ const groups = options.groups ?? SUPPORTED_GROUPS;
275
+ const offerGroups = options.offerGroups ?? DEFAULT_OFFER_GROUPS;
276
+ // Suites for every offered version, 1.3 first. negotiateCipher later re-checks the family of
277
+ // the server's pick against the negotiated version, so a union offer cannot be abused to run
278
+ // a 1.3 suite under 1.2 or the reverse.
279
+ const ciphers = options.ciphers ?? [
280
+ ...(offers13 ? TLS13_CIPHERS : []),
281
+ ...(offers12 ? TLS12_CIPHERS : []),
282
+ ];
283
+
284
+ // --- resumption offer ----------------------------------------------------------------------
285
+ // Everything about the offered PSK that later steps need is derived once, up front: the Early
286
+ // Secret and binder key here (they depend only on the PSK), the binder itself per hello (it
287
+ // depends on each hello's bytes). psk_dhe_ke is the only mode ever offered, so acceptance
288
+ // still runs a fresh key exchange and a leaked ticket cannot unlock recorded traffic.
289
+ let pskOffer = null;
290
+ if (options.psk) {
291
+ const { identity, psk, hash, obfuscatedTicketAge, peer } = options.psk;
292
+ if (!offers13) {
293
+ throw new TlsError(codes.CONFIG_INVALID,
294
+ 'options.psk offers a TLS 1.3 resumption PSK but TLS 1.3 is not among the offered versions');
295
+ }
296
+ // The PSK can only be selected together with a suite of its own hash (RFC 8446 s4.2.11).
297
+ // Offering one no offered suite could carry is a wiring bug upstream, not a server choice,
298
+ // and must fail here rather than surface as a mysteriously-always-full handshake.
299
+ const usable = ciphers.some((c) => CIPHER_PARAMS[c]?.hash === hash);
300
+ if (!usable) {
301
+ throw new TlsError(codes.CONFIG_INVALID,
302
+ `options.psk was minted under ${hash} but no offered cipher suite uses that hash`,
303
+ { hash, ciphers });
304
+ }
305
+ const early = await earlySecret(hash, psk);
306
+ pskOffer = {
307
+ identity, psk, hash, obfuscatedTicketAge, peer: peer ?? null,
308
+ earlySecret: early,
309
+ binderKey: await resumptionBinderKey(hash, early),
310
+ binderLen: hashLength(hash),
311
+ };
312
+ }
313
+
314
+ // --- ClientHello ---------------------------------------------------------------------------
315
+ // ONE hello for every offered version. Key shares ride in a 1.3-only extension, so they are
316
+ // generated only when 1.3 is on the table; a 1.2-only offer must not pay for a key it can
317
+ // never use (and must stay byte-identical to what handshakeTls12 always sent).
318
+ const keyShares = [];
319
+ if (offers13) {
320
+ for (const g of offerGroups) keyShares.push(await generateKeyShare(g, deps));
321
+ }
322
+ const hello = buildClientHello({
323
+ hostname,
324
+ keyShares,
325
+ groups,
326
+ alpn,
327
+ ciphers,
328
+ versions,
329
+ random: options.clientRandom,
330
+ legacySessionId: options.legacySessionId,
331
+ psk: pskOffer && {
332
+ identity: pskOffer.identity,
333
+ obfuscatedTicketAge: pskOffer.obfuscatedTicketAge(),
334
+ binderLen: pskOffer.binderLen,
335
+ },
336
+ randomBytes: deps.randomBytes,
337
+ });
338
+ if (pskOffer) {
339
+ // The binder (RFC 8446 s4.2.11.2): an HMAC under the binder key over the transcript of THIS
340
+ // hello truncated just before the binders list, hashed with the PSK's OWN hash — the
341
+ // negotiated suite does not exist yet and has no say. For the first hello that transcript
342
+ // is just the truncated message; no Transcript object exists this early, deliberately (its
343
+ // hash is the suite's, chosen later). The patched message is what the transcript and the
344
+ // wire both get, so the binder is inside every later hash of this hello.
345
+ const truncatedHash = new Uint8Array(await crypto.subtle.digest(
346
+ pskOffer.hash, hello.message.subarray(0, hello.truncatedLength)));
347
+ setPskBinder(hello, await finishedVerifyData(pskOffer.hash, pskOffer.binderKey, truncatedHash));
348
+ }
349
+ await record.writeHandshake([hello.message]);
350
+
351
+ // --- ServerHello: the server picks, we dispatch --------------------------------------------
352
+ const first = expectServerHello(await record.nextHandshakeMessage(), offers12);
353
+ const sh = parseServerHello(first.body);
354
+
355
+ if (sh.isHelloRetryRequest && !offers13) {
356
+ // The HelloRetryRequest random is a fixed TLS 1.3 constant; an honest 1.2 server hits it
357
+ // with probability 2^-256. Seeing it means a 1.3 message was spliced into this handshake.
358
+ throw new TlsError(
359
+ codes.TLS_HANDSHAKE,
360
+ 'ServerHello.random is the TLS 1.3 HelloRetryRequest sentinel, which cannot occur in an ' +
361
+ 'honest TLS 1.2 handshake',
362
+ );
363
+ }
364
+
365
+ // HelloRetryRequest is TLS 1.3 only, so an HRR decides the version by itself; the 1.3 driver
366
+ // re-runs negotiateVersion on the real ServerHello that follows it. Otherwise the ServerHello
367
+ // decides here — and this call, with the full offered list, is what makes the RFC 8446 s4.1.3
368
+ // downgrade sentinel and the not-offered check live.
369
+ const version = sh.isHelloRetryRequest
370
+ ? TLS13
371
+ : negotiateVersion(sh, { offeredVersions: versions });
372
+
373
+ // The suite is known now, from either the HelloRetryRequest or the real ServerHello, so the
374
+ // transcript can be created exactly once under the correct hash. It is fed the ClientHello
375
+ // only; folding in the ServerHello is the driver's job, because the 1.3 driver may first have
376
+ // to replace the ClientHello with its message_hash form (RFC 8446 s4.4.1).
377
+ const { suite, params } = negotiateCipher(sh, { offeredCiphers: hello.offeredCiphers, version });
378
+ const transcript = new Transcript(params.hash, { maxBytes: options.maxTranscriptBytes });
379
+ transcript.update(hello.message);
380
+
381
+ const ctx = {
382
+ record,
383
+ transcript,
384
+ hello,
385
+ serverHello: sh,
386
+ rawServerHello: first.raw,
387
+ suite,
388
+ params,
389
+ hostname,
390
+ verifyPeer,
391
+ options,
392
+ deps,
393
+ offer: { versions, ciphers: hello.offeredCiphers, groups, offerGroups, alpn, keyShares,
394
+ psk: pskOffer },
395
+ };
396
+
397
+ if (version === TLS13) {
398
+ // Pin the record layer before the driver reads on: a 1.3 server's compatibility CCS may
399
+ // arrive right behind the ServerHello and must be dropped, not surfaced as a 1.2 key-change
400
+ // event. No keys exist yet, so the pin is still legal.
401
+ record.setVersion(TLS13);
402
+ return continueTls13(ctx);
403
+ }
404
+ // version can only be TLS12 here (negotiateVersion refuses anything unoffered), and the
405
+ // record layer is already in 1.2 mode: TLS12 ∈ versions is what put it there.
406
+ return continueTls12(ctx);
407
+ }