tunnelfetch 1.1.2 → 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/README.md +44 -1
- package/README.zh-CN.md +32 -0
- package/package.json +1 -1
- package/src/client/header-order.js +159 -0
- package/src/client.js +62 -16
- package/src/http2/connection.js +55 -9
- package/src/index.js +1 -0
- package/src/profiles.js +150 -0
- package/src/tls/connect.js +14 -0
- package/src/tls/extensions.js +10 -0
- package/src/tls/grease.js +109 -0
- package/src/tls/handshake-messages.js +120 -4
- package/src/tls/handshake.js +5 -0
- package/src/warmup-fixture.js +44 -44
- package/types/client/header-order.d.ts +56 -0
- package/types/client.d.ts +53 -0
- package/types/http2/connection.d.ts +25 -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 +31 -0
- package/types/tls/extensions.d.ts +7 -0
- package/types/tls/grease.d.ts +46 -0
- package/types/tls/handshake-messages.d.ts +104 -57
|
@@ -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,25 @@ 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`.
|
|
82
|
+
* @property {Array<[number, number]>} [http2Settings] the HTTP/2 SETTINGS flight, as [id, value]
|
|
83
|
+
* pairs. Order is significant — an Akamai-style h2 fingerprint reads the ids in the order they
|
|
84
|
+
* are sent — so this replaces the flight rather than merging into it. Defaults to curl's. The
|
|
85
|
+
* TLS half of the fingerprint is configured through `tls` (`ciphers`, `groups`, `sigSchemes`,
|
|
86
|
+
* `alpn`, `versions`, `extensionOrder`).
|
|
68
87
|
* @property {boolean} [http2] offer HTTP/2 via ALPN and speak it when the server selects it.
|
|
69
88
|
* Default true. The goal is ACCESS, not speed — some sites treat HTTP/1.1 as a bot signal — and
|
|
70
89
|
* on a CPU-billed runtime h2 costs MORE than h1 (HPACK is extra work). Set false to offer only
|
|
@@ -222,6 +241,40 @@ export type ClientOptions = {
|
|
|
222
241
|
* default true.
|
|
223
242
|
*/
|
|
224
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;
|
|
270
|
+
/**
|
|
271
|
+
* the HTTP/2 SETTINGS flight, as [id, value]
|
|
272
|
+
* pairs. Order is significant — an Akamai-style h2 fingerprint reads the ids in the order they
|
|
273
|
+
* are sent — so this replaces the flight rather than merging into it. Defaults to curl's. The
|
|
274
|
+
* TLS half of the fingerprint is configured through `tls` (`ciphers`, `groups`, `sigSchemes`,
|
|
275
|
+
* `alpn`, `versions`, `extensionOrder`).
|
|
276
|
+
*/
|
|
277
|
+
http2Settings?: [number, number][] | undefined;
|
|
225
278
|
/**
|
|
226
279
|
* offer HTTP/2 via ALPN and speak it when the server selects it.
|
|
227
280
|
* Default true. The goal is ACCESS, not speed — some sites treat HTTP/1.1 as a bot signal — and
|
|
@@ -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
|
|
@@ -74,6 +74,10 @@ export class Http2Connection {
|
|
|
74
74
|
endStream: boolean;
|
|
75
75
|
} | null;
|
|
76
76
|
_maxHeaderBlockBytes: number;
|
|
77
|
+
/** @type {Array<[number, number]> | null} the SETTINGS flight, ids and order included */
|
|
78
|
+
_settingsFlight: Array<[number, number]> | null;
|
|
79
|
+
_pseudoHeaderOrder: string[] | null;
|
|
80
|
+
_hpackIndexing: Record<string, "without" | "incremental" | "never"> | null;
|
|
77
81
|
_expectFirstSettings: boolean;
|
|
78
82
|
_fatal: any;
|
|
79
83
|
_goaway: {
|
|
@@ -252,6 +256,26 @@ export type Http2ConnectionOptions = {
|
|
|
252
256
|
* self-protection cap on a decoded response header list.
|
|
253
257
|
*/
|
|
254
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;
|
|
272
|
+
/**
|
|
273
|
+
* the SETTINGS flight sent in the connection
|
|
274
|
+
* preface, as [id, value] pairs. Order is significant — an Akamai-style HTTP/2 fingerprint reads
|
|
275
|
+
* the ids in the order they are sent — so this replaces the flight entirely rather than merging.
|
|
276
|
+
* Defaults to curl's: MAX_CONCURRENT_STREAMS, INITIAL_WINDOW_SIZE, ENABLE_PUSH.
|
|
277
|
+
*/
|
|
278
|
+
settings?: [number, number][] | undefined;
|
|
255
279
|
/**
|
|
256
280
|
* cap on the RAW bytes of one HEADERS+CONTINUATION run,
|
|
257
281
|
* before HPACK decoding. Default 262144, matching the decoded cap. This is the bound that stops
|
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
|
@@ -14,6 +14,17 @@
|
|
|
14
14
|
* @property {number[]} [offerGroups] groups to send an actual key_share for. Default the first
|
|
15
15
|
* supported group; a HelloRetryRequest recovers any other choice at the cost of a round trip.
|
|
16
16
|
* @property {number[]} [ciphers] cipher suites to offer, in preference order.
|
|
17
|
+
* @property {number[]} [sigSchemes] signature_algorithms to offer, in preference order.
|
|
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.
|
|
24
|
+
* JA3 and JA4 hash the extension list in WIRE ORDER, so this is most of what a fingerprinter
|
|
25
|
+
* reads. Defaults to curl's order (`CURL_EXTENSION_ORDER`). Extensions not named keep their
|
|
26
|
+
* natural position at the end; `pre_shared_key` is always last whatever is asked, because RFC
|
|
27
|
+
* 8446 s4.2.11 defines the binder transcript as the hello truncated just before the binders.
|
|
17
28
|
* @property {Uint8Array} [clientRandom] fixed ClientHello.random, for reproducible handshakes.
|
|
18
29
|
* @property {Uint8Array} [legacySessionId] fixed legacy_session_id, likewise.
|
|
19
30
|
* @property {boolean} [compatibilityCcs] send the middlebox-compatibility ChangeCipherSpec.
|
|
@@ -144,6 +155,26 @@ export type TlsOptions = {
|
|
|
144
155
|
* cipher suites to offer, in preference order.
|
|
145
156
|
*/
|
|
146
157
|
ciphers?: number[] | undefined;
|
|
158
|
+
/**
|
|
159
|
+
* signature_algorithms to offer, in preference order.
|
|
160
|
+
*/
|
|
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;
|
|
170
|
+
/**
|
|
171
|
+
* ClientHello extension types, in the order to emit them.
|
|
172
|
+
* JA3 and JA4 hash the extension list in WIRE ORDER, so this is most of what a fingerprinter
|
|
173
|
+
* reads. Defaults to curl's order (`CURL_EXTENSION_ORDER`). Extensions not named keep their
|
|
174
|
+
* natural position at the end; `pre_shared_key` is always last whatever is asked, because RFC
|
|
175
|
+
* 8446 s4.2.11 defines the binder transcript as the hello truncated just before the binders.
|
|
176
|
+
*/
|
|
177
|
+
extensionOrder?: number[] | "shuffle" | undefined;
|
|
147
178
|
/**
|
|
148
179
|
* fixed ClientHello.random, for reproducible handshakes.
|
|
149
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,51 +28,31 @@ 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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
* @property {Uint8Array} message framed handshake message, ready for the record layer
|
|
57
|
-
* @property {Uint8Array} clientRandom
|
|
58
|
-
* @property {Uint8Array} legacySessionId
|
|
59
|
-
* @property {number[]} offeredCiphers
|
|
60
|
-
* @property {number[]} offeredGroups
|
|
61
|
-
* @property {number[]} offeredSigSchemes
|
|
62
|
-
* @property {Set<number>} offeredExtensions extension types present in the hello
|
|
63
|
-
* @property {string[]} offeredAlpn
|
|
64
|
-
* @property {number} [binderOffset] psk only: where the binder's bytes sit in `message`
|
|
65
|
-
* @property {number} [truncatedLength] psk only: how many leading bytes of `message` the binder
|
|
66
|
-
* transcript covers (RFC 8446 s4.2.11.2 truncation — everything except the binders list)
|
|
67
|
-
*/
|
|
68
|
-
/**
|
|
69
|
-
* Build a ClientHello. Returns the framed handshake message plus the metadata the rest of the
|
|
70
|
-
* handshake needs to police the server's answer.
|
|
71
|
-
*
|
|
72
|
-
* @param {ClientHelloOptions} opts
|
|
73
|
-
* @returns {ClientHello}
|
|
74
|
-
*/
|
|
75
|
-
export function buildClientHello({ hostname, keyShares, random, legacySessionId, ciphers, groups, sigSchemes, alpn, versions, extraExtensions, psk, randomBytes, }: ClientHelloOptions): ClientHello;
|
|
31
|
+
export function buildClientHello({ hostname, keyShares, random, legacySessionId, ciphers, groups, sigSchemes, alpn, versions, extensionOrder, extraExtensions, psk, grease, randomBytes, }: {
|
|
32
|
+
hostname: any;
|
|
33
|
+
keyShares: any;
|
|
34
|
+
random: any;
|
|
35
|
+
legacySessionId: any;
|
|
36
|
+
ciphers: any;
|
|
37
|
+
groups?: number[] | undefined;
|
|
38
|
+
sigSchemes?: number[] | undefined;
|
|
39
|
+
alpn?: string[] | undefined;
|
|
40
|
+
versions?: number[] | undefined;
|
|
41
|
+
extensionOrder?: readonly number[] | undefined;
|
|
42
|
+
extraExtensions?: never[] | undefined;
|
|
43
|
+
psk?: null | undefined;
|
|
44
|
+
grease?: boolean | undefined;
|
|
45
|
+
randomBytes?: ((n: any) => Uint8Array<any>) | undefined;
|
|
46
|
+
}): {
|
|
47
|
+
message: Uint8Array<ArrayBufferLike>;
|
|
48
|
+
clientRandom: any;
|
|
49
|
+
legacySessionId: any;
|
|
50
|
+
offeredCiphers: any;
|
|
51
|
+
offeredGroups: number[];
|
|
52
|
+
offeredSigSchemes: number[];
|
|
53
|
+
offeredExtensions: Set<any>;
|
|
54
|
+
offeredAlpn: string[];
|
|
55
|
+
};
|
|
76
56
|
/**
|
|
77
57
|
* Patch the real binder over the placeholder `buildClientHello` emitted. Separate from the
|
|
78
58
|
* builder because the binder is derived FROM the built message (truncated), so there is no
|
|
@@ -308,6 +288,73 @@ export function checkFinished(received: Uint8Array, expected: Uint8Array): true;
|
|
|
308
288
|
* @returns {string | null} null when the server declined ALPN entirely
|
|
309
289
|
*/
|
|
310
290
|
export function checkAlpn(extensions: Map<number, Uint8Array>, offeredAlpn: string[], where: string): string | null;
|
|
291
|
+
/**
|
|
292
|
+
* @typedef {object} ClientHelloOptions
|
|
293
|
+
* @property {string} hostname SNI, unless it is an IP literal (then no SNI is sent)
|
|
294
|
+
* @property {Array<{ group: number, keyExchange: Uint8Array }>} keyShares public halves to
|
|
295
|
+
* offer; empty for a 1.2-only hello, whose wire form must not carry the extension at all
|
|
296
|
+
* @property {Uint8Array} [random] fixed ClientHello.random, for reproducible handshakes
|
|
297
|
+
* @property {Uint8Array} [legacySessionId] fixed legacy_session_id, likewise
|
|
298
|
+
* @property {number[]} [ciphers] default: the union for the offered versions, 1.3 first
|
|
299
|
+
* @property {number[]} [groups] supported_groups, default SUPPORTED_GROUPS
|
|
300
|
+
* @property {number[]} [sigSchemes] default SUPPORTED_SIG_SCHEMES
|
|
301
|
+
* @property {string[]} [alpn] default ['http/1.1']; empty array omits the extension
|
|
302
|
+
* @property {number[]} [versions] default [TLS13, TLS12]
|
|
303
|
+
* @property {Uint8Array[]} [extraExtensions] pre-encoded, sent verbatim (the HRR cookie)
|
|
304
|
+
* @property {{ identity: Uint8Array, obfuscatedTicketAge: number, binderLen: number }} [psk]
|
|
305
|
+
* offer this resumption PSK. Encoded with a zeroed binder placeholder; the caller MUST derive
|
|
306
|
+
* the real binder over `message.subarray(0, truncatedLength)` and patch it in at
|
|
307
|
+
* `binderOffset` before the hello touches the wire — a zero binder on the wire is a hello
|
|
308
|
+
* every honest server must reject.
|
|
309
|
+
* @property {(n: number) => Uint8Array} [randomBytes] injectable randomness
|
|
310
|
+
*/
|
|
311
|
+
/**
|
|
312
|
+
* The built hello plus everything later steps need to police the server's answer against what
|
|
313
|
+
* was actually offered — negotiation checks must run against this record, never against the
|
|
314
|
+
* defaults they might have come from.
|
|
315
|
+
* @typedef {object} ClientHello
|
|
316
|
+
* @property {Uint8Array} message framed handshake message, ready for the record layer
|
|
317
|
+
* @property {Uint8Array} clientRandom
|
|
318
|
+
* @property {Uint8Array} legacySessionId
|
|
319
|
+
* @property {number[]} offeredCiphers
|
|
320
|
+
* @property {number[]} offeredGroups
|
|
321
|
+
* @property {number[]} offeredSigSchemes
|
|
322
|
+
* @property {Set<number>} offeredExtensions extension types present in the hello
|
|
323
|
+
* @property {string[]} offeredAlpn
|
|
324
|
+
* @property {number} [binderOffset] psk only: where the binder's bytes sit in `message`
|
|
325
|
+
* @property {number} [truncatedLength] psk only: how many leading bytes of `message` the binder
|
|
326
|
+
* transcript covers (RFC 8446 s4.2.11.2 truncation — everything except the binders list)
|
|
327
|
+
*/
|
|
328
|
+
/**
|
|
329
|
+
* Build a ClientHello. Returns the framed handshake message plus the metadata the rest of the
|
|
330
|
+
* handshake needs to police the server's answer.
|
|
331
|
+
*
|
|
332
|
+
* @param {ClientHelloOptions} opts
|
|
333
|
+
* @returns {ClientHello}
|
|
334
|
+
*/
|
|
335
|
+
/**
|
|
336
|
+
* Extension emission order, by type. This is not cosmetic: JA3 and JA4 hash the extension list in
|
|
337
|
+
* WIRE ORDER, so the order alone is a large part of what a fingerprinter reads.
|
|
338
|
+
*
|
|
339
|
+
* Captured from curl 8.21.0 / OpenSSL 3.6.3, which sends:
|
|
340
|
+
* renegotiation_info, server_name, ec_point_formats, supported_groups, ALPN, encrypt_then_mac,
|
|
341
|
+
* extended_master_secret, post_handshake_auth, signature_algorithms, supported_versions,
|
|
342
|
+
* psk_key_exchange_modes, key_share
|
|
343
|
+
*
|
|
344
|
+
* Two of those this package does not send, and the reason is the same in both cases — an extension
|
|
345
|
+
* is a claim about what we can do. encrypt_then_mac only applies to CBC suites, which are not
|
|
346
|
+
* offered; post_handshake_auth invites a CertificateRequest after the handshake, which is not
|
|
347
|
+
* implemented. status_request goes the other way: curl does not send it, this package does,
|
|
348
|
+
* because a stapled OCSP response is its only revocation signal. It is placed where OpenSSL puts
|
|
349
|
+
* it when it does send one, right after server_name.
|
|
350
|
+
*
|
|
351
|
+
* Anything not named here keeps its natural position at the end, and pre_shared_key is forced last
|
|
352
|
+
* whatever the caller asks for, because RFC 8446 s4.2.11 defines the binder transcript as the hello
|
|
353
|
+
* truncated just before the binders — a range that only exists if nothing follows them.
|
|
354
|
+
*/
|
|
355
|
+
/** `extensionOrder: SHUFFLE_EXTENSIONS` reproduces what Chromium does — see grease.js. */
|
|
356
|
+
export const SHUFFLE_EXTENSIONS: "shuffle";
|
|
357
|
+
export const CURL_EXTENSION_ORDER: readonly number[];
|
|
311
358
|
export { GROUP_PARAMS };
|
|
312
359
|
/**
|
|
313
360
|
* An ephemeral key share: the public half as sent in key_share, plus the private key the
|
|
@@ -324,6 +371,18 @@ export type KeyShare = {
|
|
|
324
371
|
*/
|
|
325
372
|
privateKey: CryptoKey;
|
|
326
373
|
};
|
|
374
|
+
/**
|
|
375
|
+
* A parsed ServerHello. `isHelloRetryRequest` is decided by the random alone (RFC 8446 s4.1.3);
|
|
376
|
+
* everything else is exactly what the wire carried, judged later by the negotiate* functions.
|
|
377
|
+
*/
|
|
378
|
+
export type ServerHello = {
|
|
379
|
+
legacyVersion: number;
|
|
380
|
+
random: Uint8Array;
|
|
381
|
+
legacySessionIdEcho: Uint8Array;
|
|
382
|
+
cipherSuite: number;
|
|
383
|
+
extensions: Map<number, Uint8Array>;
|
|
384
|
+
isHelloRetryRequest: boolean;
|
|
385
|
+
};
|
|
327
386
|
export type ClientHelloOptions = {
|
|
328
387
|
/**
|
|
329
388
|
* SNI, unless it is an IP literal (then no SNI is sent)
|
|
@@ -415,16 +474,4 @@ export type ClientHello = {
|
|
|
415
474
|
*/
|
|
416
475
|
truncatedLength?: number | undefined;
|
|
417
476
|
};
|
|
418
|
-
/**
|
|
419
|
-
* A parsed ServerHello. `isHelloRetryRequest` is decided by the random alone (RFC 8446 s4.1.3);
|
|
420
|
-
* everything else is exactly what the wire carried, judged later by the negotiate* functions.
|
|
421
|
-
*/
|
|
422
|
-
export type ServerHello = {
|
|
423
|
-
legacyVersion: number;
|
|
424
|
-
random: Uint8Array;
|
|
425
|
-
legacySessionIdEcho: Uint8Array;
|
|
426
|
-
cipherSuite: number;
|
|
427
|
-
extensions: Map<number, Uint8Array>;
|
|
428
|
-
isHelloRetryRequest: boolean;
|
|
429
|
-
};
|
|
430
477
|
import { GROUP_PARAMS } from './constants.js';
|