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
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
|
@@ -135,6 +135,17 @@ function expectServerHello(msg, offers12) {
|
|
|
135
135
|
* @property {number[]} [offerGroups] groups to send an actual key_share for. Default the first
|
|
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
|
+
* @property {number[]} [sigSchemes] signature_algorithms to offer, in preference order.
|
|
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.
|
|
145
|
+
* JA3 and JA4 hash the extension list in WIRE ORDER, so this is most of what a fingerprinter
|
|
146
|
+
* reads. Defaults to curl's order (`CURL_EXTENSION_ORDER`). Extensions not named keep their
|
|
147
|
+
* natural position at the end; `pre_shared_key` is always last whatever is asked, because RFC
|
|
148
|
+
* 8446 s4.2.11 defines the binder transcript as the hello truncated just before the binders.
|
|
138
149
|
* @property {Uint8Array} [clientRandom] fixed ClientHello.random, for reproducible handshakes.
|
|
139
150
|
* @property {Uint8Array} [legacySessionId] fixed legacy_session_id, likewise.
|
|
140
151
|
* @property {boolean} [compatibilityCcs] send the middlebox-compatibility ChangeCipherSpec.
|
|
@@ -326,6 +337,9 @@ async function drive({ record, hostname, verifyPeer, options, deps, versions })
|
|
|
326
337
|
alpn,
|
|
327
338
|
ciphers,
|
|
328
339
|
versions,
|
|
340
|
+
extensionOrder: options.extensionOrder,
|
|
341
|
+
sigSchemes: options.sigSchemes,
|
|
342
|
+
grease: options.grease ?? false,
|
|
329
343
|
random: options.clientRandom,
|
|
330
344
|
legacySessionId: options.legacySessionId,
|
|
331
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,
|
|
@@ -210,6 +212,68 @@ export async function deriveSharedSecret(group, privateKey, peerKey) {
|
|
|
210
212
|
* @param {ClientHelloOptions} opts
|
|
211
213
|
* @returns {ClientHello}
|
|
212
214
|
*/
|
|
215
|
+
/**
|
|
216
|
+
* Extension emission order, by type. This is not cosmetic: JA3 and JA4 hash the extension list in
|
|
217
|
+
* WIRE ORDER, so the order alone is a large part of what a fingerprinter reads.
|
|
218
|
+
*
|
|
219
|
+
* Captured from curl 8.21.0 / OpenSSL 3.6.3, which sends:
|
|
220
|
+
* renegotiation_info, server_name, ec_point_formats, supported_groups, ALPN, encrypt_then_mac,
|
|
221
|
+
* extended_master_secret, post_handshake_auth, signature_algorithms, supported_versions,
|
|
222
|
+
* psk_key_exchange_modes, key_share
|
|
223
|
+
*
|
|
224
|
+
* Two of those this package does not send, and the reason is the same in both cases — an extension
|
|
225
|
+
* is a claim about what we can do. encrypt_then_mac only applies to CBC suites, which are not
|
|
226
|
+
* offered; post_handshake_auth invites a CertificateRequest after the handshake, which is not
|
|
227
|
+
* implemented. status_request goes the other way: curl does not send it, this package does,
|
|
228
|
+
* because a stapled OCSP response is its only revocation signal. It is placed where OpenSSL puts
|
|
229
|
+
* it when it does send one, right after server_name.
|
|
230
|
+
*
|
|
231
|
+
* Anything not named here keeps its natural position at the end, and pre_shared_key is forced last
|
|
232
|
+
* whatever the caller asks for, because RFC 8446 s4.2.11 defines the binder transcript as the hello
|
|
233
|
+
* truncated just before the binders — a range that only exists if nothing follows them.
|
|
234
|
+
*/
|
|
235
|
+
/** `extensionOrder: SHUFFLE_EXTENSIONS` reproduces what Chromium does — see grease.js. */
|
|
236
|
+
export const SHUFFLE_EXTENSIONS = 'shuffle';
|
|
237
|
+
|
|
238
|
+
export const CURL_EXTENSION_ORDER = Object.freeze([
|
|
239
|
+
EXTENSION.renegotiation_info,
|
|
240
|
+
EXTENSION.server_name,
|
|
241
|
+
EXTENSION.status_request,
|
|
242
|
+
EXTENSION.ec_point_formats,
|
|
243
|
+
EXTENSION.supported_groups,
|
|
244
|
+
EXTENSION.alpn,
|
|
245
|
+
EXTENSION.extended_master_secret,
|
|
246
|
+
EXTENSION.signature_algorithms,
|
|
247
|
+
EXTENSION.supported_versions,
|
|
248
|
+
EXTENSION.psk_key_exchange_modes,
|
|
249
|
+
EXTENSION.key_share,
|
|
250
|
+
]);
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Put the encoded extensions into the requested order.
|
|
254
|
+
*
|
|
255
|
+
* @param {Array<Uint8Array|null>} parts encoded extensions, nulls for the ones not offered
|
|
256
|
+
* @param {number[]} order extension types, most significant first
|
|
257
|
+
* @returns {Array<Uint8Array>}
|
|
258
|
+
*/
|
|
259
|
+
function orderExtensions(parts, order) {
|
|
260
|
+
const present = parts.filter(Boolean);
|
|
261
|
+
const typeOf = (e) => (e[0] << 8) | e[1];
|
|
262
|
+
// pre_shared_key is not the caller's to place.
|
|
263
|
+
const psk = present.filter((e) => typeOf(e) === EXTENSION.pre_shared_key);
|
|
264
|
+
const rest = present.filter((e) => typeOf(e) !== EXTENSION.pre_shared_key);
|
|
265
|
+
const rank = new Map(order.map((t, i) => [t, i]));
|
|
266
|
+
// A stable sort on rank, with unranked extensions after every ranked one in the order they were
|
|
267
|
+
// built. Array.prototype.sort is required to be stable, so equal ranks keep their relative order.
|
|
268
|
+
return [
|
|
269
|
+
...rest
|
|
270
|
+
.map((e, i) => ({ e, i, r: rank.get(typeOf(e)) ?? Number.MAX_SAFE_INTEGER }))
|
|
271
|
+
.sort((x, y) => x.r - y.r || x.i - y.i)
|
|
272
|
+
.map((x) => x.e),
|
|
273
|
+
...psk,
|
|
274
|
+
];
|
|
275
|
+
}
|
|
276
|
+
|
|
213
277
|
export function buildClientHello({
|
|
214
278
|
hostname,
|
|
215
279
|
keyShares,
|
|
@@ -220,10 +284,19 @@ export function buildClientHello({
|
|
|
220
284
|
sigSchemes = SUPPORTED_SIG_SCHEMES,
|
|
221
285
|
alpn = [ALPN_HTTP11],
|
|
222
286
|
versions = [TLS13, TLS12],
|
|
287
|
+
extensionOrder = CURL_EXTENSION_ORDER,
|
|
223
288
|
extraExtensions = [],
|
|
224
289
|
psk = null,
|
|
290
|
+
grease = false,
|
|
225
291
|
randomBytes = defaultRandom,
|
|
226
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);
|
|
227
300
|
const clientRandom = random ?? randomBytes(32);
|
|
228
301
|
if (clientRandom.byteLength !== 32) {
|
|
229
302
|
throw new TlsError(codes.CONFIG_INVALID, `ClientHello.random must be 32 bytes, got ${clientRandom.byteLength}`);
|
|
@@ -242,7 +315,9 @@ export function buildClientHello({
|
|
|
242
315
|
throw new TlsError(codes.CONFIG_INVALID, 'ClientHello would offer no cipher suites');
|
|
243
316
|
}
|
|
244
317
|
|
|
318
|
+
// GREASE goes FIRST in each list, which is where Chromium puts it — captured, not recalled.
|
|
245
319
|
const suiteBytes = new Builder();
|
|
320
|
+
if (g) suiteBytes.u16(g.take());
|
|
246
321
|
for (const s of suites) suiteBytes.u16(s);
|
|
247
322
|
|
|
248
323
|
if (psk && !offersTls13) {
|
|
@@ -258,11 +333,16 @@ export function buildClientHello({
|
|
|
258
333
|
// Always offered, for either version: without it a server may not staple (RFC 6066 s8), and
|
|
259
334
|
// a stapled OCSP response is the only revocation signal this package can consume.
|
|
260
335
|
encodeStatusRequest(),
|
|
261
|
-
encodeSupportedGroups(groups),
|
|
336
|
+
encodeSupportedGroups(g ? [g.take(), ...groups] : groups),
|
|
262
337
|
encodeSignatureAlgorithms(sigSchemes),
|
|
263
338
|
alpn.length ? encodeAlpn(alpn) : null,
|
|
264
|
-
offersTls13 ? encodeSupportedVersions(versions) : null,
|
|
265
|
-
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,
|
|
266
346
|
offersTls13 ? encodePskKeyExchangeModes() : null,
|
|
267
347
|
offersTls12 ? encodeExtendedMasterSecret() : null,
|
|
268
348
|
offersTls12 ? encodeEcPointFormats() : null,
|
|
@@ -279,13 +359,36 @@ export function buildClientHello({
|
|
|
279
359
|
if (part) offered.add((part[0] << 8) | part[1]);
|
|
280
360
|
}
|
|
281
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
|
+
|
|
282
385
|
const body = new Builder()
|
|
283
386
|
.u16(LEGACY_VERSION)
|
|
284
387
|
.push(clientRandom)
|
|
285
388
|
.vector(1, sessionId)
|
|
286
389
|
.vector(2, suiteBytes.build())
|
|
287
390
|
.vector(1, Uint8Array.from([0])) // legacy_compression_methods: null only
|
|
288
|
-
.push(encodeExtensionBlock(
|
|
391
|
+
.push(encodeExtensionBlock(laid))
|
|
289
392
|
.build();
|
|
290
393
|
|
|
291
394
|
const message = handshakeMessage(HANDSHAKE_TYPE.client_hello, body);
|
|
@@ -446,6 +549,19 @@ export function negotiateVersion(serverHello, { offeredVersions }) {
|
|
|
446
549
|
*/
|
|
447
550
|
export function negotiateCipher(serverHello, { offeredCiphers, version }) {
|
|
448
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
|
+
}
|
|
449
565
|
if (!offeredCiphers.includes(suite)) {
|
|
450
566
|
throw new TlsUnsupportedError(
|
|
451
567
|
codes.TLS_CIPHER_UNSUPPORTED,
|
package/src/tls/handshake.js
CHANGED
|
@@ -239,6 +239,11 @@ export async function continueTls13(ctx) {
|
|
|
239
239
|
alpn,
|
|
240
240
|
ciphers,
|
|
241
241
|
versions,
|
|
242
|
+
// The retry must reproduce the first hello's extension set AND order: s4.1.2 does not list
|
|
243
|
+
// either among the modifications a second ClientHello may make, and a strict server checks.
|
|
244
|
+
extensionOrder: options.extensionOrder,
|
|
245
|
+
sigSchemes: options.sigSchemes,
|
|
246
|
+
grease: options.grease ?? false,
|
|
242
247
|
random: hello.clientRandom,
|
|
243
248
|
legacySessionId: hello.legacySessionId,
|
|
244
249
|
extraExtensions: cookie ? [cookieExtension(cookie)] : [],
|
package/src/warmup-fixture.js
CHANGED
|
@@ -18,61 +18,61 @@ export const WARMUP_HOSTNAME = "warmup.invalid";
|
|
|
18
18
|
export const WARMUP_NOW = 1893456000000; // fixed epoch ms inside the chain's validity window
|
|
19
19
|
|
|
20
20
|
const CLIENT_PRIV =
|
|
21
|
-
"
|
|
21
|
+
"MC4CAQAwBQYDK2VuBCIEINivgNQO5n8jwnzWfEWjdR14q7Km+UhPnqyL3RtkDbF7";
|
|
22
22
|
const CLIENT_PUB =
|
|
23
|
-
"
|
|
23
|
+
"o5wNrq9yxfTLf8U3jLhUsXB52Kmf2q3zodyy/4jELSM=";
|
|
24
24
|
const CLIENT_RANDOM =
|
|
25
25
|
"AwoRGB8mLTQ7QklQV15lbHN6gYiPlp2kq7K5wMfO1dw=";
|
|
26
26
|
const SESSION_ID =
|
|
27
27
|
"BRAbJjE8R1JdaHN+iZSfqrXAy9bh7PcCDRgjLjlET1o=";
|
|
28
28
|
const CLIENT_HELLO =
|
|
29
29
|
"AQAA9AMDAwoRGB8mLTQ7QklQV15lbHN6gYiPlp2kq7K5wMfO1dwgBRAbJjE8R1JdaHN+iZSfqrXAy9bh7PcCDRgjLjlET1oADBMB" +
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
30
|
+
"EwLAK8AvwCzAMAEAAJ//AQABAAAAABMAEQAADndhcm11cC5pbnZhbGlkAAUABQEAAAAAAAsAAgEAAAoACgAIAB0AFwAYABkAEAAL" +
|
|
31
|
+
"AAkIaHR0cC8xLjEAFwAAAA0AFgAUBAMFAwYDCAQIBQgGCAcEAQUBBgEAKwAFBAMEAwMALQACAQEAMwAmACQAHQAgo5wNrq9yxfTL" +
|
|
32
|
+
"f8U3jLhUsXB52Kmf2q3zodyy/4jELSM=";
|
|
33
33
|
const SERVER_BYTES =
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"+
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"/
|
|
57
|
-
"
|
|
58
|
-
"/
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
34
|
+
"FgMBAHoCAAB2AwMC2KMpeoduVRsS/IdMsSXpe01e8s3wE/rytik1l91rgiAFEBsmMTxHUl1oc36JlJ+qtcDL1uHs9wINGCMuOURP" +
|
|
35
|
+
"WhMBAAAuACsAAgMEADMAJAAdACAr+v3w+m4O5oxHFSy624+fpkWPq277w7+v3Ke3hrKpOxcDAwdY/NxJTmM9YwAtwCbLgQJoioff" +
|
|
36
|
+
"HlNuzTL1ruUzSLqTb4yMSTiKyiOITsAVKStDY8NsguJ1/CKx/s93Oy4wgtcmJudrxMQK5tRTYfwCrZDgR5yfSVD25wIX5VjuTuJ4" +
|
|
37
|
+
"OsQFJ0qX5cqXv4042XcyR06iRqN7iOKY8h2aH5opqQylx5+51GyKjFWB+qIwTceJ8+r/sl/wkxh4hwq7AfmZuRE8suxcZlojgrH6" +
|
|
38
|
+
"ka1eJ81f/HbalU90Xjs3L9A4UnxB/e3CtoA12a8TkvD6bQCb3sCZUwgt0GSKajpw8/n11XWEyW5AAojiopI+pxwur5wMEkdNrABX" +
|
|
39
|
+
"FpDt+pBW2hfWgUgeHO0JLn2XkXQVnipgdd6e6mtePKtIFqT4tW7dW5Rzp1X8QwqSA+oGpwUTleL4qROziaY9cdALrVHFQ3P5L4IA" +
|
|
40
|
+
"fWPF7FmGj+PBOqo1V4Xlw9suTnGJmiIve9XOY+J0GndLb3b/DULmo0wNG4VdvyO0keFqg2Ov3cKAFHfrbPCRSJwdQfpaA2abuC9H" +
|
|
41
|
+
"MkKgK5JAtvWbAopxu281UMA3tJpWSxiI8LjpbLnNqhUFseBukdRFVVPwE4ckqwqk5NcTb66Eqyyw0fd+TioDTqyTyyGJJwvBHm3l" +
|
|
42
|
+
"KXkU4x4crwFsyDWDZpH8KbYbImVlpCtK+uSPEouREgTeimbeR3elxccpfGkm47yYnERBAfCcM18OcZkMb3AqFsgnaN+Cy345lBJs" +
|
|
43
|
+
"uJfRkRfAmdhlCz5KWmqSNY+3fT6iWyJ9dPBvmcsCnxn6FUZfNxAoYSi7wNzsSv7CRMDWPhdEIWMoDVjf8ZKubz0R/OddtVr1Il20" +
|
|
44
|
+
"xiIcevpEJ9pRntZjCkWtykA+z48Vw3Ia8HoMsKDMQagJ472FhNggwMipgA1uM6+nEe+/scUC3HDck6YGwgpb9k1LWVxYsCIdoz0t" +
|
|
45
|
+
"pbVoCV+QCvqyJGNWpIwRQLYjxHV84WzH/tWbGyyFXkFrA/7XgjE4bydjs88lcC7rAHKXLRrpF1CMwQg5XOkI8ftYRbYIKK9OYx/7" +
|
|
46
|
+
"uUPGBPdPvYkrOwe2q1VgHQd+vK87hlN+aNDOlbxSdcy9U84OSUPwDQs7TYqu7R9Hxsc8BTnRo1Gn6XeVrIdVZmrhQQWdCX9MFSww" +
|
|
47
|
+
"GCs8IxY5CM/2/thifsZN85K0AxgBz45XphZS9YNAjCIex8L4ky5mruYESLcHErLPkMZCDUuvgapmtD8SKsxTz/sLhZuotN6Xmbmb" +
|
|
48
|
+
"5WjTm3Rw7W95mI41mHRCLBEHK2KhqSLdpH2vO43D/g4LKAh21M3wxKJAmElkRyUa8zb+E6GcqtTnL6Zldlm+cRp8KopVLBAJFpFw" +
|
|
49
|
+
"T3riPPiJ6QxWcZjM9aXCHPE+TL1W+KzyTwLChHzPjBopJ+PMjXos7T7DTgQLmO9jsdILAl6xNQgPPukoOAy9Wn3yCWcsETEelZbV" +
|
|
50
|
+
"rgZ4aEwXkurJ5RrsOjGEN+uODmJ2lbW2//B+lKlDGILtisLEg8CzzrSN6BZ420Z50PkGZoP6ML7+JC0rtC75gyNrWXYIBzxwr2/S" +
|
|
51
|
+
"2F30780r8cqU5yvq7VI+nDbUzNoauhWgdVBrCQ/4blaupU+yxHbBvYjRBMBuKwcHTJO2R/s+xTqK5j41Sukm3x99+vEqwrTmN1dm" +
|
|
52
|
+
"aWYk70oKuW1Ewx9V7/ExKwGbsDBxYMjKS8h/aAc0CZz7Fxk30V05LA5rUPsG7zeUyKsvBnnivtCcY7GN1b6mEsLhkkHg6hxaDjBf" +
|
|
53
|
+
"GfjodpXTHsbiZExkL+CYQnE6LQGtOhHrt+ojtQb7iSCvdjIZ/i1Mw2N3UFjkW6qM1ISASQBCSrG8WZqvjZQ+wsngaeo1iL5Zg5qv" +
|
|
54
|
+
"Rc+KoaEackS0NVraKSFrc+xHfyofDskV/FsS9ovfYnouP3pV5SdIcBWMOvIUovGLB2m1nW1k1mySRfm4VHqeag/cSmXSNQX6aVSh" +
|
|
55
|
+
"AsrHAyPrwtBBF5Ox892Ok5p91yDahZhDWfTBTTkY76Ru12FaVafiPyG6lFxbx88tIpiVqfnhqvlj2qvLJFe24MmvjWiXdO00axAy" +
|
|
56
|
+
"M9JTe0IvA5hvyVaTn0w1MzzaNBkDDPpgLlt0yEVoblcrqaQjP0H9J+SjxAUgT52sDDCLjFxFY/zh5guI9RhDYZ2Pyg+vdudJKfja" +
|
|
57
|
+
"dr4L4CpUTKFO+DEJf/Efc7nOlTJWD4Dh3SavGfu7gplzh3FMBFegkUkCQJC2hzatNNSpkE8REhC73Rm9me2iysJMv/4Sy5a6Er5Y" +
|
|
58
|
+
"ffE0aAsJ/gXCvE7fQbeZhogp3iBQm911LBwtUn5Wwh8ALGELJxSsQloxFJwPYSzhUGJ01XKNYo6GIWjrpWtcKIKAe1tlB4Ni/Ag7" +
|
|
59
|
+
"bQV7J67dyHvMti/Jgq7VODUJIb8E+u6H7FVQ/8jzE1mm4Dmmtfd9LqB7pal3YbzzxwfN6Qw84jN7lgcksByuZ7eTV2Q4T0IK/toG" +
|
|
60
|
+
"oroTtRRz7vMwg6xLnNIhtdeQaQMVnveuVkWZ29pzwNTxaKVITIIfsJjxTmHqph7wED0rBpR7vSs9WOsMSeoXAwMBGfgD+3KLo6SI" +
|
|
61
|
+
"9AOwKrhRqXO7Xgn2i58peIpNbvy4DVJ3AOjAect4osO+zRcr5gBp/2JFJgL5l5hgdUpApp9U50/SOOPwCvAZIpfPDlWlixJjUF/K" +
|
|
62
|
+
"8NxWHgx62Wuq6mX7CW7BEqwNzb1yYNSzfZYR0qhmiJ04vj4nnxXcmKum9fj5eO4ptiyLUAqrDpYwUAKCAqlLVZCJbS1dVdwEWSQs" +
|
|
63
|
+
"X/lq6RoYYBaRde8fJT2EXzQnfkn5eV4dzI1gseEMQUJ85kshKa847TwI/4rNRhdhXtDCHi8lMfoRgcobGwYmSImTtt6pxJPj07Og" +
|
|
64
|
+
"tyDBW2lyhFOLT2hMSJSdK/tQ/PezQDviGzEaV/5oKHDReaTKWOsjbDkftJiufdJ+FwMDABOfvj5nYBpetvkAsU02oXftLJYe";
|
|
65
65
|
const ROOT_DER =
|
|
66
66
|
"MIIC2jCCAcKgAwIBAgIBATANBgkqhkiG9w0BAQsFADAeMRwwGgYDVQQDDBNXYXJtdXAgRml4dHVyZSBSb290MB4XDTI1MDEwMTAw" +
|
|
67
67
|
"MDAwMFoXDTM1MDEwMTAwMDAwMFowHjEcMBoGA1UEAwwTV2FybXVwIEZpeHR1cmUgUm9vdDCCASIwDQYJKoZIhvcNAQEBBQADggEP" +
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
"
|
|
74
|
-
"
|
|
75
|
-
"
|
|
68
|
+
"ADCCAQoCggEBAMEAjrxVUntvHweCVz7mjYZTWZiTOW3mabXocCrFXKs8CcJVYNGQkpvhC/1VIr4t7hdrSPG31j6PvR4x0OAZTJZR" +
|
|
69
|
+
"k++Zo1jPUWIG3YomFJWhVjoJqoZTcO8uMDydvO75OTxto6Mc5gpfzHgfc52aAqemSqnfzryjupS6o1KOwbE1byLjM/Mmpp4CrRXx" +
|
|
70
|
+
"Fy/Jd8g2w1dSTTZo7QDIYBuZcNW4UYna9Mpac7otnv8JdlRSKonbbxXbx1E2GUm0tVM+4B5ARtBOFggnZYNW+KvQwcl4NRLTrFZS" +
|
|
71
|
+
"KNaRAW78DxclmSBsMm0lV+lsnV9miyuR2vfsj1FwSHKe1gzf12yRMpECAwEAAaMjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B" +
|
|
72
|
+
"Af8EBAMCAgQwDQYJKoZIhvcNAQELBQADggEBAGmB1ws+kKHGtO5D8UnCCyXI/q5LtHsMDO+lDwp/6HAKJ6xVnJCNVP4d1/nTwMVm" +
|
|
73
|
+
"KeJTfEseakqaVSiUcowlPEgSM1OVIRU6aRqBog4cjyBt9F74cmdy+VOewkd4jwHUDnkuj8sNx6BdU8FN83w2nIlQ35g/exzQDP8q" +
|
|
74
|
+
"mB/GtQlnGbmi6mRMKzFn5stSwQS5tcTwz8h5dkDG2FtsBG4f8YDs8r2G0ur7FQD9aIhlttcUUdSE1mZHm4itgqgyE+hreL0x0wqz" +
|
|
75
|
+
"7V7NQK7fMH8OLTr5JrjVMyrpX8EQqyntEkAS8IaaQ42h3GNo1+HRVeJ9M53noaveacxhtTZDnI5u8wA=";
|
|
76
76
|
|
|
77
77
|
export const WARMUP_FIXTURE = {
|
|
78
78
|
clientPrivPkcs8: () => B(CLIENT_PRIV),
|