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 +1 -1
- package/src/client/header-order.js +159 -0
- package/src/client.js +52 -16
- package/src/http2/connection.js +36 -4
- package/src/index.js +1 -0
- package/src/profiles.js +150 -0
- package/src/tls/connect.js +7 -1
- package/src/tls/extensions.js +10 -0
- package/src/tls/grease.js +109 -0
- package/src/tls/handshake-messages.js +60 -4
- package/src/tls/handshake.js +1 -0
- package/types/client/header-order.d.ts +56 -0
- package/types/client.d.ts +40 -0
- package/types/http2/connection.d.ts +16 -1
- package/types/http2/hpack.d.ts +1 -1
- package/types/index.d.ts +1 -0
- package/types/profiles.d.ts +96 -0
- package/types/tls/connect.d.ts +15 -2
- package/types/tls/extensions.d.ts +7 -0
- package/types/tls/grease.d.ts +46 -0
- package/types/tls/handshake-messages.d.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tunnelfetch",
|
|
3
|
-
"version": "1.
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
return { authority, headers:
|
|
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
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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 };
|
package/src/http2/connection.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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:
|
|
1248
|
+
fields.push({ name, value, indexing: indexingFor(name) });
|
|
1217
1249
|
}
|
|
1218
1250
|
return fields;
|
|
1219
1251
|
}
|
package/src/index.js
CHANGED
package/src/profiles.js
ADDED
|
@@ -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
|
+
}
|
package/src/tls/connect.js
CHANGED
|
@@ -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
|
|
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 && {
|
package/src/tls/extensions.js
CHANGED
|
@@ -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
|
|
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(
|
|
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,
|
package/src/tls/handshake.js
CHANGED
|
@@ -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
|
package/types/http2/hpack.d.ts
CHANGED
|
@@ -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?: "
|
|
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
|
+
};
|
package/types/tls/connect.d.ts
CHANGED
|
@@ -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
|
|
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
|
/**
|