tunnelfetch 1.8.4 → 1.11.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/README.md CHANGED
@@ -453,6 +453,63 @@ Until 1.6.2 the profile carried curl **8.7.1's** window while presenting curl **
453
453
  ClientHello: one named client, two source versions, and a split identity that only a capture could
454
454
  find, because each half was individually true of some curl.
455
455
 
456
+ **`extensionOrder` arranges extensions; it cannot add them.** The builder filters to the extensions
457
+ it actually generated and sorts those, so an identity needing one this package does not build is not
458
+ reachable by ordering. Chromium sends five that it does not:
459
+
460
+ | | |
461
+ | --- | --- |
462
+ | `signed_certificate_timestamp` | 18 |
463
+ | `compress_certificate` | 27 |
464
+ | `session_ticket` | 35 |
465
+ | `application_settings` | 17613 |
466
+ | `encrypted_client_hello` | 65037 |
467
+
468
+ So `profiles.chrome` presents a Chromium cipher list, group list and GREASE placement over an
469
+ extension **set** that is curl's. A test reads that gap straight out of the committed Chromium
470
+ capture and fails if it changes, so it cannot drift quietly — but it is a real difference and a
471
+ JA3/JA4 hash sees it.
472
+
473
+ `tls.omitExtensions` is the subtractive counterpart, and `status_request` (5) is what it exists for:
474
+ that is the one extension this package sends which curl does not, so an identity matching a sample
475
+ without it had no way to drop it. Dropping it gives up OCSP stapling — the only revocation signal
476
+ this package consumes — so pairing it with `trust.revocation: 'require-staple'` is refused at
477
+ configuration time rather than left to fail every connection on a certificate that was never asked
478
+ to carry a staple.
479
+
480
+ `tls.extraExtensions` takes pre-encoded extensions and is the only way to close it. They are ordered
481
+ like any other and reproduced on a HelloRetryRequest retry, because a second hello that changed its
482
+ extension set would be both malformed (RFC 8446 §4.1.2) and a signal in itself. Encoding them
483
+ correctly is the caller's job; this package does not parse what it did not build.
484
+
485
+ **Every offered cipher must be one this package can perform — unless you say otherwise.**
486
+ `tls.ciphers` used to be taken verbatim, so a list containing a CBC or RSA-key-exchange suite put a
487
+ number on the wire that a server could select, after which the AEAD layer had nothing to build and
488
+ the connection died mid-handshake. Such a list is refused at configuration time by default, and an
489
+ explicit `TLS_CHACHA20_POLY1305_SHA256` with no injected implementation is refused rather than
490
+ silently dropped — quietly presenting a different fingerprint from the one you asked for is the
491
+ worst outcome available to a package like this one.
492
+
493
+ But refusing outright is the wrong default to have no escape from, because **the restriction is
494
+ itself a fingerprint**:
495
+
496
+ | | suites offered | performable here |
497
+ | --- | --- | --- |
498
+ | curl 8.21.0 | 30 | 7 |
499
+ | Chromium | 15 | 7 |
500
+
501
+ A hello restricted to what can be honoured carries a cipher list less than half the length of any
502
+ real client's, and list length and contents are exactly what a JA3 hash reads. `tls.allowUnperformableCiphers`
503
+ offers the accurate list. The trade is narrow: the first unperformable suite sits at index 5 of
504
+ curl's list and 7 of Chromium's, behind the TLS 1.3 suites, so a server with 1.3 available never
505
+ reaches one. If a server does select one the handshake fails, and the error names the option rather
506
+ than reading like a defect here.
507
+
508
+ Most of the gap is not a missing feature. Sixteen of curl's twenty-three are CBC — MAC-then-encrypt,
509
+ which cannot be implemented without a Lucky13 padding oracle in JavaScript — and two more are RSA key
510
+ exchange with no forward secrecy. Both are refusals this package intends to keep. The three that
511
+ *are* implementable are the TLS 1.2 ChaCha20-Poly1305 suites, and they are not implemented yet.
512
+
456
513
  Extension order matters because JA3 and JA4 hash the extension list **in wire order**, so it is most
457
514
  of what a fingerprinter reads. `pre_shared_key` is forced last whatever you ask for: RFC 8446
458
515
  §4.2.11 defines the binder transcript as the hello truncated just before the binders, which is a
