tunnelfetch 1.8.3 → 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 +30 -37
- package/package.json +1 -1
- package/src/tls/connect.js +43 -1
- package/src/tls/handshake.js +8 -1
- package/types/tls/connect.d.ts +23 -2
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
|
|
@@ -1047,43 +1077,6 @@ subject DN, and only the one anchor a chain lands on is ever decoded, so startup
|
|
|
1047
1077
|
the 380 KB bundle (133 KB gzipped) and a request that imports but does not use the package costs
|
|
1048
1078
|
0 ms.
|
|
1049
1079
|
|
|
1050
|
-
### Streaming APIs: turn HTTP/2 off
|
|
1051
|
-
|
|
1052
|
-
An SSE response from an LLM API is the opposite shape to everything else measured here: a small body
|
|
1053
|
-
arriving as hundreds of tiny events rather than a large one arriving in a few chunks. Over HTTP/2
|
|
1054
|
-
each of those events is a DATA frame with flow control and a `WINDOW_UPDATE` behind it; over
|
|
1055
|
-
HTTP/1.1 it is a chunked-encoding chunk and nothing else. These APIs do not multiplex, so the
|
|
1056
|
-
cheaper framing simply wins:
|
|
1057
|
-
|
|
1058
|
-
```js
|
|
1059
|
-
new Client({ connect, proxy, http2: false });
|
|
1060
|
-
```
|
|
1061
|
-
|
|
1062
|
-
Measured against a real streaming endpoint through a proxy, 20K tokens in and 8K tokens out:
|
|
1063
|
-
|
|
1064
|
-
| | CPU/request | per 1M requests |
|
|
1065
|
-
| --- | --- | --- |
|
|
1066
|
-
| HTTP/2 | 32 ms | $0.94 |
|
|
1067
|
-
| **HTTP/1.1** | **28 ms** | **$0.86** |
|
|
1068
|
-
| platform `fetch` — reference; it cannot use a proxy | 5 ms | $0.40 |
|
|
1069
|
-
|
|
1070
|
-
**13% for one option, and nothing is given up** — a request that never opens a second stream gains
|
|
1071
|
-
nothing from multiplexing.
|
|
1072
|
-
|
|
1073
|
-
Two things about this shape are worth knowing because they are counter-intuitive:
|
|
1074
|
-
|
|
1075
|
-
**The cost is flat in output length.** 512 tokens and 8000 tokens cost within 2 ms of each other.
|
|
1076
|
-
Events are batched by the server and V8 tiers up inside the request, and the two together flatten
|
|
1077
|
-
the curve completely. An earlier version of this section modelled it as linear from two nearby
|
|
1078
|
-
points and predicted a break-even 15x lower than the truth; do not extrapolate this from a slope.
|
|
1079
|
-
|
|
1080
|
-
**The request body is cheap.** A 20K-token prompt is ~108 KB of JSON to serialise, buffer, encrypt
|
|
1081
|
-
and frame, and it costs 4 ms on h1 or 6 ms on h2 — 15–20% of the request, not the dominant term.
|
|
1082
|
-
|
|
1083
|
-
For scale: at these sizes the model's own bill is about **$0.0088 per request**, so this package's
|
|
1084
|
-
CPU is **0.011%** of what you pay. Turn h2 off because it is free, not because it will show up on an
|
|
1085
|
-
invoice.
|
|
1086
|
-
|
|
1087
1080
|
### Streaming APIs, and what they cost
|
|
1088
1081
|
|
|
1089
1082
|
An SSE response from an LLM API is the opposite shape to everything else measured here: a small body
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tunnelfetch",
|
|
3
|
-
"version": "1.
|
|
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",
|
package/src/tls/connect.js
CHANGED
|
@@ -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,
|
package/src/tls/handshake.js
CHANGED
|
@@ -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
|
-
|
|
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(),
|
package/types/tls/connect.d.ts
CHANGED
|
@@ -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
|
*/
|