tunnelfetch 1.8.4 → 1.9.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,36 @@ 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.extraExtensions` takes pre-encoded extensions and is the only way to close it. They are ordered
474
+ like any other and reproduced on a HelloRetryRequest retry, because a second hello that changed its
475
+ extension set would be both malformed (RFC 8446 §4.1.2) and a signal in itself. Encoding them
476
+ correctly is the caller's job; this package does not parse what it did not build.
477
+
478
+ **Every offered cipher must be one this package can perform.** `tls.ciphers` used to be taken
479
+ verbatim, so a list containing a CBC or RSA-key-exchange suite put a number on the wire that a
480
+ server could select — after which the AEAD layer had nothing to build and the connection died
481
+ mid-handshake. Such a list is now refused at configuration time. An explicit `TLS_CHACHA20_POLY1305_SHA256`
482
+ without an injected implementation is refused too, rather than silently dropped: quietly presenting
483
+ a different fingerprint from the one you asked for is the worst outcome available to a package like
484
+ this one.
485
+
456
486
  Extension order matters because JA3 and JA4 hash the extension list **in wire order**, so it is most
457
487
  of what a fingerprinter reads. `pre_shared_key` is forced last whatever you ask for: RFC 8446
458
488
  §4.2.11 defines the binder transcript as the hello truncated just before the binders, which is a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tunnelfetch",
3
- "version": "1.8.4",
3
+ "version": "1.9.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",
@@ -137,7 +137,16 @@ 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. Every suite must be
141
+ * one this package can perform; an offer it cannot honour is a dead connection the moment a
142
+ * server selects it, so an unknown suite is refused here rather than on the wire.
143
+ * @property {Uint8Array[]} [extraExtensions] pre-encoded ClientHello extensions, appended before
144
+ * ordering. `extensionOrder` can only arrange extensions that were BUILT — it filters to what
145
+ * exists and sorts that — so ordering alone cannot produce an extension this package does not
146
+ * generate. Chromium sends several that it does not: `signed_certificate_timestamp` (18),
147
+ * `compress_certificate` (27), `session_ticket` (35), `application_settings` (17613) and ECH
148
+ * (65037). Supplying them here is the only way to close that gap, and it is the caller's job to
149
+ * encode them correctly — this package does not parse what it did not build.
141
150
  * @property {number[]} [sigSchemes] signature_algorithms to offer, in preference order.
142
151
  * @property {boolean | number} [grease] send GREASE (RFC 8701) reserved values in the cipher list,
143
152
  * the extension list (one at each end), supported_groups, supported_versions and key_share.
@@ -328,6 +337,38 @@ async function drive({ record, hostname, verifyPeer, options, deps, versions })
328
337
  ];
329
338
  if (!chacha) ciphers = ciphers.filter((c) => c !== CIPHER.TLS_CHACHA20_POLY1305_SHA256);
330
339
 
340
+ // A caller-supplied list was previously taken verbatim. That let a hello offer suites this
341
+ // package cannot perform — the CBC and RSA-key-exchange suites, most obviously — and the failure
342
+ // did not surface at configuration time. It surfaced after the ClientHello was on the wire, when
343
+ // a server took the offer up and the AEAD layer had nothing to build: a dead connection rather
344
+ // than a rejected config.
345
+ //
346
+ // profiles.js already states the principle for the profile system: "a fingerprint field this
347
+ // package cannot perform is an offer a server may take and then find unhonoured, which fails the
348
+ // connection rather than merely looking wrong". It applies just as much to `tls.ciphers`, and
349
+ // now does. CIPHER_PARAMS is the set this package knows how to key and seal.
350
+ if (options.ciphers) {
351
+ const unperformable = ciphers.filter((c) => !CIPHER_PARAMS[c]);
352
+ if (unperformable.length) {
353
+ throw new TlsError(
354
+ codes.CONFIG_INVALID,
355
+ `tls.ciphers offers ${unperformable.map((c) => `0x${c.toString(16).padStart(4, '0')}`).join(', ')}, ` +
356
+ 'which this package cannot perform. Offering a suite it cannot complete trades a ' +
357
+ 'fingerprint match for a dead connection the moment a server selects one — see the ' +
358
+ 'chrome profile, which drops the CBC and RSA-key-exchange suites for exactly this reason.',
359
+ { unperformable },
360
+ );
361
+ }
362
+ if (!chacha && options.ciphers.includes(CIPHER.TLS_CHACHA20_POLY1305_SHA256)) {
363
+ throw new TlsError(
364
+ codes.CONFIG_INVALID,
365
+ 'tls.ciphers offers TLS_CHACHA20_POLY1305_SHA256 but no ChaCha20 implementation was ' +
366
+ 'supplied. Pass one through `ciphers: { chacha20 }`, or drop the suite from the list — ' +
367
+ 'silently removing it would change the fingerprint you asked for without saying so.',
368
+ );
369
+ }
370
+ }
371
+
331
372
  // --- resumption offer ----------------------------------------------------------------------
332
373
  // Everything about the offered PSK that later steps need is derived once, up front: the Early
333
374
  // Secret and binder key here (they depend only on the PSK), the binder itself per hello (it
@@ -372,6 +413,7 @@ async function drive({ record, hostname, verifyPeer, options, deps, versions })
372
413
  groups,
373
414
  alpn,
374
415
  ciphers,
416
+ extraExtensions: options.extraExtensions ?? [],
375
417
  versions,
376
418
  extensionOrder: options.extensionOrder,
377
419
  sigSchemes: options.sigSchemes,
@@ -246,7 +246,14 @@ 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
+ extraExtensions: [
254
+ ...(options.extraExtensions ?? []),
255
+ ...(cookie ? [cookieExtension(cookie)] : []),
256
+ ],
250
257
  psk: offeredPsk && {
251
258
  identity: offeredPsk.identity,
252
259
  obfuscatedTicketAge: offeredPsk.obfuscatedTicketAge(),
@@ -13,7 +13,16 @@
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. Every suite must be
17
+ * one this package can perform; an offer it cannot honour is a dead connection the moment a
18
+ * server selects it, so an unknown suite is refused here rather than on the wire.
19
+ * @property {Uint8Array[]} [extraExtensions] pre-encoded ClientHello extensions, appended before
20
+ * ordering. `extensionOrder` can only arrange extensions that were BUILT — it filters to what
21
+ * exists and sorts that — so ordering alone cannot produce an extension this package does not
22
+ * generate. Chromium sends several that it does not: `signed_certificate_timestamp` (18),
23
+ * `compress_certificate` (27), `session_ticket` (35), `application_settings` (17613) and ECH
24
+ * (65037). Supplying them here is the only way to close that gap, and it is the caller's job to
25
+ * encode them correctly — this package does not parse what it did not build.
17
26
  * @property {number[]} [sigSchemes] signature_algorithms to offer, in preference order.
18
27
  * @property {boolean | number} [grease] send GREASE (RFC 8701) reserved values in the cipher list,
19
28
  * the extension list (one at each end), supported_groups, supported_versions and key_share.
@@ -160,9 +169,21 @@ export type TlsOptions = {
160
169
  */
161
170
  offerGroups?: number[] | undefined;
162
171
  /**
163
- * cipher suites to offer, in preference order.
172
+ * cipher suites to offer, in preference order. Every suite must be
173
+ * one this package can perform; an offer it cannot honour is a dead connection the moment a
174
+ * server selects it, so an unknown suite is refused here rather than on the wire.
164
175
  */
165
176
  ciphers?: number[] | undefined;
177
+ /**
178
+ * pre-encoded ClientHello extensions, appended before
179
+ * ordering. `extensionOrder` can only arrange extensions that were BUILT — it filters to what
180
+ * exists and sorts that — so ordering alone cannot produce an extension this package does not
181
+ * generate. Chromium sends several that it does not: `signed_certificate_timestamp` (18),
182
+ * `compress_certificate` (27), `session_ticket` (35), `application_settings` (17613) and ECH
183
+ * (65037). Supplying them here is the only way to close that gap, and it is the caller's job to
184
+ * encode them correctly — this package does not parse what it did not build.
185
+ */
186
+ extraExtensions?: Uint8Array<ArrayBufferLike>[] | undefined;
166
187
  /**
167
188
  * signature_algorithms to offer, in preference order.
168
189
  */