@@ -548,6 +605,20 @@ new Client({ connect, proxy, maxBodyBytes: Infinity }); // or any number you h
548
605
  The trade is deliberate: an unasked-for limit is discoverable the first time it bites, and names the
549
606
  option in its error. An unasked-for OOM is neither.
550
607
 
608
+ #### The proxy sees a fingerprint too
609
+
610
+ `Proxy-Connection` is a pre-standard hop header that never reached a spec. The origin never sees it;
611
+ the proxy always does. Clients disagree — some send `keep-alive`, some `close`, some omit it — so for
612
+ anyone matching a client's behaviour *at the proxy* it is part of the fingerprint, and it used to be
613
+ hard-coded.
614
+
615
+ ```js
616
+ new Client({ connect, proxy: { ...cfg, proxyConnection: 'close' } }); // or null to omit it
617
+ ```
618
+
619
+ The default stays `keep-alive`, which avoids a class of proxy that closes the tunnel after one
620
+ request. Omitting the header is not the same as sending `close`.
621
+
551
622
  ### Trust — the `verify=` knob
552
623
 
553
624
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tunnelfetch",
3
- "version": "1.8.4",
3
+ "version": "1.11.0",
4
4
  "description": "A fetch-shaped HTTP client that can route through HTTP CONNECT / HTTPS / SOCKS5 proxies on runtimes with only raw TCP, such as Cloudflare Workers. Implements TLS in userland because the runtime cannot verify a tunnelled peer.",
5
5
  "keywords": [
6
6
  "fetch",
@@ -122,9 +122,15 @@ function buildConnectRequest(proxy, target) {
122
122
  if (proxy.username) {
123
123
  lines.push(`Proxy-Authorization: Basic ${basicCredentials(proxy.username, proxy.password)}`);
124
124
  }
125
- // Some proxies still key off the pre-standard hop header; sending it is harmless and avoids a
126
- // class of proxy that closes the tunnel after one request without it.
127
- lines.push('Proxy-Connection: keep-alive');
125
+ // `Proxy-Connection` is a pre-standard hop header that never made it into a spec, and clients
126
+ // disagree about it: some send `keep-alive`, some send `close`, some omit it. The proxy sees this
127
+ // even though the origin never does, so for anyone matching a client's behaviour AT THE PROXY it
128
+ // is part of the fingerprint. `keep-alive` stays the default — it avoids a class of proxy that
129
+ // closes the tunnel after one request — but it is no longer fixed.
130
+ //
131
+ // `null` omits the header entirely, which is not the same as sending `close`.
132
+ const pc = proxy.proxyConnection === undefined ? 'keep-alive' : proxy.proxyConnection;
133
+ if (pc !== null) lines.push(`Proxy-Connection: ${pc}`);
128
134
  return `${lines.join('\r\n')}\r\n\r\n`;
129
135
  }
130
136
 
@@ -19,7 +19,13 @@ import { openSocks5 } from './socks5.js';
19
19
  * @typedef {(addr: {hostname: string, port: number},
20
20
  * opts?: {secureTransport?: 'off'|'on'|'starttls', allowHalfOpen?: boolean}) => Duplex} ConnectFn
21
21
  * @typedef {{ protocol: 'http'|'https'|'socks5'|'socks5h', hostname: string, port: number,
22
- * username?: string, password?: string }} ProxyConfig
22
+ * username?: string, password?: string,
23
+ * proxyConnection?: string | null }} ProxyConfig
24
+ *
25
+ * `proxyConnection` sets the pre-standard `Proxy-Connection` header on a CONNECT request, or
26
+ * omits it entirely when null. Default 'keep-alive'. The origin never sees this header; the
27
+ * proxy does, so it belongs to whatever fingerprint the proxy is reading. Clients disagree —
28
+ * some send keep-alive, some close, some nothing — and omitting is not the same as 'close'.
23
29
  */
24
30
 
25
31
  const DEFAULT_PORTS = { http: 8080, https: 443, socks5: 1080, socks5h: 1080 };
@@ -114,6 +120,10 @@ function normalise(cfg) {
114
120
  port,
115
121
  username: cfg.username || undefined,
116
122
  password: cfg.password || undefined,
123
+ // Listed explicitly because this function REBUILDS the config rather than copying it, so a
124
+ // field not named here is dropped without a word. That is how `http2ConnectionWindow` came to
125
+ // be declared in a profile and read by nothing.
126
+ proxyConnection: cfg.proxyConnection,
117
127
  });
