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.
@@ -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
+ }
@@ -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 && {
@@ -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 ? encodeKeyShare(keyShares.map(({ group, keyExchange }) => ({ group, keyExchange }))) : null,
339
+ offersTls13 ? encodeSupportedVersions(g ? [g.take(), ...versions] : versions) : null,
340
+ offersTls13
341
+ ? encodeKeyShare([
342
+ ...(g ? [greaseKeyShare(g.take())] : []),
343
+ ...keyShares.map(({ group, keyExchange }) => ({ group, keyExchange })),
344
+ ])
345
+ : null,
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(extensionParts))
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,
@@ -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)] : [],
@@ -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
- "MC4CAQAwBQYDK2VuBCIEIPjlXa5xRQTO0OmW85dWmeM6C0AjCqfOKH5MJlnIpT9b";
21
+ "MC4CAQAwBQYDK2VuBCIEINivgNQO5n8jwnzWfEWjdR14q7Km+UhPnqyL3RtkDbF7";
22
22
  const CLIENT_PUB =
23
- "vbmT3r0piIyf5GavMXh1RqIsCml4dFibAAgovDW+Vww=";
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
- "EwLAK8AvwCzAMAEAAJ8AAAATABEAAA53YXJtdXAuaW52YWxpZAAFAAUBAAAAAAAKAAoACAAdABcAGAAZAA0AFgAUBAMFAwYDCAQI" +
31
- "BQgGCAcEAQUBBgEAEAALAAkIaHR0cC8xLjEAKwAFBAMEAwMAMwAmACQAHQAgvbmT3r0piIyf5GavMXh1RqIsCml4dFibAAgovDW+" +
32
- "VwwALQACAQEAFwAAAAsAAgEA/wEAAQA=";
30
+ "EwLAK8AvwCzAMAEAAJ//AQABAAAAABMAEQAADndhcm11cC5pbnZhbGlkAAUABQEAAAAAAAsAAgEAAAoACgAIAB0AFwAYABkAEAAL" +
31
+ "AAkIaHR0cC8xLjEAFwAAAA0AFgAUBAMFAwYDCAQIBQgGCAcEAQUBBgEAKwAFBAMEAwMALQACAQEAMwAmACQAHQAgo5wNrq9yxfTL" +
32
+ "f8U3jLhUsXB52Kmf2q3zodyy/4jELSM=";
33
33
  const SERVER_BYTES =
