tunnelfetch 1.2.0 → 1.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tunnelfetch",
3
- "version": "1.2.0",
3
+ "version": "1.3.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",
@@ -0,0 +1,159 @@
1
+ // Request header order and case — the part of a fingerprint that needs no TLS inspection at all.
2
+ //
3
+ // The platform's `Headers` is the wrong wire representation and always was: it iterates
4
+ // lexicographically sorted and lowercased. So a request built through it goes out as
5
+ // `accept, accept-encoding, connection, host, referer, user-agent` whatever the caller wrote —
6
+ // alphabetical order, with `user-agent` last, which no real client does. curl sends:
7
+ //
8
+ // Host, User-Agent, Accept, Accept-Encoding, <the caller's headers, in order>,
9
+ // Content-Length, Content-Type
10
+ //
11
+ // captured off the wire from curl 8.21.0. Note where the framing headers go: last, AFTER the
12
+ // caller's. That is why the default order below carries a `'*'` marker rather than being a plain
13
+ // ranking — "everything else" belongs in the middle, not at the end.
14
+ //
15
+ // Case matters too, and only on HTTP/1.1: real clients send `Host:` and `X-Custom:`, not `host:`
16
+ // and `x-custom:`. HTTP/2 is the opposite — RFC 9113 s8.2.1 REQUIRES lowercase field names, and a
17
+ // server must treat an uppercase one as malformed. So this preserves the case it was given and the
18
+ // h2 path lowercases at the last moment, which is the only place that is correct.
19
+
20
+ import { HttpError, codes } from '../errors.js';
21
+
22
+ /**
23
+ * Default request header order, from curl 8.21.0 on the wire. `'*'` is where headers not named
24
+ * here go, in the order they were given — which is where curl puts the caller's own.
25
+ */
26
+ export const CURL_HEADER_ORDER = Object.freeze([
27
+ 'host',
28
+ 'user-agent',
29
+ 'accept',
30
+ 'accept-encoding',
31
+ '*',
32
+ // curl sends no Connection on HTTP/1.1 (keep-alive is the default and it stays silent), so
33
+ // there is no reference position for it. Grouped with the other connection-and-framing headers
34
+ // at the end, which is where curl puts the ones it does send.
35
+ 'connection',
36
+ 'content-length',
37
+ 'content-type',
38
+ ]);
39
+
40
+ /**
41
+ * An ordered, case-preserving header list.
42
+ *
43
+ * Deliberately not a `Headers` subclass and deliberately not backed by one: the whole point is to
44
+ * be the thing `Headers` is not. Lookup and mutation are case-insensitive, as HTTP requires;
45
+ * iteration returns names exactly as they were written, in the order they arrived.
46
+ */
47
+ export class OrderedHeaders {
48
+ /** @param {Headers | Iterable<[string, string]> | Record<string, string> | null} [init] */
49
+ constructor(init = null) {
50
+ /** @type {Array<[string, string]>} name as written, value */
51
+ this._list = [];
52
+ if (init == null) return;
53
+ const pairs =
54
+ typeof (/** @type {any} */ (init)[Symbol.iterator]) === 'function'
55
+ ? /** @type {Iterable<[string, string]>} */ (init)
56
+ : Object.entries(init);
57
+ for (const [name, value] of pairs) this.append(name, value);
58
+ }
59
+
60
+ _indexOf(name) {
61
+ const lower = String(name).toLowerCase();
62
+ return this._list.findIndex(([n]) => n.toLowerCase() === lower);
63
+ }
64
+
65
+ has(name) {
66
+ return this._indexOf(name) !== -1;
67
+ }
68
+
69
+ /** Comma-joined when a field appears more than once, matching `Headers.get`. */
70
+ get(name) {
71
+ const lower = String(name).toLowerCase();
72
+ const hits = this._list.filter(([n]) => n.toLowerCase() === lower).map(([, v]) => v);
73
+ return hits.length ? hits.join(', ') : null;
74
+ }
75
+
76
+ /**
77
+ * Replace IN PLACE when the field is already present, so setting a value does not move a header
78
+ * to the end and silently reorder the request. A caller who wrote `User-Agent` first and then
79
+ * had it overwritten should still see it first.
80
+ */
81
+ set(name, value) {
82
+ const at = this._indexOf(name);
83
+ if (at === -1) {
84
+ this.append(name, value);
85
+ return;
86
+ }
87
+ this._list[at] = [this._list[at][0], String(value)];
88
+ // A repeated field collapses to the first position, as `Headers.set` collapses to one value.
89
+ const lower = String(name).toLowerCase();
90
+ this._list = this._list.filter(([n], i) => i === at || n.toLowerCase() !== lower);
91
+ }
92
+
93
+ append(name, value) {
94
+ const n = String(name);
95
+ if (n === '') throw new HttpError(codes.HTTP_HEADER, 'header name must not be empty', { name });
96
+ this._list.push([n, String(value)]);
97
+ }
98
+
99
+ delete(name) {
100
+ const lower = String(name).toLowerCase();
101
+ this._list = this._list.filter(([n]) => n.toLowerCase() !== lower);
102
+ }
103
+
104
+ /**
105
+ * Put the fields into `order`, which names lowercased header names and may contain `'*'` to mark
106
+ * where everything unnamed goes. Fields keep their relative order within each group, so a
107
+ * caller's own sequence survives.
108
+ *
109
+ * @param {readonly string[]} order
110
+ */
111
+ reorder(order) {
112
+ const star = order.indexOf('*');
113
+ const rank = new Map();
114
+ order.forEach((name, i) => {
115
+ if (name !== '*') rank.set(name, i);
116
+ });
117
+ // With no '*', unnamed fields go last — the same rule the TLS extension order uses.
118
+ const fallback = star === -1 ? order.length : star;
119
+ this._list = this._list
120
+ .map((entry, i) => ({ entry, i, r: rank.get(entry[0].toLowerCase()) ?? fallback }))
121
+ .sort((a, b) => a.r - b.r || a.i - b.i)
122
+ .map((x) => x.entry);
123
+ }
124
+
125
+ /** @returns {Array<[string, string]>} names as written, in order */
126
+ entries() {
127
+ return this._list.map(([n, v]) => [n, v]);
128
+ }
129
+
130
+ /** Lowercased names, for HTTP/2 where RFC 9113 s8.2.1 requires them. */
131
+ lowercased() {
132
+ return this._list.map(([n, v]) => [n.toLowerCase(), v]);
133
+ }
134
+
135
+ [Symbol.iterator]() {
136
+ return this.entries()[Symbol.iterator]();
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Read the caller's header names in the order and case they wrote them, BEFORE anything hands them
142
+ * to `Request`, which is where both are lost.
143
+ *
144
+ * Recovers what is recoverable and no more: an array of pairs or a plain object still carries the
145
+ * caller's order, a `Headers` or a `Request` does not — those were normalised before this package
146
+ * ever saw them, and there is nothing here to reconstruct.
147
+ *
148
+ * @param {RequestInfo | URL} input
149
+ * @param {RequestInit} [init]
150
+ * @returns {Array<[string, string]> | null} null when the caller's order was already gone
151
+ */
152
+ export function callerHeaderOrder(input, init) {
153
+ const raw = init?.headers ?? (input instanceof Request ? null : undefined);
154
+ if (raw == null) return null;
155
+ if (typeof Headers !== 'undefined' && raw instanceof Headers) return null;
156
+ if (Array.isArray(raw)) return raw.map(([n, v]) => [String(n), String(v)]);
157
+ if (typeof raw === 'object') return Object.entries(raw).map(([n, v]) => [n, String(v)]);
158
+ return null;
159
+ }
package/src/client.js CHANGED
@@ -15,6 +15,8 @@ import { ByteReader, ByteWriter, UnexpectedEofError, concat, utf8 } from './util
15
15
  import { serializeRequestHead } from './http1/request.js';
16
16
  import { bodyFraming, readResponseBody, readResponseHead } from './http1/response.js';
17
17
  import { acceptEncodingFor, decodeBody } from './client/decode.js';
18
+ import { OrderedHeaders, CURL_HEADER_ORDER, callerHeaderOrder } from './client/header-order.js';
19
+ import { applyProfile } from './profiles.js';
18
20
  import { CookieJar } from './client/cookies.js';
19
21
  import { DEFAULT_MAX_REDIRECTS, nextRequest, shouldRedirect } from './client/redirect.js';
20
22
  import { ConnectionPool, poolKey } from './pool.js';
@@ -82,6 +84,20 @@ const NULL_BODY_STATUS = new Set([101, 204, 205, 304]);
82
84
  * the wire bytes it saves do not pay that back — see the README. The reason to turn it on is
83
85
  * matching a browser's Accept-Encoding, not saving CPU.
84
86
  * @property {boolean} [keepAlive] default true.
87
+ * @property {import('./profiles.js').FingerprintProfile} [profile] one coherent network identity
88
+ * instead of a dozen knobs that can disagree — TLS, HTTP/2, header order and default headers
89
+ * together. Explicit options win over it. A profile that declares capabilities this package
90
+ * cannot perform is REFUSED rather than silently reduced: see `profiles.chrome`.
91
+ * @property {readonly string[]} [headerOrder] request header names, lowercased, in the order to
92
+ * emit them; `'*'` marks where headers not named go, in the order the caller gave them. Defaults
93
+ * to curl's (`CURL_HEADER_ORDER`). The platform `Headers` sorts alphabetically and lowercases, so
94
+ * without this a request goes out with `user-agent` last, which no real client does.
95
+ * @property {string[]} [http2PseudoHeaderOrder] request pseudo-headers in the order to emit them.
96
+ * Defaults to curl's. Any of the four omitted is appended rather than dropped: RFC 9113 s8.3.1
97
+ * makes all four mandatory, so a request missing one is malformed rather than merely unusual.
98
+ * @property {Record<string, 'incremental'|'without'|'never'>} [http2HpackIndexing] per-field HPACK
99
+ * indexing. Which fields enter the dynamic table is read by an Akamai-style h2 fingerprint.
100
+ * Defaults to curl's: everything incremental except `:path`.
85
101
  * @property {Array<[number, number]>} [http2Settings] the HTTP/2 SETTINGS flight, as [id, value]
86
102
  * pairs. Order is significant — an Akamai-style h2 fingerprint reads the ids in the order they
87
103
  * are sent — so this replaces the flight rather than merging into it. Defaults to curl's. The
@@ -106,7 +122,7 @@ export class Client {
106
122
  * @param {ClientOptions} [options]
107
123
  */
108
124
  constructor(options = {}) {
109
- this.options = snapshotOptions(options);
125
+ this.options = snapshotOptions(applyProfile(options));
110
126
  this.pool = new ConnectionPool(options.pool);
111
127
  // HTTP/2 connections are NOT pooled the way h1 is: one connection multiplexes many concurrent
112
128
  // streams, so it is not checked out per request. It lives here, keyed exactly like the h1 pool,
@@ -237,6 +253,9 @@ export function install(options = {}) {
237
253
 
238
254
  async function performFetch(client, input, init) {
239
255
  const o = client.options;
256
+ // Read the caller's header names and case before `Request` normalises them away: the platform
257
+ // `Headers` sorts lexicographically and lowercases, so by the line below both are already gone.
258
+ const callerOrder = callerHeaderOrder(input, init);
240
259
  const request = new Request(input, init);
241
260
  const redirectMode = init?.redirect ?? request.redirect ?? 'follow';
242
261
  const maxRedirects = o.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
@@ -251,7 +270,9 @@ async function performFetch(client, input, init) {
251
270
  let current = {
252
271
  url: new URL(request.url),
253
272
  method: request.method,
254
- headers: new Headers(request.headers),
273
+ // The caller's own list when it survived, so its order and case reach the wire; otherwise the
274
+ // normalised Headers, which is all there is left to work from.
275
+ headers: new OrderedHeaders(callerOrder ?? request.headers),
255
276
  body,
256
277
  };
257
278
  const history = [];
@@ -446,6 +467,12 @@ function registerHttp2(client, key, conn) {
446
467
  // `tls`, and one being reachable while the other was not made "the fingerprint is
447
468
  // configurable" only half true.
448
469
  ...(client.options.http2Settings ? { settings: client.options.http2Settings } : {}),
470
+ ...(client.options.http2PseudoHeaderOrder
471
+ ? { pseudoHeaderOrder: client.options.http2PseudoHeaderOrder }
472
+ : {}),
473
+ ...(client.options.http2HpackIndexing
474
+ ? { hpackIndexing: client.options.http2HpackIndexing }
475
+ : {}),
449
476
  onClose: () => {
450
477
  client._h2conns.delete(h2);
451
478
  // Only drop the keyed entry if it is still this connection; a newer one may have replaced it.
@@ -723,11 +750,15 @@ async function sendAndReceiveH2(client, h2, current, { deadlines }) {
723
750
  */
724
751
  function buildH2Request(client, current, target) {
725
752
  const o = client.options;
726
- const headers = new Headers(current.headers);
753
+ const headers = new OrderedHeaders(current.headers);
727
754
  const defaultPort = current.url.protocol === 'https:' ? 443 : 80;
728
755
  const authority =
729
756
  target.port === defaultPort ? current.url.hostname : `${current.url.hostname}:${target.port}`;
730
757
 
758
+ // Profile headers are defaults: a request that sets its own User-Agent keeps it.
759
+ for (const [name, value] of client.options.profileHeaders ?? []) {
760
+ if (!headers.has(name)) headers.set(name, value);
761
+ }
731
762
  if (!headers.has('accept')) headers.set('accept', '*/*');
732
763
  if (!headers.has('accept-encoding') && o.decompress !== false) {
733
764
  headers.set('accept-encoding', acceptEncodingFor(o.decoders));
@@ -747,12 +778,12 @@ function buildH2Request(client, current, target) {
747
778
  else if (['POST', 'PUT', 'PATCH'].includes(current.method)) headers.set('content-length', '0');
748
779
  else headers.delete('content-length');
749
780
 
750
- // Headers iterates lowercased (RFC 9113 s8.2.1 requires lowercase names on the wire) — which is
751
- // also why h1 loses caller order; h2 is no different here. Pseudo-header ORDER, the part a
752
- // fingerprinter reads, is fixed in http2/connection.js buildRequestFields, not here.
753
- const out = [];
754
- for (const [k, v] of headers) out.push([k, v]);
755
- return { authority, headers: out };
781
+ // Lowercased at the last moment, and only here: RFC 9113 s8.2.1 REQUIRES lowercase field names
782
+ // on an h2 wire and a server must treat an uppercase one as malformed. h1 is the opposite — real
783
+ // clients send `Host:`, not `host:` — which is why the case is carried this far and dropped only
784
+ // on this path. Pseudo-header order is fixed in http2/connection.js buildRequestFields.
785
+ headers.reorder(o.headerOrder ?? CURL_HEADER_ORDER);
786
+ return { authority, headers: headers.lowercased() };
756
787
  }
757
788
 
758
789
  function wantsKeepAlive(headInfo) {
@@ -803,13 +834,19 @@ function requestTarget(url) {
803
834
  }
804
835
 
805
836
  function buildHeaders(client, current, target) {
806
- const headers = new Headers(current.headers);
837
+ const headers = new OrderedHeaders(current.headers);
807
838
  // Host is derived from the URL and never carried across a redirect; the default port is omitted
808
839
  // because a server matching virtual hosts on the literal Host value expects it that way.
809
840
  const defaultPort = current.url.protocol === 'https:' ? 443 : 80;
810
841
  const hostValue =
811
842
  target.port === defaultPort ? current.url.hostname : `${current.url.hostname}:${target.port}`;
812
843
 
844
+ // Profile headers are defaults: a request that sets its own User-Agent keeps it. Applied on both
845
+ // the h1 and h2 paths, which build their headers separately — the h2 side had this and the h1
846
+ // side did not, so a profile's User-Agent reached an h2 request and vanished from an h1 one.
847
+ for (const [name, value] of client.options.profileHeaders ?? []) {
848
+ if (!headers.has(name)) headers.set(name, value);
849
+ }
813
850
  if (!headers.has('accept')) headers.set('accept', '*/*');
814
851
  if (!headers.has('accept-encoding') && client.options.decompress !== false) {
815
852
  // Never advertise br or zstd: the runtime has no DecompressionStream for either, so the
@@ -838,12 +875,11 @@ function buildHeaders(client, current, target) {
838
875
  else if (['POST', 'PUT', 'PATCH'].includes(current.method)) headers.set('content-length', '0');
839
876
  else headers.delete('content-length');
840
877
 
841
- const ordered = [['host', hostValue]];
842
- for (const [k, v] of headers) {
843
- if (k === 'host') continue;
844
- ordered.push([k, v]);
845
- }
846
- return ordered;
878
+ // Host is set rather than prepended so the order below decides where it goes — which for curl
879
+ // is first, but for a caller matching something else may not be.
880
+ headers.set('Host', hostValue);
881
+ headers.reorder(client.options.headerOrder ?? CURL_HEADER_ORDER);
882
+ return headers.entries();
847
883
  }
848
884
 
849
885
  export { CookieJar, ConnectionPool, utf8 };
@@ -117,6 +117,13 @@ export class Http2Retryable extends Http2Error {}
117
117
  * @property {number} [maxConcurrentStreams] our advertised SETTINGS_MAX_CONCURRENT_STREAMS.
118
118
  * @property {number} [maxHeaderTableSize] our advertised SETTINGS_HEADER_TABLE_SIZE.
119
119
  * @property {number} [maxHeaderListSize] self-protection cap on a decoded response header list.
120
+ * @property {string[]} [pseudoHeaderOrder] request pseudo-headers in the order to emit them.
121
+ * Defaults to curl's `[':method', ':scheme', ':authority', ':path']`. Any of the four left out is
122
+ * appended rather than dropped — RFC 9113 s8.3.1 makes all four mandatory and a request missing
123
+ * one is malformed, which is not a fingerprint choice anyone should be able to make by accident.
124
+ * @property {Record<string, 'incremental'|'without'|'never'>} [hpackIndexing] per-field HPACK
125
+ * indexing. Which fields enter the dynamic table is part of the fingerprint. Defaults to curl's:
126
+ * everything incremental except `:path`, which is sent without indexing.
120
127
  * @property {Array<[number, number]>} [settings] the SETTINGS flight sent in the connection
121
128
  * preface, as [id, value] pairs. Order is significant — an Akamai-style HTTP/2 fingerprint reads
122
129
  * the ids in the order they are sent — so this replaces the flight entirely rather than merging.
@@ -202,6 +209,10 @@ export class Http2Connection {
202
209
  this._maxHeaderBlockBytes = opts.maxHeaderBlockBytes ?? 262144;
203
210
  /** @type {Array<[number, number]> | null} the SETTINGS flight, ids and order included */
204
211
  this._settingsFlight = opts.settings ?? null;
212
+ // The rest of what an Akamai-style h2 fingerprint reads: the pseudo-header order and which
213
+ // fields go into the HPACK dynamic table. Both default to curl's, both captured off the wire.
214
+ this._pseudoHeaderOrder = opts.pseudoHeaderOrder ?? null;
215
+ this._hpackIndexing = opts.hpackIndexing ?? null;
205
216
  this._expectFirstSettings = true;
206
217
 
207
218
  this._fatal = null; // set once; rejects every stream and every future request
@@ -321,7 +332,10 @@ export class Http2Connection {
321
332
  }
322
333
  const hasBody = body != null && body.byteLength > 0;
323
334
 
324
- const fields = buildRequestFields({ method, scheme, authority, path, headers });
335
+ const fields = buildRequestFields(
336
+ { method, scheme, authority, path, headers },
337
+ { pseudoHeaderOrder: this._pseudoHeaderOrder, hpackIndexing: this._hpackIndexing },
338
+ );
325
339
  const block = encodeHeaderBlock(fields);
326
340
  this._sendHeaderBlock(id, block, !hasBody);
327
341
  stream.localEnded = !hasBody;
@@ -1206,14 +1220,32 @@ export class Http2Connection {
1206
1220
  * headers: Array<[string, string]> }} req
1207
1221
  * @returns {import('./hpack.js').HpackField[]}
1208
1222
  */
1209
- export function buildRequestFields({ method, scheme, authority, path, headers }) {
1223
+ export function buildRequestFields({ method, scheme, authority, path, headers }, opts = {}) {
1210
1224
  const pseudo = { ':method': method, ':scheme': scheme, ':authority': authority, ':path': path };
1225
+ const order = opts.pseudoHeaderOrder ?? PSEUDO_HEADER_ORDER;
1226
+ // Which fields go into the dynamic table is itself part of the fingerprint: an Akamai-style h2
1227
+ // hash reads the HPACK representation, and curl indexes everything except :path. A caller
1228
+ // matching another client needs both this and the order, so both are configurable — with the
1229
+ // caller's map consulted first and curl's rule as the default.
1230
+ const indexingFor = (name) =>
1231
+ opts.hpackIndexing?.[name] ?? (name === ':path' ? 'without' : 'incremental');
1232
+
1211
1233
  const fields = [];
1234
+ const seen = new Set();
1235
+ for (const name of order) {
1236
+ if (!(name in pseudo) || seen.has(name)) continue;
1237
+ seen.add(name);
1238
+ fields.push({ name, value: pseudo[name], indexing: indexingFor(name) });
1239
+ }
1240
+ // A caller-supplied order that omits a pseudo-header would produce a malformed request
1241
+ // (RFC 9113 s8.3.1 makes all four mandatory for a request), so the missing ones are appended in
1242
+ // curl's order rather than silently dropped.
1212
1243
  for (const name of PSEUDO_HEADER_ORDER) {
1213
- fields.push({ name, value: pseudo[name], indexing: name === ':path' ? 'without' : 'incremental' });
1244
+ if (seen.has(name)) continue;
1245
+ fields.push({ name, value: pseudo[name], indexing: indexingFor(name) });
1214
1246
  }
1215
1247
  for (const [name, value] of headers) {
1216
- fields.push({ name, value, indexing: 'incremental' });
1248
+ fields.push({ name, value, indexing: indexingFor(name) });
1217
1249
  }
1218
1250
  return fields;
1219
1251
  }
package/src/index.js CHANGED
@@ -44,3 +44,4 @@ import { createFetch } from './client.js';
44
44
  * platform's own fetch cannot serve; see the README for why that is not defaulted.
45
45
  */
46
46
  export const fetch = createFetch();
47
+ export { profiles, curl, chrome, applyProfile } from './profiles.js';
@@ -0,0 +1,150 @@
1
+ // Fingerprint profiles: one coherent network identity, instead of a dozen knobs that can disagree.
2
+ //
3
+ // Every field a fingerprinter reads is individually configurable — cipher list, groups, signature
4
+ // algorithms, ALPN, TLS versions, ClientHello extension order, GREASE, request header order,
5
+ // HTTP/2 SETTINGS, pseudo-header order, HPACK indexing, Accept-Encoding. That is necessary and it
6
+ // is also a trap: nothing stopped a caller assembling a Chrome User-Agent on top of curl's TLS and
7
+ // curl's HTTP/2, which is a combination no real client produces and a detector reads instantly.
8
+ //
9
+ // A profile is the whole identity or none of it. It supplies defaults for every layer at once, and
10
+ // it declares what it REQUIRES — because a profile that quietly drops the half of itself this
11
+ // runtime cannot perform would recreate exactly the incoherence it exists to prevent.
12
+ //
13
+ // The values are captured, not recalled. curl 8.21.0 / OpenSSL 3.6.3 and Chromium, both read off
14
+ // the wire on 2026-08-01. See test/tls/fingerprint.test.js and test/tls/grease.test.js.
15
+
16
+ import { ConfigError, codes } from './errors.js';
17
+ import { CURL_EXTENSION_ORDER, SHUFFLE_EXTENSIONS } from './tls/handshake-messages.js';
18
+ import { CURL_HEADER_ORDER } from './client/header-order.js';
19
+
20
+ /**
21
+ * @typedef {object} FingerprintProfile
22
+ * @property {string} name
23
+ * @property {object} [tls] merged into `tls`
24
+ * @property {readonly string[]} [headerOrder]
25
+ * @property {Array<[number, number]>} [http2Settings]
26
+ * @property {string[]} [http2PseudoHeaderOrder]
27
+ * @property {Record<string, string>} [http2HpackIndexing]
28
+ * @property {Array<[string, string]>} [headers] default request headers, in order
29
+ * @property {string[]} [requires] capabilities the caller must inject for this identity to be
30
+ * honest: `'cipher:chacha20'`, `'group:x25519mlkem768'`, `'decoder:br'`, `'decoder:zstd'`
31
+ */
32
+
33
+ /**
34
+ * curl 8.21.0 / OpenSSL 3.6.3. Complete: every layer was captured, and everything it offers is
35
+ * something this package can actually perform. This is the default identity.
36
+ */
37
+ export const curl = Object.freeze({
38
+ name: 'curl/8.21.0',
39
+ tls: Object.freeze({
40
+ alpn: ['h2', 'http/1.1'],
41
+ extensionOrder: CURL_EXTENSION_ORDER,
42
+ grease: false, // curl does not GREASE
43
+ }),
44
+ headerOrder: CURL_HEADER_ORDER,
45
+ headers: Object.freeze([['User-Agent', 'curl/8.21.0']]),
46
+ // Captured: MAX_CONCURRENT_STREAMS, INITIAL_WINDOW_SIZE, ENABLE_PUSH, in that order.
47
+ http2Settings: Object.freeze([[3, 100], [4, 10485760], [2, 0]]),
48
+ http2PseudoHeaderOrder: Object.freeze([':method', ':scheme', ':authority', ':path']),
49
+ http2HpackIndexing: Object.freeze({ ':path': 'without' }),
50
+ requires: Object.freeze([]),
51
+ });
52
+
53
+ /**
54
+ * Chromium, TLS layer captured off the wire.
55
+ *
56
+ * INCOMPLETE ON PURPOSE, and it refuses to be used as though it were not. Two things are missing
57
+ * and neither can be papered over:
58
+ *
59
+ * * Chromium offers TLS_CHACHA20_POLY1305_SHA256 and the X25519MLKEM768 group, and this package
60
+ * implements neither. A ClientHello is an OFFER: a server may take either, and a client that
61
+ * then cannot complete the handshake has traded a fingerprint mismatch for a dead connection.
62
+ * Both are reachable by injection, which is why they are listed in `requires` rather than
63
+ * silently dropped.
64
+ * * Chromium's HTTP/2 preface was not captured — capturing it needs a TLS server the browser
65
+ * will trust, which is a different exercise. So this profile carries no h2 layer, and using it
66
+ * with HTTP/2 enabled would produce a Chromium ClientHello above a curl h2 preface: precisely
67
+ * the split identity a profile exists to prevent.
68
+ *
69
+ * `applyProfile` refuses both cases with a message naming what is missing.
70
+ */
71
+ export const chrome = Object.freeze({
72
+ name: 'chromium (TLS layer only)',
73
+ tls: Object.freeze({
74
+ // 16 suites, GREASE excluded — it is added by the grease option, not carried in the list.
75
+ ciphers: Object.freeze([
76
+ 0x1301, 0x1302, 0x1303, 0xc02b, 0xc02f, 0xc02c, 0xc030, 0xcca9,
77
+ 0xcca8, 0xc013, 0xc014, 0x009c, 0x009d, 0x002f, 0x0035,
78
+ ]),
79
+ groups: Object.freeze([0x11ec, 0x001d, 0x0017, 0x0018]),
80
+ sigSchemes: Object.freeze([0x0403, 0x0804, 0x0401, 0x0503, 0x0805, 0x0501, 0x0806, 0x0601]),
81
+ alpn: ['h2', 'http/1.1'],
82
+ // Measured: two hellos, identical extension set, entirely different orders. Chromium shuffles.
83
+ extensionOrder: SHUFFLE_EXTENSIONS,
84
+ grease: true,
85
+ }),
86
+ headers: Object.freeze([['Accept-Encoding', 'gzip, deflate, br, zstd']]),
87
+ http2Settings: null, // not captured — see above
88
+ requires: Object.freeze([
89
+ 'cipher:chacha20',
90
+ 'group:x25519mlkem768',
91
+ 'decoder:br',
92
+ 'decoder:zstd',
93
+ 'http2:captured',
94
+ ]),
95
+ });
96
+
97
+ /** @type {Record<string, FingerprintProfile>} */
98
+ export const profiles = Object.freeze({ curl, chrome });
99
+
100
+ /**
101
+ * Fold a profile into a Client's options, and refuse an identity that cannot be honoured.
102
+ *
103
+ * Explicit options WIN over the profile: a caller who names a field meant to name it, and silently
104
+ * overriding them would make the profile impossible to adjust. The profile fills what was not said.
105
+ *
106
+ * @param {object} options as given to the Client
107
+ * @returns {object} options with the profile folded in
108
+ */
109
+ export function applyProfile(options) {
110
+ const p = options.profile;
111
+ if (!p) return options;
112
+ if (typeof p !== 'object' || !p.name) {
113
+ throw new ConfigError(
114
+ codes.CONFIG_INVALID,
115
+ 'profile must be a fingerprint profile object; see `profiles` for the built-in ones',
116
+ );
117
+ }
118
+
119
+ const missing = [];
120
+ for (const need of p.requires ?? []) {
121
+ const [kind, what] = need.split(':');
122
+ if (kind === 'decoder' && !options.decoders?.[what]) missing.push(need);
123
+ if (kind === 'cipher' && !options.ciphers?.[what]) missing.push(need);
124
+ if (kind === 'group' && !options.groups?.[what]) missing.push(need);
125
+ // A profile with no captured h2 layer must not be run over HTTP/2, or it presents this
126
+ // identity's ClientHello above a different client's preface.
127
+ if (kind === 'http2' && options.http2 !== false) missing.push(need);
128
+ }
129
+ if (missing.length) {
130
+ throw new ConfigError(
131
+ codes.CONFIG_INVALID,
132
+ `the "${p.name}" profile cannot be presented honestly: ${missing.join(', ')} ` +
133
+ `${missing.length === 1 ? 'is' : 'are'} missing. A fingerprint field this package cannot ` +
134
+ 'perform is an offer a server may take and then find unhonoured, which fails the ' +
135
+ 'connection rather than merely looking wrong. Supply the missing pieces (see `decoders`, ' +
136
+ '`ciphers`, `groups`) or set `http2: false`, or use a profile that is complete.',
137
+ { profile: p.name, missing },
138
+ );
139
+ }
140
+
141
+ const out = { ...options };
142
+ if (p.tls) out.tls = { ...p.tls, ...(options.tls ?? {}) };
143
+ for (const key of ['headerOrder', 'http2Settings', 'http2PseudoHeaderOrder', 'http2HpackIndexing']) {
144
+ if (options[key] === undefined && p[key] != null) out[key] = p[key];
145
+ }
146
+ // Profile headers are DEFAULTS: a request that sets its own User-Agent keeps it. They are folded
147
+ // in per request rather than here, so this only records them.
148
+ if (p.headers) out.profileHeaders = p.headers;
149
+ return out;
150
+ }
@@ -136,7 +136,12 @@ function expectServerHello(msg, offers12) {
136
136
  * supported group; a HelloRetryRequest recovers any other choice at the cost of a round trip.
137
137
  * @property {number[]} [ciphers] cipher suites to offer, in preference order.
138
138
  * @property {number[]} [sigSchemes] signature_algorithms to offer, in preference order.
139
- * @property {number[]} [extensionOrder] ClientHello extension types, in the order to emit them.
139
+ * @property {boolean | number} [grease] send GREASE (RFC 8701) reserved values in the cipher list,
140
+ * the extension list (one at each end), supported_groups, supported_versions and key_share.
141
+ * Default false, because curl does not GREASE — Chromium does. A number is a seed, which makes
142
+ * the hello reproducible; `true` draws one from `deps.randomBytes`. A server that negotiates a
143
+ * GREASE value is refused with a typed error naming it.
144
+ * @property {number[] | 'shuffle'} [extensionOrder] ClientHello extension types, in the order to emit them.
140
145
  * JA3 and JA4 hash the extension list in WIRE ORDER, so this is most of what a fingerprinter
141
146
  * reads. Defaults to curl's order (`CURL_EXTENSION_ORDER`). Extensions not named keep their
142
147
  * natural position at the end; `pre_shared_key` is always last whatever is asked, because RFC
@@ -334,6 +339,7 @@ async function drive({ record, hostname, verifyPeer, options, deps, versions })
334
339
  versions,
335
340
  extensionOrder: options.extensionOrder,
336
341
  sigSchemes: options.sigSchemes,
342
+ grease: options.grease ?? false,
337
343
  random: options.clientRandom,
338
344
  legacySessionId: options.legacySessionId,
339
345
  psk: pskOffer && {
@@ -26,6 +26,16 @@ function ext(type, body) {
26
26
  return new Builder().u16(type).vector(2, body).build();
27
27
  }
28
28
 
29
+ /**
30
+ * An extension of an arbitrary type with an arbitrary body. Exists for GREASE (RFC 8701), whose
31
+ * whole point is to carry a reserved type this package assigns no meaning to.
32
+ * @param {number} type
33
+ * @param {Uint8Array} body
34
+ */
35
+ export function encodeRawExtension(type, body) {
36
+ return ext(type, body);
37
+ }
38
+
29
39
  // ------------------------------------------------------------------ encoders (ClientHello)
30
40
 
31
41
  /**
@@ -0,0 +1,109 @@
1
+ // GREASE (RFC 8701) — reserved values a client sprinkles through its ClientHello so that servers
2
+ // and middleboxes stay tolerant of values they do not recognise. A peer MUST ignore them; one that
3
+ // negotiates a GREASE value is broken, and this file makes that refusal explicit rather than
4
+ // leaving it to a downstream "no parameters for this suite" check whose message would blame the
5
+ // offer list.
6
+ //
7
+ // curl does not GREASE. Chromium does, and its placement was captured off the wire from this
8
+ // machine's Chromium rather than recalled — two ClientHellos, compared:
9
+ //
10
+ // ciphers one GREASE value, FIRST
11
+ // extensions one GREASE extension FIRST (empty) and one LAST (a single zero byte)
12
+ // supported_groups one GREASE value, FIRST
13
+ // supported_versions one GREASE value, FIRST
14
+ // key_share one GREASE entry FIRST, carrying a one-byte key
15
+ // ALPN none
16
+ // signature_algorithms none
17
+ //
18
+ // The same capture settled something that would otherwise have been guessed wrong: Chromium
19
+ // SHUFFLES its extension order on every connection. The two hellos shared an identical non-GREASE
20
+ // extension set and had entirely different orders, with GREASE first and last both times and a
21
+ // different GREASE value each time. So "match Chrome's extension order" is not a fixed list — it
22
+ // is a shuffle. See `shuffleExtensions`.
23
+
24
+ /** The sixteen reserved values (RFC 8701 s2): 0x0A0A, 0x1A1A, ... 0xFAFA. */
25
+ export const GREASE_VALUES = Object.freeze(
26
+ Array.from({ length: 16 }, (_, i) => (i << 12) | 0x0a00 | (i << 4) | 0x0a),
27
+ );
28
+
29
+ /** @param {number} v @returns {boolean} */
30
+ export function isGrease(v) {
31
+ return (v & 0x0f0f) === 0x0a0a && v >>> 8 === (v & 0xff);
32
+ }
33
+
34
+ /**
35
+ * A deterministic-from-seed source of GREASE values and shuffles.
36
+ *
37
+ * Seeded rather than ad-hoc `Math.random` for two reasons: this package forbids ambient randomness
38
+ * in `src/` (repo-hygiene enforces it, so that every byte on the wire is reproducible in a test),
39
+ * and a fingerprint that cannot be reproduced cannot be asserted byte-for-byte.
40
+ *
41
+ * @param {number} seed
42
+ */
43
+ export function greaseSource(seed) {
44
+ let s = seed >>> 0 || 0x9e3779b9;
45
+ const next = () => {
46
+ s ^= s << 13;
47
+ s >>>= 0;
48
+ s ^= s >> 17;
49
+ s ^= s << 5;
50
+ s >>>= 0;
51
+ return s;
52
+ };
53
+ const used = new Set();
54
+ return {
55
+ /** A GREASE value not yet handed out in this hello, since Chromium never repeats one. */
56
+ take() {
57
+ for (let i = 0; i < 64; i++) {
58
+ const v = GREASE_VALUES[next() % GREASE_VALUES.length];
59
+ if (!used.has(v)) {
60
+ used.add(v);
61
+ return v;
62
+ }
63
+ }
64
+ /* c8 ignore next */
65
+ return GREASE_VALUES[used.size % GREASE_VALUES.length];
66
+ },
67
+ next,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Fisher-Yates over the middle of the extension list, leaving the ends alone.
73
+ *
74
+ * The first and last positions are not free: Chromium pins a GREASE extension to each, and
75
+ * `pre_shared_key` MUST be last of all (RFC 8446 s4.2.11 — the binder transcript is the hello
76
+ * truncated just before the binders, a range that only exists if nothing follows them). So the
77
+ * shuffle covers everything between the fixed ends and nothing else.
78
+ *
79
+ * @param {Array<Uint8Array>} parts encoded extensions, already ordered
80
+ * @param {{next: () => number}} rng
81
+ * @param {(e: Uint8Array) => number} typeOf
82
+ * @param {number} pskType
83
+ * @returns {Array<Uint8Array>}
84
+ */
85
+ export function shuffleExtensions(parts, rng, typeOf, pskType) {
86
+ const out = [...parts];
87
+ // Anything pinned at either end stays put: leading GREASE, and trailing GREASE or pre_shared_key.
88
+ let lo = 0;
89
+ while (lo < out.length && isGrease(typeOf(out[lo]))) lo++;
90
+ let hi = out.length - 1;
91
+ while (hi > lo && (isGrease(typeOf(out[hi])) || typeOf(out[hi]) === pskType)) hi--;
92
+
93
+ for (let i = hi; i > lo; i--) {
94
+ const j = lo + (rng.next() % (i - lo + 1));
95
+ [out[i], out[j]] = [out[j], out[i]];
96
+ }
97
+ return out;
98
+ }
99
+
100
+ /**
101
+ * A GREASE key_share entry: a reserved group with a single-byte key, which is what Chromium sends.
102
+ * The byte is fixed rather than random — it is never used for anything, and a value that varies
103
+ * would only make the hello harder to assert on.
104
+ *
105
+ * @param {number} group
106
+ */
107
+ export function greaseKeyShare(group) {
108
+ return { group, keyExchange: Uint8Array.of(0x00) };
109
+ }
@@ -11,6 +11,7 @@
11
11
  import { TlsError, TlsUnsupportedError, CertificateError, codes, hex16 } from '../errors.js';
12
12
  import { concat, equal, timingSafeEqual, utf8 } from '../util/bytes.js';
13
13
  import { Builder, Cursor, vector, handshakeMessage } from './wire.js';
14
+ import { greaseSource, greaseKeyShare, shuffleExtensions, isGrease } from './grease.js';
14
15
  // der.js is a strict ASN.1 reader with no trust policy in it; the signature-format conversion
15
16
  // lives there so the certificate path builder and this file cannot drift apart.
16
17
  import { ecdsaDerToRaw } from '../trust/der.js';
@@ -49,6 +50,7 @@ import {
49
50
  encodePreSharedKey,
50
51
  encodePskKeyExchangeModes,
51
52
  encodeRenegotiationInfo,
53
+ encodeRawExtension,
52
54
  encodeServerName,
53
55
  encodeSignatureAlgorithms,
54
56
  encodeStatusRequest,
@@ -230,6 +232,9 @@ export async function deriveSharedSecret(group, privateKey, peerKey) {
230
232
  * whatever the caller asks for, because RFC 8446 s4.2.11 defines the binder transcript as the hello
231
233
  * truncated just before the binders — a range that only exists if nothing follows them.
232
234
  */
235
+ /** `extensionOrder: SHUFFLE_EXTENSIONS` reproduces what Chromium does — see grease.js. */
236
+ export const SHUFFLE_EXTENSIONS = 'shuffle';
237
+
233
238
  export const CURL_EXTENSION_ORDER = Object.freeze([
234
239
  EXTENSION.renegotiation_info,
235
240
  EXTENSION.server_name,
@@ -282,8 +287,16 @@ export function buildClientHello({
282
287
  extensionOrder = CURL_EXTENSION_ORDER,
283
288
  extraExtensions = [],
284
289
  psk = null,
290
+ grease = false,
285
291
  randomBytes = defaultRandom,
286
292
  }) {
293
+ // A seed rather than ambient randomness: repo-hygiene forbids Math.random in src/ so that every
294
+ // byte this package puts on the wire is reproducible in a test, and a fingerprint that cannot be
295
+ // reproduced cannot be asserted byte-for-byte.
296
+ const g = grease === false || grease == null
297
+ ? null
298
+ : greaseSource(typeof grease === 'number' ? grease : ((randomBytes(4)[0] << 24) |
299
+ (randomBytes(4)[1] << 16) | (randomBytes(4)[2] << 8) | randomBytes(4)[3]) >>> 0);
287
300
  const clientRandom = random ?? randomBytes(32);
288
301
  if (clientRandom.byteLength !== 32) {
289
302
  throw new TlsError(codes.CONFIG_INVALID, `ClientHello.random must be 32 bytes, got ${clientRandom.byteLength}`);
@@ -302,7 +315,9 @@ export function buildClientHello({
302
315
  throw new TlsError(codes.CONFIG_INVALID, 'ClientHello would offer no cipher suites');
303
316
  }
304
317
 
318
+ // GREASE goes FIRST in each list, which is where Chromium puts it — captured, not recalled.
305
319
  const suiteBytes = new Builder();
320
+ if (g) suiteBytes.u16(g.take());
306
321
  for (const s of suites) suiteBytes.u16(s);
307
322
 
308
323
  if (psk && !offersTls13) {
@@ -318,11 +333,16 @@ export function buildClientHello({
318
333
  // Always offered, for either version: without it a server may not staple (RFC 6066 s8), and
319
334
  // a stapled OCSP response is the only revocation signal this package can consume.
320
335
  encodeStatusRequest(),
321
- encodeSupportedGroups(groups),
336
+ encodeSupportedGroups(g ? [g.take(), ...groups] : groups),
322
337
  encodeSignatureAlgorithms(sigSchemes),
323
338
  alpn.length ? encodeAlpn(alpn) : null,
324
- offersTls13 ? encodeSupportedVersions(versions) : null,
325
- offersTls13 ? encodeKeyShare(keyShares.map(({ group, keyExchange }) => ({ group, keyExchange }))) : null,
339
+ offersTls13 ? encodeSupportedVersions(g ? [g.take(), ...versions] : versions) : null,
340
+ offersTls13
341
+ ? encodeKeyShare([
342
+ ...(g ? [greaseKeyShare(g.take())] : []),
343
+ ...keyShares.map(({ group, keyExchange }) => ({ group, keyExchange })),
344
+ ])
345
+ : null,
326
346
  offersTls13 ? encodePskKeyExchangeModes() : null,
327
347
  offersTls12 ? encodeExtendedMasterSecret() : null,
328
348
  offersTls12 ? encodeEcPointFormats() : null,
@@ -339,13 +359,36 @@ export function buildClientHello({
339
359
  if (part) offered.add((part[0] << 8) | part[1]);
340
360
  }
341
361
 
362
+ const typeOf = (e) => (e[0] << 8) | e[1];
363
+ // Order (or shuffle) the real extensions FIRST, then bracket them with GREASE. Doing it the
364
+ // other way round put both GREASE extensions at the end under a fixed order, because neither
365
+ // reserved type appears in any order list — and it put the trailing one after pre_shared_key,
366
+ // which RFC 8446 s4.2.11 forbids.
367
+ const laidReal =
368
+ extensionOrder === SHUFFLE_EXTENSIONS
369
+ ? shuffleExtensions(extensionParts.filter(Boolean), g ?? greaseSource(1), typeOf,
370
+ EXTENSION.pre_shared_key)
371
+ : orderExtensions(extensionParts, extensionOrder);
372
+
373
+ let laid = laidReal;
374
+ if (g) {
375
+ // Leading GREASE is empty, trailing carries a single zero byte — as captured from Chromium.
376
+ // The trailing one goes BEFORE any pre_shared_key, which must stay last of all.
377
+ const head = encodeRawExtension(g.take(), new Uint8Array(0));
378
+ const tail = encodeRawExtension(g.take(), Uint8Array.of(0));
379
+ const pskAt = laidReal.findIndex((e) => typeOf(e) === EXTENSION.pre_shared_key);
380
+ laid = pskAt === -1
381
+ ? [head, ...laidReal, tail]
382
+ : [head, ...laidReal.slice(0, pskAt), tail, ...laidReal.slice(pskAt)];
383
+ }
384
+
342
385
  const body = new Builder()
343
386
  .u16(LEGACY_VERSION)
344
387
  .push(clientRandom)
345
388
  .vector(1, sessionId)
346
389
  .vector(2, suiteBytes.build())
347
390
  .vector(1, Uint8Array.from([0])) // legacy_compression_methods: null only
348
- .push(encodeExtensionBlock(orderExtensions(extensionParts, extensionOrder)))
391
+ .push(encodeExtensionBlock(laid))
349
392
  .build();
350
393
 
351
394
  const message = handshakeMessage(HANDSHAKE_TYPE.client_hello, body);
@@ -506,6 +549,19 @@ export function negotiateVersion(serverHello, { offeredVersions }) {
506
549
  */
507
550
  export function negotiateCipher(serverHello, { offeredCiphers, version }) {
508
551
  const suite = serverHello.cipherSuite;
552
+ // A GREASE value is reserved and MUST be ignored by a server (RFC 8701 s3). One that negotiates
553
+ // it is broken, and because we offered it the "was it offered" test below would let it through —
554
+ // so it is refused here, naming GREASE, rather than falling to the missing-parameters check
555
+ // whose message would blame this package's own offer list.
556
+ if (isGrease(suite)) {
557
+ throw new TlsUnsupportedError(
558
+ codes.TLS_CIPHER_UNSUPPORTED,
559
+ `server selected the GREASE cipher suite ${hex16(suite)}, which RFC 8701 reserves and ` +
560
+ 'requires a server to ignore. It exists in the offer precisely to detect peers that do ' +
561
+ 'not.',
562
+ { cipherSuite: suite },
563
+ );
564
+ }
509
565
  if (!offeredCiphers.includes(suite)) {
510
566
  throw new TlsUnsupportedError(
511
567
  codes.TLS_CIPHER_UNSUPPORTED,
@@ -243,6 +243,7 @@ export async function continueTls13(ctx) {
243
243
  // either among the modifications a second ClientHello may make, and a strict server checks.
244
244
  extensionOrder: options.extensionOrder,
245
245
  sigSchemes: options.sigSchemes,
246
+ grease: options.grease ?? false,
246
247
  random: hello.clientRandom,
247
248
  legacySessionId: hello.legacySessionId,
248
249
  extraExtensions: cookie ? [cookieExtension(cookie)] : [],
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Read the caller's header names in the order and case they wrote them, BEFORE anything hands them
3
+ * to `Request`, which is where both are lost.
4
+ *
5
+ * Recovers what is recoverable and no more: an array of pairs or a plain object still carries the
6
+ * caller's order, a `Headers` or a `Request` does not — those were normalised before this package
7
+ * ever saw them, and there is nothing here to reconstruct.
8
+ *
9
+ * @param {RequestInfo | URL} input
10
+ * @param {RequestInit} [init]
11
+ * @returns {Array<[string, string]> | null} null when the caller's order was already gone
12
+ */
13
+ export function callerHeaderOrder(input: RequestInfo | URL, init?: RequestInit): Array<[string, string]> | null;
14
+ /**
15
+ * Default request header order, from curl 8.21.0 on the wire. `'*'` is where headers not named
16
+ * here go, in the order they were given — which is where curl puts the caller's own.
17
+ */
18
+ export const CURL_HEADER_ORDER: readonly string[];
19
+ /**
20
+ * An ordered, case-preserving header list.
21
+ *
22
+ * Deliberately not a `Headers` subclass and deliberately not backed by one: the whole point is to
23
+ * be the thing `Headers` is not. Lookup and mutation are case-insensitive, as HTTP requires;
24
+ * iteration returns names exactly as they were written, in the order they arrived.
25
+ */
26
+ export class OrderedHeaders {
27
+ /** @param {Headers | Iterable<[string, string]> | Record<string, string> | null} [init] */
28
+ constructor(init?: Headers | Iterable<[string, string]> | Record<string, string> | null);
29
+ /** @type {Array<[string, string]>} name as written, value */
30
+ _list: Array<[string, string]>;
31
+ _indexOf(name: any): number;
32
+ has(name: any): boolean;
33
+ /** Comma-joined when a field appears more than once, matching `Headers.get`. */
34
+ get(name: any): string | null;
35
+ /**
36
+ * Replace IN PLACE when the field is already present, so setting a value does not move a header
37
+ * to the end and silently reorder the request. A caller who wrote `User-Agent` first and then
38
+ * had it overwritten should still see it first.
39
+ */
40
+ set(name: any, value: any): void;
41
+ append(name: any, value: any): void;
42
+ delete(name: any): void;
43
+ /**
44
+ * Put the fields into `order`, which names lowercased header names and may contain `'*'` to mark
45
+ * where everything unnamed goes. Fields keep their relative order within each group, so a
46
+ * caller's own sequence survives.
47
+ *
48
+ * @param {readonly string[]} order
49
+ */
50
+ reorder(order: readonly string[]): void;
51
+ /** @returns {Array<[string, string]>} names as written, in order */
52
+ entries(): Array<[string, string]>;
53
+ /** Lowercased names, for HTTP/2 where RFC 9113 s8.2.1 requires them. */
54
+ lowercased(): string[][];
55
+ [Symbol.iterator](): ArrayIterator<[string, string]>;
56
+ }
package/types/client.d.ts CHANGED
@@ -65,6 +65,20 @@ export function install(options?: ClientOptions): () => void;
65
65
  * the wire bytes it saves do not pay that back — see the README. The reason to turn it on is
66
66
  * matching a browser's Accept-Encoding, not saving CPU.
67
67
  * @property {boolean} [keepAlive] default true.
68
+ * @property {import('./profiles.js').FingerprintProfile} [profile] one coherent network identity
69
+ * instead of a dozen knobs that can disagree — TLS, HTTP/2, header order and default headers
70
+ * together. Explicit options win over it. A profile that declares capabilities this package
71
+ * cannot perform is REFUSED rather than silently reduced: see `profiles.chrome`.
72
+ * @property {readonly string[]} [headerOrder] request header names, lowercased, in the order to
73
+ * emit them; `'*'` marks where headers not named go, in the order the caller gave them. Defaults
74
+ * to curl's (`CURL_HEADER_ORDER`). The platform `Headers` sorts alphabetically and lowercases, so
75
+ * without this a request goes out with `user-agent` last, which no real client does.
76
+ * @property {string[]} [http2PseudoHeaderOrder] request pseudo-headers in the order to emit them.
77
+ * Defaults to curl's. Any of the four omitted is appended rather than dropped: RFC 9113 s8.3.1
78
+ * makes all four mandatory, so a request missing one is malformed rather than merely unusual.
79
+ * @property {Record<string, 'incremental'|'without'|'never'>} [http2HpackIndexing] per-field HPACK
80
+ * indexing. Which fields enter the dynamic table is read by an Akamai-style h2 fingerprint.
81
+ * Defaults to curl's: everything incremental except `:path`.
68
82
  * @property {Array<[number, number]>} [http2Settings] the HTTP/2 SETTINGS flight, as [id, value]
69
83
  * pairs. Order is significant — an Akamai-style h2 fingerprint reads the ids in the order they
70
84
  * are sent — so this replaces the flight rather than merging into it. Defaults to curl's. The
@@ -227,6 +241,32 @@ export type ClientOptions = {
227
241
  * default true.
228
242
  */
229
243
  keepAlive?: boolean | undefined;
244
+ /**
245
+ * one coherent network identity
246
+ * instead of a dozen knobs that can disagree — TLS, HTTP/2, header order and default headers
247
+ * together. Explicit options win over it. A profile that declares capabilities this package
248
+ * cannot perform is REFUSED rather than silently reduced: see `profiles.chrome`.
249
+ */
250
+ profile?: import("./profiles.js").FingerprintProfile | undefined;
251
+ /**
252
+ * request header names, lowercased, in the order to
253
+ * emit them; `'*'` marks where headers not named go, in the order the caller gave them. Defaults
254
+ * to curl's (`CURL_HEADER_ORDER`). The platform `Headers` sorts alphabetically and lowercases, so
255
+ * without this a request goes out with `user-agent` last, which no real client does.
256
+ */
257
+ headerOrder?: readonly string[] | undefined;
258
+ /**
259
+ * request pseudo-headers in the order to emit them.
260
+ * Defaults to curl's. Any of the four omitted is appended rather than dropped: RFC 9113 s8.3.1
261
+ * makes all four mandatory, so a request missing one is malformed rather than merely unusual.
262
+ */
263
+ http2PseudoHeaderOrder?: string[] | undefined;
264
+ /**
265
+ * per-field HPACK
266
+ * indexing. Which fields enter the dynamic table is read by an Akamai-style h2 fingerprint.
267
+ * Defaults to curl's: everything incremental except `:path`.
268
+ */
269
+ http2HpackIndexing?: Record<string, "without" | "incremental" | "never"> | undefined;
230
270
  /**
231
271
  * the HTTP/2 SETTINGS flight, as [id, value]
232
272
  * pairs. Order is significant — an Akamai-style h2 fingerprint reads the ids in the order they
@@ -13,7 +13,7 @@ export function buildRequestFields({ method, scheme, authority, path, headers }:
13
13
  authority: string;
14
14
  path: string;
15
15
  headers: Array<[string, string]>;
16
- }): import("./hpack.js").HpackField[];
16
+ }, opts?: {}): import("./hpack.js").HpackField[];
17
17
  /**
18
18
  * @typedef {ReadableStream<Uint8Array> & { completed: Promise<boolean>,
19
19
  * trailers: Promise<Headers | null> }} BodyStream
@@ -76,6 +76,8 @@ export class Http2Connection {
76
76
  _maxHeaderBlockBytes: number;
77
77
  /** @type {Array<[number, number]> | null} the SETTINGS flight, ids and order included */
78
78
  _settingsFlight: Array<[number, number]> | null;
79
+ _pseudoHeaderOrder: string[] | null;
80
+ _hpackIndexing: Record<string, "without" | "incremental" | "never"> | null;
79
81
  _expectFirstSettings: boolean;
80
82
  _fatal: any;
81
83
  _goaway: {
@@ -254,6 +256,19 @@ export type Http2ConnectionOptions = {
254
256
  * self-protection cap on a decoded response header list.
255
257
  */
256
258
  maxHeaderListSize?: number | undefined;
259
+ /**
260
+ * request pseudo-headers in the order to emit them.
261
+ * Defaults to curl's `[':method', ':scheme', ':authority', ':path']`. Any of the four left out is
262
+ * appended rather than dropped — RFC 9113 s8.3.1 makes all four mandatory and a request missing
263
+ * one is malformed, which is not a fingerprint choice anyone should be able to make by accident.
264
+ */
265
+ pseudoHeaderOrder?: string[] | undefined;
266
+ /**
267
+ * per-field HPACK
268
+ * indexing. Which fields enter the dynamic table is part of the fingerprint. Defaults to curl's:
269
+ * everything incremental except `:path`, which is sent without indexing.
270
+ */
271
+ hpackIndexing?: Record<string, "without" | "incremental" | "never"> | undefined;
257
272
  /**
258
273
  * the SETTINGS flight sent in the connection
259
274
  * preface, as [id, value] pairs. Order is significant — an Akamai-style HTTP/2 fingerprint reads
@@ -79,7 +79,7 @@ export type HpackField = {
79
79
  * how to represent it when it is not a
80
80
  * full static match. Default 'incremental', which is what curl uses for most fields.
81
81
  */
82
- indexing?: "incremental" | "without" | "never" | undefined;
82
+ indexing?: "without" | "incremental" | "never" | undefined;
83
83
  };
84
84
  export type HpackDecoderOptions = {
85
85
  /**
package/types/index.d.ts CHANGED
@@ -15,3 +15,4 @@ export { openConnection, targetFromUrl, nativeFetchCanServe } from "./transport.
15
15
  export { openTunnel, parseProxy } from "./proxy/index.js";
16
16
  export { verifyChain, rootStoreProvenance } from "./trust/index.js";
17
17
  export { TunnelFetchError, ProxyError, HttpError, TlsError, TlsUnsupportedError, Http2Error, CertificateError, TimeoutError, LimitError, ConfigError, codes } from "./errors.js";
18
+ export { profiles, curl, chrome, applyProfile } from "./profiles.js";
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Fold a profile into a Client's options, and refuse an identity that cannot be honoured.
3
+ *
4
+ * Explicit options WIN over the profile: a caller who names a field meant to name it, and silently
5
+ * overriding them would make the profile impossible to adjust. The profile fills what was not said.
6
+ *
7
+ * @param {object} options as given to the Client
8
+ * @returns {object} options with the profile folded in
9
+ */
10
+ export function applyProfile(options: object): object;
11
+ /**
12
+ * @typedef {object} FingerprintProfile
13
+ * @property {string} name
14
+ * @property {object} [tls] merged into `tls`
15
+ * @property {readonly string[]} [headerOrder]
16
+ * @property {Array<[number, number]>} [http2Settings]
17
+ * @property {string[]} [http2PseudoHeaderOrder]
18
+ * @property {Record<string, string>} [http2HpackIndexing]
19
+ * @property {Array<[string, string]>} [headers] default request headers, in order
20
+ * @property {string[]} [requires] capabilities the caller must inject for this identity to be
21
+ * honest: `'cipher:chacha20'`, `'group:x25519mlkem768'`, `'decoder:br'`, `'decoder:zstd'`
22
+ */
23
+ /**
24
+ * curl 8.21.0 / OpenSSL 3.6.3. Complete: every layer was captured, and everything it offers is
25
+ * something this package can actually perform. This is the default identity.
26
+ */
27
+ export const curl: Readonly<{
28
+ name: "curl/8.21.0";
29
+ tls: Readonly<{
30
+ alpn: string[];
31
+ extensionOrder: readonly number[];
32
+ grease: false;
33
+ }>;
34
+ headerOrder: readonly string[];
35
+ headers: readonly string[][];
36
+ http2Settings: readonly number[][];
37
+ http2PseudoHeaderOrder: readonly string[];
38
+ http2HpackIndexing: Readonly<{
39
+ ':path': "without";
40
+ }>;
41
+ requires: readonly never[];
42
+ }>;
43
+ /**
44
+ * Chromium, TLS layer captured off the wire.
45
+ *
46
+ * INCOMPLETE ON PURPOSE, and it refuses to be used as though it were not. Two things are missing
47
+ * and neither can be papered over:
48
+ *
49
+ * * Chromium offers TLS_CHACHA20_POLY1305_SHA256 and the X25519MLKEM768 group, and this package
50
+ * implements neither. A ClientHello is an OFFER: a server may take either, and a client that
51
+ * then cannot complete the handshake has traded a fingerprint mismatch for a dead connection.
52
+ * Both are reachable by injection, which is why they are listed in `requires` rather than
53
+ * silently dropped.
54
+ * * Chromium's HTTP/2 preface was not captured — capturing it needs a TLS server the browser
55
+ * will trust, which is a different exercise. So this profile carries no h2 layer, and using it
56
+ * with HTTP/2 enabled would produce a Chromium ClientHello above a curl h2 preface: precisely
57
+ * the split identity a profile exists to prevent.
58
+ *
59
+ * `applyProfile` refuses both cases with a message naming what is missing.
60
+ */
61
+ export const chrome: Readonly<{
62
+ name: "chromium (TLS layer only)";
63
+ tls: Readonly<{
64
+ ciphers: readonly number[];
65
+ groups: readonly number[];
66
+ sigSchemes: readonly number[];
67
+ alpn: string[];
68
+ extensionOrder: "shuffle";
69
+ grease: true;
70
+ }>;
71
+ headers: readonly string[][];
72
+ http2Settings: null;
73
+ requires: readonly string[];
74
+ }>;
75
+ /** @type {Record<string, FingerprintProfile>} */
76
+ export const profiles: Record<string, FingerprintProfile>;
77
+ export type FingerprintProfile = {
78
+ name: string;
79
+ /**
80
+ * merged into `tls`
81
+ */
82
+ tls?: object | undefined;
83
+ headerOrder?: readonly string[] | undefined;
84
+ http2Settings?: [number, number][] | undefined;
85
+ http2PseudoHeaderOrder?: string[] | undefined;
86
+ http2HpackIndexing?: Record<string, string> | undefined;
87
+ /**
88
+ * default request headers, in order
89
+ */
90
+ headers?: [string, string][] | undefined;
91
+ /**
92
+ * capabilities the caller must inject for this identity to be
93
+ * honest: `'cipher:chacha20'`, `'group:x25519mlkem768'`, `'decoder:br'`, `'decoder:zstd'`
94
+ */
95
+ requires?: string[] | undefined;
96
+ };
@@ -15,7 +15,12 @@
15
15
  * supported group; a HelloRetryRequest recovers any other choice at the cost of a round trip.
16
16
  * @property {number[]} [ciphers] cipher suites to offer, in preference order.
17
17
  * @property {number[]} [sigSchemes] signature_algorithms to offer, in preference order.
18
- * @property {number[]} [extensionOrder] ClientHello extension types, in the order to emit them.
18
+ * @property {boolean | number} [grease] send GREASE (RFC 8701) reserved values in the cipher list,
19
+ * the extension list (one at each end), supported_groups, supported_versions and key_share.
20
+ * Default false, because curl does not GREASE — Chromium does. A number is a seed, which makes
21
+ * the hello reproducible; `true` draws one from `deps.randomBytes`. A server that negotiates a
22
+ * GREASE value is refused with a typed error naming it.
23
+ * @property {number[] | 'shuffle'} [extensionOrder] ClientHello extension types, in the order to emit them.
19
24
  * JA3 and JA4 hash the extension list in WIRE ORDER, so this is most of what a fingerprinter
20
25
  * reads. Defaults to curl's order (`CURL_EXTENSION_ORDER`). Extensions not named keep their
21
26
  * natural position at the end; `pre_shared_key` is always last whatever is asked, because RFC
@@ -154,6 +159,14 @@ export type TlsOptions = {
154
159
  * signature_algorithms to offer, in preference order.
155
160
  */
156
161
  sigSchemes?: number[] | undefined;
162
+ /**
163
+ * send GREASE (RFC 8701) reserved values in the cipher list,
164
+ * the extension list (one at each end), supported_groups, supported_versions and key_share.
165
+ * Default false, because curl does not GREASE — Chromium does. A number is a seed, which makes
166
+ * the hello reproducible; `true` draws one from `deps.randomBytes`. A server that negotiates a
167
+ * GREASE value is refused with a typed error naming it.
168
+ */
169
+ grease?: number | boolean | undefined;
157
170
  /**
158
171
  * ClientHello extension types, in the order to emit them.
159
172
  * JA3 and JA4 hash the extension list in WIRE ORDER, so this is most of what a fingerprinter
@@ -161,7 +174,7 @@ export type TlsOptions = {
161
174
  * natural position at the end; `pre_shared_key` is always last whatever is asked, because RFC
162
175
  * 8446 s4.2.11 defines the binder transcript as the hello truncated just before the binders.
163
176
  */
164
- extensionOrder?: number[] | undefined;
177
+ extensionOrder?: number[] | "shuffle" | undefined;
165
178
  /**
166
179
  * fixed ClientHello.random, for reproducible handshakes.
167
180
  */
@@ -1,3 +1,10 @@
1
+ /**
2
+ * An extension of an arbitrary type with an arbitrary body. Exists for GREASE (RFC 8701), whose
3
+ * whole point is to carry a reserved type this package assigns no meaning to.
4
+ * @param {number} type
5
+ * @param {Uint8Array} body
6
+ */
7
+ export function encodeRawExtension(type: number, body: Uint8Array): Uint8Array<ArrayBufferLike>;
1
8
  /**
2
9
  * server_name (RFC 6066). Only host_name (type 0) exists in practice.
3
10
  * An IP literal must NOT be sent as SNI — RFC 6066 s3 forbids it, and servers that do virtual
@@ -0,0 +1,46 @@
1
+ /** @param {number} v @returns {boolean} */
2
+ export function isGrease(v: number): boolean;
3
+ /**
4
+ * A deterministic-from-seed source of GREASE values and shuffles.
5
+ *
6
+ * Seeded rather than ad-hoc `Math.random` for two reasons: this package forbids ambient randomness
7
+ * in `src/` (repo-hygiene enforces it, so that every byte on the wire is reproducible in a test),
8
+ * and a fingerprint that cannot be reproduced cannot be asserted byte-for-byte.
9
+ *
10
+ * @param {number} seed
11
+ */
12
+ export function greaseSource(seed: number): {
13
+ /** A GREASE value not yet handed out in this hello, since Chromium never repeats one. */
14
+ take(): number;
15
+ next: () => number;
16
+ };
17
+ /**
18
+ * Fisher-Yates over the middle of the extension list, leaving the ends alone.
19
+ *
20
+ * The first and last positions are not free: Chromium pins a GREASE extension to each, and
21
+ * `pre_shared_key` MUST be last of all (RFC 8446 s4.2.11 — the binder transcript is the hello
22
+ * truncated just before the binders, a range that only exists if nothing follows them). So the
23
+ * shuffle covers everything between the fixed ends and nothing else.
24
+ *
25
+ * @param {Array<Uint8Array>} parts encoded extensions, already ordered
26
+ * @param {{next: () => number}} rng
27
+ * @param {(e: Uint8Array) => number} typeOf
28
+ * @param {number} pskType
29
+ * @returns {Array<Uint8Array>}
30
+ */
31
+ export function shuffleExtensions(parts: Array<Uint8Array>, rng: {
32
+ next: () => number;
33
+ }, typeOf: (e: Uint8Array) => number, pskType: number): Array<Uint8Array>;
34
+ /**
35
+ * A GREASE key_share entry: a reserved group with a single-byte key, which is what Chromium sends.
36
+ * The byte is fixed rather than random — it is never used for anything, and a value that varies
37
+ * would only make the hello harder to assert on.
38
+ *
39
+ * @param {number} group
40
+ */
41
+ export function greaseKeyShare(group: number): {
42
+ group: number;
43
+ keyExchange: Uint8Array<ArrayBuffer>;
44
+ };
45
+ /** The sixteen reserved values (RFC 8701 s2): 0x0A0A, 0x1A1A, ... 0xFAFA. */
46
+ export const GREASE_VALUES: readonly number[];
@@ -28,7 +28,7 @@ export function generateKeyShare(group: number, { generateKeyPair }?: import("./
28
28
  * @returns {Promise<Uint8Array>} throws on any degenerate or malformed peer key
29
29
  */
30
30
  export function deriveSharedSecret(group: number, privateKey: CryptoKey, peerKey: Uint8Array): Promise<Uint8Array>;
31
- export function buildClientHello({ hostname, keyShares, random, legacySessionId, ciphers, groups, sigSchemes, alpn, versions, extensionOrder, extraExtensions, psk, randomBytes, }: {
31
+ export function buildClientHello({ hostname, keyShares, random, legacySessionId, ciphers, groups, sigSchemes, alpn, versions, extensionOrder, extraExtensions, psk, grease, randomBytes, }: {
32
32
  hostname: any;
33
33
  keyShares: any;
34
34
  random: any;
@@ -41,6 +41,7 @@ export function buildClientHello({ hostname, keyShares, random, legacySessionId,
41
41
  extensionOrder?: readonly number[] | undefined;
42
42
  extraExtensions?: never[] | undefined;
43
43
  psk?: null | undefined;
44
+ grease?: boolean | undefined;
44
45
  randomBytes?: ((n: any) => Uint8Array<any>) | undefined;
45
46
  }): {
46
47
  message: Uint8Array<ArrayBufferLike>;
@@ -351,6 +352,8 @@ export function checkAlpn(extensions: Map<number, Uint8Array>, offeredAlpn: stri
351
352
  * whatever the caller asks for, because RFC 8446 s4.2.11 defines the binder transcript as the hello
352
353
  * truncated just before the binders — a range that only exists if nothing follows them.
353
354
  */
355
+ /** `extensionOrder: SHUFFLE_EXTENSIONS` reproduces what Chromium does — see grease.js. */
356
+ export const SHUFFLE_EXTENSIONS: "shuffle";
354
357
  export const CURL_EXTENSION_ORDER: readonly number[];
355
358
  export { GROUP_PARAMS };
356
359
  /**