118
128
  }
119
129
 
@@ -137,7 +137,30 @@ function expectServerHello(msg, offers12) {
137
137
  * @property {number[]} [groups] supported_groups, in preference order.
138
138
  * @property {number[]} [offerGroups] groups to send an actual key_share for. Default the first
139
139
  * supported group; a HelloRetryRequest recovers any other choice at the cost of a round trip.
140
- * @property {number[]} [ciphers] cipher suites to offer, in preference order.
140
+ * @property {number[]} [ciphers] cipher suites to offer, in preference order. By default every
141
+ * suite must be one this package can perform: an offer it cannot honour is a dead connection the
142
+ * moment a server selects it, so an unknown suite is refused here rather than on the wire.
143
+ * @property {number[]} [omitExtensions] extension types to leave out of the ClientHello, the
144
+ * subtractive counterpart to `extraExtensions`. `status_request` (5) is the one extension this
145
+ * package sends that curl does not, so an identity matching a sample without it needs this.
146
+ * Dropping it gives up OCSP stapling, which is the only revocation signal this package can
147
+ * consume — pairing it with `trust.revocation: 'require-staple'` is refused rather than left to
148
+ * fail every connection.
149
+ * @property {boolean} [allowUnperformableCiphers] offer suites this package cannot complete.
150
+ * For fingerprint fidelity only. Real clients offer far more than this package implements — curl
151
+ * 8.21.0 offers thirty against seven performable here, Chromium fifteen against seven — so a
152
+ * hello restricted to what it can honour carries a cipher list shorter than any real client's,
153
+ * which is exactly what a JA3 hash reads. With this set, a server that selects an unperformable
154
+ * suite fails the handshake; the first such suite sits behind the TLS 1.3 ones in both real
155
+ * lists, so a 1.3-capable server does not reach it. Knowingly trading a rare failure for an
156
+ * accurate fingerprint is a legitimate choice; making it silently is not.
157
+ * @property {Uint8Array[]} [extraExtensions] pre-encoded ClientHello extensions, appended before
158
+ * ordering. `extensionOrder` can only arrange extensions that were BUILT — it filters to what
159
+ * exists and sorts that — so ordering alone cannot produce an extension this package does not
160
+ * generate. Chromium sends several that it does not: `signed_certificate_timestamp` (18),
161
+ * `compress_certificate` (27), `session_ticket` (35), `application_settings` (17613) and ECH
162
+ * (65037). Supplying them here is the only way to close that gap, and it is the caller's job to
163
+ * encode them correctly — this package does not parse what it did not build.
141
164
  * @property {number[]} [sigSchemes] signature_algorithms to offer, in preference order.
142
165
  * @property {boolean | number} [grease] send GREASE (RFC 8701) reserved values in the cipher list,
143
166
  * the extension list (one at each end), supported_groups, supported_versions and key_share.
@@ -328,6 +351,47 @@ async function drive({ record, hostname, verifyPeer, options, deps, versions })
328
351
  ];
329
352
  if (!chacha) ciphers = ciphers.filter((c) => c !== CIPHER.TLS_CHACHA20_POLY1305_SHA256);
330
353
 