34
- "FgMBAHoCAAB2AwPR9xYqSLPpiCwNco2cXVmtsxGRHpcVn9pgzYTTNfuygSAFEBsmMTxHUl1oc36JlJ+qtcDL1uHs9wINGCMuOURP" +
35
- "WhMBAAAuACsAAgMEADMAJAAdACB+60XRWGB4e1t9Dy9U4hH3KMr8mYswz6ypnzfDjwHbdRcDAwdYnGQEkD2xWtrlMBiqAi7aAE5y" +
36
- "OFQ7yBVgD+JYtaKoaQ5E6XGWU8HFO8TwqJAN1k1PERiecKl0Pdj1ne7lLDUxJ8w4rJMjXxuc8vSyfN1KjTxoBXvZ4hKuJeolN4Sp" +
37
- "nVeUNtFXfNnLNuk1UDJZ/UWsZKATQM2PQEAgSXJA5bogqMD3w81B9FHF/6TxFiWu1uhB9AavIgwiRrO1XJPt/toozSJl/CMfvJNp" +
38
- "+ukMVYeKACc8Vu9tYYi1gZRbVh9jDpWfW4x5Qsvwb+GgWdvDeDe4+P5nff2cmq2KrlUyGXaezYZiusNpYsRtGDLz+mcH1Gj99m4v" +
39
- "fa4bGGz4lZiZG03M3CYJ6xbTfXn9JnRvhMjNxU4PxN1ntYYjuAPF+1GgKcjpTDWWY2sWNycZXY9nIk7AwG2+bl062gwmTHcSYXut" +
40
- "1G7+WSzncPbzsdN00JJFjJDYMuH+mvtCqRaaOLu3A1Doqmdf79yfBUZN9pNA+DsFTYYaUGD4yaOzp+KguRAuVxd3yA0fsPRmz276" +
41
- "oDmTCkNqgH8AZ3LPBHuW2b/DKG8Md4i6/p1pjHWa3Odjh8AIv6fc59deRwt9w6++FHODQC+NLa+2YFkuPWjxcWvYNBNulTQddz2y" +
42
- "hPIBu8T7cL4lFgK0sgif13Q3ZSRMNbqdmS5T2weodLVnmODNs3o3pA42Q6yt1yPnoKtdk+yQuN2xvOBZb3h3ZMhIMcFHHeGlHoiU" +
43
- "uogypE89jOsorucWK6yroBvewBOA4bx8TtUC46LkGSe5VTqatsixkODqd2RUMISXj+jE/g+niaOUWSQ4ONVucJBd6SgWwCT+EkJb" +
44
- "oxH2ZyfInSNqRUAu5bUsb6P9kodpAtp2FKbLmCALoKL6K7RcHLfPtDh6Ejv8zHay7UGSINZtoKFhkVO5cpoNFoBgxAQud01RVh09" +
45
- "BCz0IhBw8ZIsK+4GLd43ffHjO4Oqx5JQUIXpRO4i5/wqRxFac8SfmqUqkDJ4EqZlbZ0VO151hkslyHUjwb540kFtsO4+6pFw4dZt" +
46
- "Hu9h4qW6wspnm2GWJb1KZtHeQfOD3bNvNCsLPxCSgTkGyXZAzhW1uMzXXElONkw+vaE5y/9W2vadqLR1TMzwYsW3K2qc/zIq7/wx" +
47
- "q0nFJubW9Gty2D2gF3Zikfhid6YDoUYVg5Wy5uXayXR/4Mss7b4VF0ikUCsVn9JVwSony5o4TlHa4Zh9nzmJFXoVKQwmOeQ0/T6g" +
48
- "ap86oZRdXSpMZDg0LarqVOOsU4zyuK0a9Ts8TTmoDxz5wpVRKI87mDb1kN/T8YfDM+Kaf181nhQM+jfXUtj3g2SjrbfmOgDUWcy2" +
49
- "6solXDjF0XWEe42tcQm44k0518zHUL9ItudBsKkLZpFcfmTDHON7SOUjydsLEB5vMd9m/rfDl6c9nGZyUic+LLjpocc41zvyN/cx" +
50
- "qZl/dwFxrHooPEYhaiqOxSbn75doMygIhjwLQHMPrG72d6Ym1UrMcUwhi/4oad6xE7tlIGLbJqv1RCgg6dij5tGpPOvXoBcvQVEs" +
51
- "AWpq8pTXEpru5yydwrX9akoC1J6pKuhwYudrzfA3jvPTcCnHWAKS3PG+hQ59JLuxVUrw6f9A3/jXUUuYk3XkGuKLw924STdQS+9d" +
52
- "i34c7DB6TcDaoq9XMMTs4DsU/aaILIUztt1rgjEyRxx16Kjm8qot5g2ldz/suM7iHSe2Dd+MOvdGmA2cnF6EW2VFMDbbRUe1BL6m" +
53
- "a8Sa62xZPgF+hZrK+cQNs1MYIVjrzdbDxwePWSl+R87dgNBtplZKh/fp4as9tM/SD7kLh/TxeTEBIuuf5VKPGQcHLt4ifYh+4eIj" +
54
- "h9IKJchum7/53DW+13efK2qVXtjAz8izCDmL8My5QrfeWwKhppGzSILDPtEce/MUYu5bUNo4n8AHomWf2fpaeUn104bDHMH+0ook" +
55
- "cpAJki15og5ZrxzkUG6oNtCGgZqfogV3FDRw4olaEkUQY7cHTi7CZkR3oRNiOMJu/fsi6XbksXxNDW9y+ugUwciW9UjqxkfLh7I4" +
56
- "/eETaNRbuVYjcVSypTEuC1dqjXnxFucHN0NyANn1PrIm7qBOoPnrLhq5wYpuLZ9qlfiXWun3oBlO4MyRh0YepClhx/qT74KZryFB" +
57
- "0AyYzMolgyvR6OiZ3ajxEqo2pUgfKtDlo1AqGmydH16HScaaIgG8R2nj30FUE4BOJUr8XWm4Nk9yVuI0LPrVSaIb7RlpfskAVAGu" +
58
- "/wF/YR9Itk9GhGiQ4pw50hgi/MnzQYixea9bUV+1Lfe4aA+/WRoQYsAu2jO+4VsuhwSCv5zTQPllr+rkNmvwjEnX7IAl138dk+/Y" +
59
- "gA16PAWCQUkrk6Z1vZFjl3qfYelCwuIWT7HnfW8ZTYjvDZ5OgnLtm55MKjdrwX6xhxXocZ8+SiurIx9FVQdvFJg3vvAPCYnislUf" +
60
- "wbIQQlCI0JWcyL0iI/JmIE2YWsegK1pvD/3PmB1fUiePd0wDado3LpdO39yUYA6rE4ynHUpwrmnCPlqEOh0XAwMBGacbGrwvRADw" +
61
- "I/oOygBJY1Cl6puADo3n6DTofw4wr1ihuyxX6c2Br8Fj9R57WG8ALIp0CQNNy7mJ8YynOlKP1AeQspIelnQPzVo8l4n7ThvlMayx" +
62
- "xDMnJHdIBCSKg8rUQdxi/zM/z1lnORCzQMJXi2A786I+FTa/2f+y9ln2uDRD6LEKlrz71onlf8o2h2x+cqwYUBdQPeZqa6gkFhWJ" +
63
- "5QXq0jK/iViKEIyduX7wZL9Pf26PKvsuiFUTNwBMvEJdAkSkmhxbR4SeWdMuvxQ/cvIn42Id6HaOqdsEueNrv/dnskCqqAHDGFLe" +
64
- "h+IQgpHZdFNYrFPlhhDLqIHLk6MZZLfS3QLmFhVTSMn4xpQ809ZMvGT3NXWTOB7GFwMDABM00dpa951QoPFgnJv9SpPMaxoa";
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
- "ADCCAQoCggEBAMlyqJWB5tdme7o+TptR4T2er2MD5gsVcRmg1XWAIHnjY5Wy+cQ9dn670JtY5My0WBX+RSDPFsRzPnimzyLm2Jzs" +
69
- "J8xpOMDtfE3/NUjxLSHrhdxJ+gFqoB0j7faIzLar1EXMdlhGCmFtXXoMQyuKhlMRzqP3VpH0N91OkGueQFlJAsuazEf9zm0v6PnV" +
70
- "1QuH4X4+/wRYeAjrLmksLkLhI2h/QZ/EwiVwcLQUvPb4kSHeemZKRRXByLAqOBj6g/MqFRkhqiYQmDB4upjBcYHC8Ae9b02hP8vQ" +
71
- "q9Dv5pRGloGk7olcviiSTj3YTIFd5Cd7xQZXyE42dJa6C9bZHkgZHPMCAwEAAaMjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B" +
72
- "Af8EBAMCAgQwDQYJKoZIhvcNAQELBQADggEBAB62L1/vofc1iZN1EoyIdHfgQZS7EtrjO4kkpHJPb2e1AmgvSCGSBC1OBaqlgpcT" +
73
- "gSfmwSY7g059w18gaEkBhjm5czUmt+IM1DWir6hLPI//6QizsQo0AatePnMvJYuny9VMG2HWbpY17IKxT3/sG4zID7XtDjycuBkf" +
74
- "uy6DN6Qrp0+aqYMfXs28HWwDgf3b7ZjEAwquSQ+dG8Ml36fd8hjydf2VjOl0h5C3xfc88sEEv0BAks11cYjHDT6jHNYYbXfWcesk" +
75
- "nflRe2my0pLsKJFzVhQvpWl6Tud5+Im99hi0GMI1L17hMv9YlCGR+vIQsP78y/WTIEHMiFTpCWd1NTY=";
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),