354
+ // A caller-supplied list was previously taken verbatim. That let a hello offer suites this
355
+ // package cannot perform — the CBC and RSA-key-exchange suites, most obviously — and the failure
356
+ // did not surface at configuration time. It surfaced after the ClientHello was on the wire, when
357
+ // a server took the offer up and the AEAD layer had nothing to build: a dead connection rather
358
+ // than a rejected config.
359
+ //
360
+ // profiles.js already states the principle for the profile system: "a fingerprint field this
361
+ // package cannot perform is an offer a server may take and then find unhonoured, which fails the
362
+ // connection rather than merely looking wrong". It applies just as much to `tls.ciphers`, and
363
+ // now does. CIPHER_PARAMS is the set this package knows how to key and seal.
364
+ //
365
+ // `allowUnperformableCiphers` opts out, and it exists because refusing outright was the wrong
366
+ // default to have no escape from. Every real client offers far more suites than this package
367
+ // implements — curl 8.21.0 offers thirty and seven are performable here; Chromium offers fifteen
368
+ // and seven are — so a hello restricted to what can be honoured has a cipher list shorter than
369
+ // any real client's, and that is itself what a JA3 hash reads. A caller matching a fingerprint
370
+ // may rationally prefer the accurate list: the first unperformable suite sits at index 5 of
371
+ // curl's and 7 of Chromium's, behind the TLS 1.3 suites, so a server with 1.3 available never
372
+ // reaches it. The risk is real but narrow, and it is the caller's to take knowingly.
373
+ if (options.ciphers && !options.allowUnperformableCiphers) {
374
+ const unperformable = ciphers.filter((c) => !CIPHER_PARAMS[c]);
375
+ if (unperformable.length) {
376
+ throw new TlsError(
377
+ codes.CONFIG_INVALID,
378
+ `tls.ciphers offers ${unperformable.map((c) => `0x${c.toString(16).padStart(4, '0')}`).join(', ')}, ` +
379
+ 'which this package cannot perform. Offering a suite it cannot complete trades a ' +
380
+ 'fingerprint match for a dead connection the moment a server selects one — see the ' +
381
+ 'chrome profile, which drops the CBC and RSA-key-exchange suites for exactly this reason.',
382
+ { unperformable },
383
+ );
384
+ }
385
+ if (!chacha && options.ciphers.includes(CIPHER.TLS_CHACHA20_POLY1305_SHA256)) {
386
+ throw new TlsError(
387
+ codes.CONFIG_INVALID,
388
+ 'tls.ciphers offers TLS_CHACHA20_POLY1305_SHA256 but no ChaCha20 implementation was ' +
389
+ 'supplied. Pass one through `ciphers: { chacha20 }`, or drop the suite from the list — ' +
390
+ 'silently removing it would change the fingerprint you asked for without saying so.',
391
+ );
392
+ }
393
+ }
394
+
331
395
  // --- resumption offer ----------------------------------------------------------------------
332
396
  // Everything about the offered PSK that later steps need is derived once, up front: the Early
333
397
  // Secret and binder key here (they depend only on the PSK), the binder itself per hello (it
@@ -372,6 +436,8 @@ async function drive({ record, hostname, verifyPeer, options, deps, versions })
372
436
  groups,
373
437
  alpn,
374
438
  ciphers,
439
+ extraExtensions: options.extraExtensions ?? [],
440
+ omitExtensions: options.omitExtensions ?? [],
375
441
  versions,
376
442
  extensionOrder: options.extensionOrder,
377
443
  sigSchemes: options.sigSchemes,
@@ -192,6 +192,7 @@ export async function deriveSharedSecret(group, privateKey, peerKey, deps = {})
192
192
  * @property {string[]} [alpn] default ['http/1.1']; empty array omits the extension
193
193
  * @property {number[]} [versions] default [TLS13, TLS12]
194
194
  * @property {Uint8Array[]} [extraExtensions] pre-encoded, sent verbatim (the HRR cookie)
195
+ * @property {number[]} [omitExtensions] extension types to leave out of the hello
195
196
  * @property {{ identity: Uint8Array, obfuscatedTicketAge: number, binderLen: number }} [psk]
196
197
  * offer this resumption PSK. Encoded with a zeroed binder placeholder; the caller MUST derive
197
198
  * the real binder over `message.subarray(0, truncatedLength)` and patch it in at
@@ -299,6 +300,7 @@ export function buildClientHello({
299
300
  versions = [TLS13, TLS12],
300
301
  extensionOrder = CURL_EXTENSION_ORDER,
301
302
  extraExtensions = [],
303
+ omitExtensions = [],
302
304
  psk = null,
303
305
  grease = false,
304
306
  randomBytes = defaultRandom,
@@ -341,11 +343,15 @@ export function buildClientHello({
341
343
  'a resumption PSK was supplied but TLS 1.3 is not among the offered versions');
342
344
  }
343
345
 
346
+ const omit = new Set(omitExtensions);
344
347
  const extensionParts = [
345
348
  encodeServerName(hostname),
346
- // Always offered, for either version: without it a server may not staple (RFC 6066 s8), and
347
- // a stapled OCSP response is the only revocation signal this package can consume.
348
- encodeStatusRequest(),
349
+ // Offered by default for either version: without it a server may not staple (RFC 6066 s8), and
350
+ // a stapled OCSP response is the only revocation signal this package can consume. It is also
351
+ // the one extension this package sends that curl does not, so an identity matching a sample
352
+ // without it needs a way to drop it — `omitExtensions` is the subtractive counterpart to
353
+ // `extraExtensions`, and dropping this one gives up stapling.
354
+ omit.has(EXTENSION.status_request) ? null : encodeStatusRequest(),
349
355
  encodeSupportedGroups(g ? [g.take(), ...groups] : groups),
350
356
  encodeSignatureAlgorithms(sigSchemes),
351
357
  alpn.length ? encodeAlpn(alpn) : null,
@@ -589,7 +595,10 @@ export function negotiateCipher(serverHello, { offeredCiphers, version }) {
589
595
  if (!params) {
590
596
  throw new TlsUnsupportedError(
591
597
  codes.TLS_CIPHER_UNSUPPORTED,
592
- `cipher suite ${hex16(suite)} has no parameters; this is a bug in the offer list`,
598
+ `cipher suite ${hex16(suite)} was offered but this package cannot perform it, so the ` +
599
+ 'handshake cannot continue. Either the offer list has a bug, or `allowUnperformableCiphers` ' +
600
+ 'was set to match a real client\'s cipher list and a server has selected one of the suites ' +
601
+ 'that choice knowingly put on the wire.',
593
602
  { cipherSuite: suite },
594
603
  );
595
604
  }
@@ -246,7 +246,15 @@ export async function continueTls13(ctx) {
246
246
  grease: options.grease ?? false,
247
247
  random: hello.clientRandom,
248
248
  legacySessionId: hello.legacySessionId,
249
- extraExtensions: cookie ? [cookieExtension(cookie)] : [],
249
+ // The caller's extras must be reproduced too, for the same reason the order is: s4.1.2 does
250
+ // not permit a second hello to change its extension SET, and a retry that quietly dropped
251
+ // them would present one fingerprint on the first flight and a different one on the second —
252
+ // a difference that is itself a signal.
253
+ omitExtensions: options.omitExtensions ?? [],
254
+ extraExtensions: [
255
+ ...(options.extraExtensions ?? []),
256
+ ...(cookie ? [cookieExtension(cookie)] : []),
257
+ ],
250
258
  psk: offeredPsk && {
251
259
  identity: offeredPsk.identity,
252
260
  obfuscatedTicketAge: offeredPsk.obfuscatedTicketAge(),
package/src/transport.js CHANGED
@@ -124,6 +124,20 @@ export async function openConnection({
124
124
  }) {
125
125
  const target = targetFromUrl(url);
126
126
  const proxyConfig = parseProxy(proxy);
127
+
128
+ // Dropping status_request gives up OCSP stapling, and stapling is the only revocation signal this
129
+ // package consumes. Combined with `require-staple` that is not a weakened check, it is a policy
130
+ // that can never be satisfied: every connection would fail with OCSP_REQUIRED, on a certificate
131
+ // that was never asked to carry a staple. Refused here, where both settings are visible.
132
+ if ((tls?.omitExtensions ?? []).includes(5) && trust?.revocation === 'require-staple') {
133
+ throw new ConfigError(
134
+ codes.CONFIG_INVALID,
135
+ "tls.omitExtensions drops status_request (5) while trust.revocation is 'require-staple'. " +
136
+ 'Without status_request a server is not asked to staple, so no staple can arrive and every ' +
137
+ 'connection would fail. Keep the extension, or relax the revocation policy — the two are ' +
138
+ 'a contradiction rather than a stricter setting.',
139
+ );
140
+ }
127
141
  const owns = !deadlines;
128
142
  const dl = deadlines ?? new DeadlineController({}, { signal });
129
143
 
@@ -49,12 +49,19 @@ export type ConnectFn = (addr: {
49
49
  secureTransport?: "off" | "on" | "starttls";
50
50
  allowHalfOpen?: boolean;
51
51
  }) => Duplex;
52
+ /**
53
+ * `proxyConnection` sets the pre-standard `Proxy-Connection` header on a CONNECT request, or
54
+ * omits it entirely when null. Default 'keep-alive'. The origin never sees this header; the
55
+ * proxy does, so it belongs to whatever fingerprint the proxy is reading. Clients disagree —
56
+ * some send keep-alive, some close, some nothing — and omitting is not the same as 'close'.
57
+ */
52
58
  export type ProxyConfig = {
53
59
  protocol: "http" | "https" | "socks5" | "socks5h";
54
60
  hostname: string;
55
61
  port: number;
56
62
  username?: string;
57
63
  password?: string;
64
+ proxyConnection?: string | null;
58
65
  };
59
66
  import { openDirect } from './direct.js';
60
67
  import { openHttpConnect } from './http-connect.js';
@@ -13,7 +13,30 @@
13
13
  * @property {number[]} [groups] supported_groups, in preference order.
14
14
  * @property {number[]} [offerGroups] groups to send an actual key_share for. Default the first
15
15
  * supported group; a HelloRetryRequest recovers any other choice at the cost of a round trip.
16
- * @property {number[]} [ciphers] cipher suites to offer, in preference order.
16
+ * @property {number[]} [ciphers] cipher suites to offer, in preference order. By default every
17
+ * suite must be one this package can perform: an offer it cannot honour is a dead connection the
18
+ * moment a server selects it, so an unknown suite is refused here rather than on the wire.
19
+ * @property {number[]} [omitExtensions] extension types to leave out of the ClientHello, the
20
+ * subtractive counterpart to `extraExtensions`. `status_request` (5) is the one extension this
21
+ * package sends that curl does not, so an identity matching a sample without it needs this.
22
+ * Dropping it gives up OCSP stapling, which is the only revocation signal this package can
23
+ * consume — pairing it with `trust.revocation: 'require-staple'` is refused rather than left to
24
+ * fail every connection.
25
+ * @property {boolean} [allowUnperformableCiphers] offer suites this package cannot complete.
26
+ * For fingerprint fidelity only. Real clients offer far more than this package implements — curl
27
+ * 8.21.0 offers thirty against seven performable here, Chromium fifteen against seven — so a
28
+ * hello restricted to what it can honour carries a cipher list shorter than any real client's,
29
+ * which is exactly what a JA3 hash reads. With this set, a server that selects an unperformable
30
+ * suite fails the handshake; the first such suite sits behind the TLS 1.3 ones in both real
31
+ * lists, so a 1.3-capable server does not reach it. Knowingly trading a rare failure for an
32
+ * accurate fingerprint is a legitimate choice; making it silently is not.
33
+ * @property {Uint8Array[]} [extraExtensions] pre-encoded ClientHello extensions, appended before
34
+ * ordering. `extensionOrder` can only arrange extensions that were BUILT — it filters to what
35
+ * exists and sorts that — so ordering alone cannot produce an extension this package does not
36
+ * generate. Chromium sends several that it does not: `signed_certificate_timestamp` (18),
37
+ * `compress_certificate` (27), `session_ticket` (35), `application_settings` (17613) and ECH
38
+ * (65037). Supplying them here is the only way to close that gap, and it is the caller's job to
39
+ * encode them correctly — this package does not parse what it did not build.
17
40
  * @property {number[]} [sigSchemes] signature_algorithms to offer, in preference order.
18
41
  * @property {boolean | number} [grease] send GREASE (RFC 8701) reserved values in the cipher list,
19
42
  * the extension list (one at each end), supported_groups, supported_versions and key_share.
@@ -160,9 +183,41 @@ export type TlsOptions = {
160
183
  */
161
184
  offerGroups?: number[] | undefined;
162
185
  /**
163
- * cipher suites to offer, in preference order.
186
+ * cipher suites to offer, in preference order. By default every
187
+ * suite must be one this package can perform: an offer it cannot honour is a dead connection the
188
+ * moment a server selects it, so an unknown suite is refused here rather than on the wire.
164
189
  */
165
190
  ciphers?: number[] | undefined;
191
+ /**
192
+ * extension types to leave out of the ClientHello, the
193
+ * subtractive counterpart to `extraExtensions`. `status_request` (5) is the one extension this
194
+ * package sends that curl does not, so an identity matching a sample without it needs this.
195
+ * Dropping it gives up OCSP stapling, which is the only revocation signal this package can
196
+ * consume — pairing it with `trust.revocation: 'require-staple'` is refused rather than left to
197
+ * fail every connection.
198
+ */
199
+ omitExtensions?: number[] | undefined;
200
+ /**
201
+ * offer suites this package cannot complete.
202
+ * For fingerprint fidelity only. Real clients offer far more than this package implements — curl
203
+ * 8.21.0 offers thirty against seven performable here, Chromium fifteen against seven — so a
204
+ * hello restricted to what it can honour carries a cipher list shorter than any real client's,
205
+ * which is exactly what a JA3 hash reads. With this set, a server that selects an unperformable
206
+ * suite fails the handshake; the first such suite sits behind the TLS 1.3 ones in both real
207
+ * lists, so a 1.3-capable server does not reach it. Knowingly trading a rare failure for an
208
+ * accurate fingerprint is a legitimate choice; making it silently is not.
209
+ */
210
+ allowUnperformableCiphers?: boolean | undefined;
211
+ /**
212
+ * pre-encoded ClientHello extensions, appended before
213
+ * ordering. `extensionOrder` can only arrange extensions that were BUILT — it filters to what
214
+ * exists and sorts that — so ordering alone cannot produce an extension this package does not
215
+ * generate. Chromium sends several that it does not: `signed_certificate_timestamp` (18),
216
+ * `compress_certificate` (27), `session_ticket` (35), `application_settings` (17613) and ECH
217
+ * (65037). Supplying them here is the only way to close that gap, and it is the caller's job to
218
+ * encode them correctly — this package does not parse what it did not build.
219
+ */
220
+ extraExtensions?: Uint8Array<ArrayBufferLike>[] | undefined;
166
221
  /**
167
222
  * signature_algorithms to offer, in preference order.
168
223
  */
@@ -32,7 +32,7 @@ export function generateKeyShare(group: number, deps?: import("./connect.js").Tl
32
32
  * @returns {Promise<Uint8Array>} throws on any degenerate or malformed peer key
33
33
  */
34
34
  export function deriveSharedSecret(group: number, privateKey: CryptoKey | import("./hybrid.js").HybridPrivate, peerKey: Uint8Array, deps?: import("./connect.js").TlsDeps): Promise<Uint8Array>;
35
- export function buildClientHello({ hostname, keyShares, random, legacySessionId, ciphers, groups, sigSchemes, alpn, versions, extensionOrder, extraExtensions, psk, grease, randomBytes, }: {
35
+ export function buildClientHello({ hostname, keyShares, random, legacySessionId, ciphers, groups, sigSchemes, alpn, versions, extensionOrder, extraExtensions, omitExtensions, psk, grease, randomBytes, }: {
36
36
  hostname: any;
37
37
  keyShares: any;
38
38
  random: any;
@@ -44,6 +44,7 @@ export function buildClientHello({ hostname, keyShares, random, legacySessionId,
44
44
  versions?: number[] | undefined;
45
45
  extensionOrder?: readonly number[] | undefined;
46
46
  extraExtensions?: never[] | undefined;
47
+ omitExtensions?: never[] | undefined;
47
48
  psk?: null | undefined;
48
49
  grease?: boolean | undefined;
49
50
  randomBytes?: ((n: any) => Uint8Array<any>) | undefined;
@@ -305,6 +306,7 @@ export function checkAlpn(extensions: Map<number, Uint8Array>, offeredAlpn: stri
305
306
  * @property {string[]} [alpn] default ['http/1.1']; empty array omits the extension
306
307
  * @property {number[]} [versions] default [TLS13, TLS12]
307
308
  * @property {Uint8Array[]} [extraExtensions] pre-encoded, sent verbatim (the HRR cookie)
309
+ * @property {number[]} [omitExtensions] extension types to leave out of the hello
308
310
  * @property {{ identity: Uint8Array, obfuscatedTicketAge: number, binderLen: number }} [psk]
309
311
  * offer this resumption PSK. Encoded with a zeroed binder placeholder; the caller MUST derive
310
312
  * the real binder over `message.subarray(0, truncatedLength)` and patch it in at
@@ -432,6 +434,10 @@ export type ClientHelloOptions = {
432
434
  * pre-encoded, sent verbatim (the HRR cookie)
433
435
  */
434
436
  extraExtensions?: Uint8Array<ArrayBufferLike>[] | undefined;
437
+ /**
438
+ * extension types to leave out of the hello
439
+ */
440
+ omitExtensions?: number[] | undefined;
435
441
  /**
436
442
  * offer this resumption PSK. Encoded with a zeroed binder placeholder; the caller MUST derive
437
443
  * the real binder over `message.subarray(0, truncatedLength)` and patch it in at