tunnelfetch 1.2.0 → 1.4.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 +93 -28
- package/README.zh-CN.md +54 -11
- package/package.json +5 -1
- package/src/client/header-order.js +159 -0
- package/src/client.js +79 -18
- package/src/http2/connection.js +36 -4
- package/src/index.js +1 -0
- package/src/profile/chrome.js +45 -0
- package/src/profile/vendor/chacha20poly1305.js +65 -0
- package/src/profile/vendor/mlkem768.js +67 -0
- package/src/profiles.js +184 -0
- package/src/tls/aead.js +51 -16
- package/src/tls/connect.js +49 -8
- package/src/tls/constants.js +60 -6
- package/src/tls/extensions.js +10 -0
- package/src/tls/grease.js +109 -0
- package/src/tls/handshake-messages.js +78 -9
- package/src/tls/handshake.js +2 -1
- package/src/tls/hybrid.js +166 -0
- package/src/tls/record.js +24 -3
- package/src/warmup-fixture.js +46 -45
- package/types/client/header-order.d.ts +56 -0
- package/types/client.d.ts +68 -0
- package/types/http2/connection.d.ts +16 -1
- package/types/http2/hpack.d.ts +1 -1
- package/types/index.d.ts +1 -0
- package/types/profile/chrome.d.ts +16 -0
- package/types/profile/vendor/chacha20poly1305.d.ts +7 -0
- package/types/profile/vendor/mlkem768.d.ts +23 -0
- package/types/profiles.d.ts +100 -0
- package/types/tls/aead.d.ts +17 -1
- package/types/tls/connect.d.ts +45 -6
- package/types/tls/constants.d.ts +31 -3
- package/types/tls/extensions.d.ts +7 -0
- package/types/tls/grease.d.ts +46 -0
- package/types/tls/handshake-messages.d.ts +12 -5
- package/types/tls/hybrid.d.ts +63 -0
- package/types/tls/record.d.ts +23 -0
package/src/tls/aead.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import { TlsError, TlsUnsupportedError, codes, hex16 } from '../errors.js';
|
|
22
22
|
import { concat, u8, u16 } from '../util/bytes.js';
|
|
23
23
|
import {
|
|
24
|
+
CIPHER,
|
|
24
25
|
CIPHER_PARAMS, CIPHER_NAME, MAX_PLAINTEXT, LEGACY_VERSION, TLS12, TLS13,
|
|
25
26
|
} from './constants.js';
|
|
26
27
|
|
|
@@ -88,6 +89,12 @@ function seq64(seq) {
|
|
|
88
89
|
* @property {Uint8Array} key
|
|
89
90
|
* @property {Uint8Array} iv the 12-byte static IV for TLS 1.3, the 4-byte implicit salt for
|
|
90
91
|
* TLS 1.2
|
|
92
|
+
* @property {{seal: (k: Uint8Array, n: Uint8Array, p: Uint8Array, aad: Uint8Array) => Uint8Array,
|
|
93
|
+
* open: (k: Uint8Array, n: Uint8Array, c: Uint8Array, aad: Uint8Array) => Uint8Array | null}}
|
|
94
|
+
* [impl] caller-supplied AEAD, required for ChaCha20-Poly1305 and unused otherwise. This runtime
|
|
95
|
+
* has no WebCrypto ChaCha20 — its only native path is node:crypto, and taking it would cost the
|
|
96
|
+
* package its "web platform only" property — so the implementation is injected. `open` returns
|
|
97
|
+
* null when authentication fails.
|
|
91
98
|
*/
|
|
92
99
|
|
|
93
100
|
/**
|
|
@@ -97,7 +104,7 @@ function seq64(seq) {
|
|
|
97
104
|
* @param {AeadOptions} opts
|
|
98
105
|
* @returns {Promise<Aead>}
|
|
99
106
|
*/
|
|
100
|
-
export async function createAead({ version = TLS13, cipher, key, iv }) {
|
|
107
|
+
export async function createAead({ version = TLS13, cipher, key, iv, impl = null }) {
|
|
101
108
|
const params = CIPHER_PARAMS[cipher];
|
|
102
109
|
if (!params || params.hash === undefined) {
|
|
103
110
|
throw new TlsUnsupportedError(codes.TLS_CIPHER_UNSUPPORTED,
|
|
@@ -119,8 +126,43 @@ export async function createAead({ version = TLS13, cipher, key, iv }) {
|
|
|
119
126
|
`iv is ${iv.byteLength} bytes; ${CIPHER_NAME[cipher]} needs ${wantIv} for this version`,
|
|
120
127
|
{ cipher, version });
|
|
121
128
|
}
|
|
122
|
-
|
|
123
|
-
|
|
129
|
+
// ChaCha20-Poly1305 has no WebCrypto on this runtime — feature-detected on workerd, where the
|
|
130
|
+
// only native path is node:crypto and taking it would cost the package its "web platform only"
|
|
131
|
+
// property. So the implementation is INJECTED: `impl` supplies seal/open, and without one the
|
|
132
|
+
// suite is never offered, so a ChaCha20 record can only be reached by a server negotiating a
|
|
133
|
+
// suite that was not in the offer, which negotiateCipher already refuses.
|
|
134
|
+
const isChaCha = cipher === CIPHER.TLS_CHACHA20_POLY1305_SHA256;
|
|
135
|
+
if (isChaCha && (typeof impl?.seal !== 'function' || typeof impl?.open !== 'function')) {
|
|
136
|
+
throw new TlsError(
|
|
137
|
+
codes.CONFIG_INVALID,
|
|
138
|
+
'ChaCha20-Poly1305 was negotiated but no implementation was supplied; pass one as ' +
|
|
139
|
+
'`ciphers: { chacha20: impl }` with seal(key, nonce, plaintext, aad) and open(...)',
|
|
140
|
+
{ cipher },
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const gcmKey = isChaCha
|
|
144
|
+
? null
|
|
145
|
+
: await crypto.subtle.importKey('raw', key, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
|
146
|
+
const chachaKey = isChaCha ? key.slice() : null;
|
|
147
|
+
/** One seal, whichever cipher was negotiated. Returns ciphertext||tag. */
|
|
148
|
+
const seal = async (nonce, aad, plain) =>
|
|
149
|
+
isChaCha
|
|
150
|
+
? impl.seal(chachaKey, nonce, plain, aad)
|
|
151
|
+
: new Uint8Array(
|
|
152
|
+
await crypto.subtle.encrypt(
|
|
153
|
+
{ name: 'AES-GCM', iv: nonce, additionalData: aad, tagLength: tagLen * 8 },
|
|
154
|
+
gcmKey, plain));
|
|
155
|
+
/** One open. Returns null on authentication failure, which every caller turns into tagFailure. */
|
|
156
|
+
const open = async (nonce, aad, ct) => {
|
|
157
|
+
if (isChaCha) return impl.open(chachaKey, nonce, ct, aad);
|
|
158
|
+
try {
|
|
159
|
+
return new Uint8Array(
|
|
160
|
+
await crypto.subtle.decrypt(
|
|
161
|
+
{ name: 'AES-GCM', iv: nonce, additionalData: aad, tagLength: tagLen * 8 }, gcmKey, ct));
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
124
166
|
const staticIv = iv.slice(); // defensive copy: the caller may zero or reuse its buffer
|
|
125
167
|
|
|
126
168
|
const tagFailure = () => new TlsError(codes.TLS_RECORD,
|
|
@@ -152,10 +194,7 @@ export async function createAead({ version = TLS13, cipher, key, iv }) {
|
|
|
152
194
|
inner[plaintext.byteLength] = type;
|
|
153
195
|
const aad = concat([u8(23), u16(LEGACY_VERSION), u16(inner.byteLength + tagLen)]);
|
|
154
196
|
const nonce = buildNonce(staticIv, seq);
|
|
155
|
-
|
|
156
|
-
{ name: 'AES-GCM', iv: nonce, additionalData: aad, tagLength: tagLen * 8 },
|
|
157
|
-
gcmKey, inner);
|
|
158
|
-
return new Uint8Array(ct);
|
|
197
|
+
return await seal(nonce, aad, inner);
|
|
159
198
|
},
|
|
160
199
|
|
|
161
200
|
/**
|
|
@@ -177,12 +216,11 @@ export async function createAead({ version = TLS13, cipher, key, iv }) {
|
|
|
177
216
|
const nonce = buildNonce(staticIv, seq);
|
|
178
217
|
let inner;
|
|
179
218
|
try {
|
|
180
|
-
inner =
|
|
181
|
-
{ name: 'AES-GCM', iv: nonce, additionalData: header, tagLength: tagLen * 8 },
|
|
182
|
-
gcmKey, body));
|
|
219
|
+
inner = await open(nonce, header, body);
|
|
183
220
|
} catch {
|
|
184
221
|
throw tagFailure();
|
|
185
222
|
}
|
|
223
|
+
if (inner == null) throw tagFailure();
|
|
186
224
|
// Strip the zero padding to expose the real content type (RFC 8446 s5.4). Scanning
|
|
187
225
|
// from the end is not constant time, but padding length is not secret from an attacker
|
|
188
226
|
// who can measure it anyway via record timing; the RFC imposes no such requirement.
|
|
@@ -222,9 +260,7 @@ export async function createAead({ version = TLS13, cipher, key, iv }) {
|
|
|
222
260
|
const explicit = seq64(seq);
|
|
223
261
|
const nonce = concat([staticIv, explicit]);
|
|
224
262
|
const aad = concat([explicit, u8(type), u16(TLS12), u16(plaintext.byteLength)]);
|
|
225
|
-
const ct =
|
|
226
|
-
{ name: 'AES-GCM', iv: nonce, additionalData: aad, tagLength: tagLen * 8 },
|
|
227
|
-
gcmKey, plaintext));
|
|
263
|
+
const ct = await seal(nonce, aad, plaintext);
|
|
228
264
|
return concat([explicit, ct]);
|
|
229
265
|
},
|
|
230
266
|
|
|
@@ -251,12 +287,11 @@ export async function createAead({ version = TLS13, cipher, key, iv }) {
|
|
|
251
287
|
const aad = concat([seq64(seq), header.subarray(0, 3), u16(ptLen)]);
|
|
252
288
|
let plaintext;
|
|
253
289
|
try {
|
|
254
|
-
plaintext =
|
|
255
|
-
{ name: 'AES-GCM', iv: nonce, additionalData: aad, tagLength: tagLen * 8 },
|
|
256
|
-
gcmKey, body.subarray(8)));
|
|
290
|
+
plaintext = await open(nonce, aad, body.subarray(8));
|
|
257
291
|
} catch {
|
|
258
292
|
throw tagFailure();
|
|
259
293
|
}
|
|
294
|
+
if (plaintext == null) throw tagFailure();
|
|
260
295
|
return { type: header[0], plaintext };
|
|
261
296
|
},
|
|
262
297
|
};
|
package/src/tls/connect.js
CHANGED
|
@@ -30,13 +30,16 @@ import { RecordLayer } from './record.js';
|
|
|
30
30
|
import { Transcript } from './transcript.js';
|
|
31
31
|
import {
|
|
32
32
|
ALPN_HTTP11,
|
|
33
|
+
CIPHER,
|
|
33
34
|
CIPHER_PARAMS,
|
|
35
|
+
GROUP,
|
|
34
36
|
HANDSHAKE_TYPE,
|
|
35
37
|
SUPPORTED_GROUPS,
|
|
36
38
|
TLS12,
|
|
37
39
|
TLS12_CIPHERS,
|
|
38
40
|
TLS13,
|
|
39
41
|
TLS13_CIPHERS,
|
|
42
|
+
TLS13_CIPHERS_WITH_CHACHA,
|
|
40
43
|
} from './constants.js';
|
|
41
44
|
import {
|
|
42
45
|
buildClientHello,
|
|
@@ -136,7 +139,12 @@ function expectServerHello(msg, offers12) {
|
|
|
136
139
|
* supported group; a HelloRetryRequest recovers any other choice at the cost of a round trip.
|
|
137
140
|
* @property {number[]} [ciphers] cipher suites to offer, in preference order.
|
|
138
141
|
* @property {number[]} [sigSchemes] signature_algorithms to offer, in preference order.
|
|
139
|
-
* @property {number
|
|
142
|
+
* @property {boolean | number} [grease] send GREASE (RFC 8701) reserved values in the cipher list,
|
|
143
|
+
* the extension list (one at each end), supported_groups, supported_versions and key_share.
|
|
144
|
+
* Default false, because curl does not GREASE — Chromium does. A number is a seed, which makes
|
|
145
|
+
* the hello reproducible; `true` draws one from `deps.randomBytes`. A server that negotiates a
|
|
146
|
+
* GREASE value is refused with a typed error naming it.
|
|
147
|
+
* @property {number[] | 'shuffle'} [extensionOrder] ClientHello extension types, in the order to emit them.
|
|
140
148
|
* JA3 and JA4 hash the extension list in WIRE ORDER, so this is most of what a fingerprinter
|
|
141
149
|
* reads. Defaults to curl's order (`CURL_EXTENSION_ORDER`). Extensions not named keep their
|
|
142
150
|
* natural position at the end; `pre_shared_key` is always last whatever is asked, because RFC
|
|
@@ -190,11 +198,19 @@ function expectServerHello(msg, offers12) {
|
|
|
190
198
|
*/
|
|
191
199
|
|
|
192
200
|
/**
|
|
193
|
-
* Injectable nondeterminism
|
|
194
|
-
*
|
|
201
|
+
* Injectable nondeterminism and crypto primitives the platform does not provide.
|
|
202
|
+
*
|
|
203
|
+
* `randomBytes` and `generateKeyPair` supply reproducibility (a recorded session replayed in an
|
|
204
|
+
* offline test). `aead` and `kem` supply capabilities this runtime lacks entirely: ChaCha20 and
|
|
205
|
+
* ML-KEM are absent from WebCrypto here, so an implementation must be injected before the suite or
|
|
206
|
+
* group they back can be offered — a ClientHello being an offer a server may take.
|
|
195
207
|
* @typedef {object} TlsDeps
|
|
196
208
|
* @property {(n: number) => Uint8Array} [randomBytes]
|
|
197
209
|
* @property {(algorithm: object, group: number) => Promise<CryptoKeyPair>} [generateKeyPair]
|
|
210
|
+
* @property {{ chacha20?: import('./aead.js').AeadOptions['impl'] }} [aead] injected AEAD
|
|
211
|
+
* implementations by name; `chacha20` gates and performs TLS_CHACHA20_POLY1305_SHA256
|
|
212
|
+
* @property {{ x25519mlkem768?: import('./hybrid.js').MlKem768 }} [kem] injected KEM
|
|
213
|
+
* implementations by name; `x25519mlkem768` gates and performs the X25519MLKEM768 hybrid group
|
|
198
214
|
*/
|
|
199
215
|
|
|
200
216
|
/**
|
|
@@ -249,6 +265,9 @@ export async function connectTls({ transport, hostname, verifyPeer, options = {}
|
|
|
249
265
|
const record = new RecordLayer(transport, {
|
|
250
266
|
maxHandshakeMessage: options.maxHandshakeMessage,
|
|
251
267
|
maxKeyUpdates: options.maxKeyUpdates,
|
|
268
|
+
// ChaCha20-Poly1305 has no WebCrypto path here; its seal/open are injected via deps.aead and
|
|
269
|
+
// threaded to every createAead. Null for the AES-GCM-only default, which needs nothing.
|
|
270
|
+
aeadImpls: deps.aead ?? null,
|
|
252
271
|
});
|
|
253
272
|
// Until the ServerHello picks, the record layer speaks the LOWEST version offered. The two
|
|
254
273
|
// semantics that differ before any ServerHello are alert tolerance and CCS handling, and the
|
|
@@ -277,15 +296,36 @@ async function drive({ record, hostname, verifyPeer, options, deps, versions })
|
|
|
277
296
|
const offers13 = versions.includes(TLS13);
|
|
278
297
|
const offers12 = versions.includes(TLS12);
|
|
279
298
|
const alpn = options.alpn ?? [ALPN_HTTP11];
|
|
280
|
-
|
|
281
|
-
|
|
299
|
+
|
|
300
|
+
// The two capability-gated primitives. A ClientHello is an offer a server may take, so neither
|
|
301
|
+
// the ChaCha20 suite nor the X25519MLKEM768 group may appear on the wire unless an implementation
|
|
302
|
+
// was injected — this runtime can perform neither on its own. Both are filtered out of ANY offer
|
|
303
|
+
// list (explicit or default) when their implementation is absent, which is what makes them
|
|
304
|
+
// impossible to advertise dishonestly.
|
|
305
|
+
const chacha = deps.aead?.chacha20;
|
|
306
|
+
const mlkem = deps.kem?.x25519mlkem768;
|
|
307
|
+
|
|
308
|
+
// supported_groups, and the groups a real key_share is sent for. With ML-KEM injected the
|
|
309
|
+
// default matches curl: X25519MLKEM768 first in both, alongside x25519. Without it, the classical
|
|
310
|
+
// default is unchanged and the hybrid group is stripped from any explicit list.
|
|
311
|
+
let groups = options.groups ?? (mlkem ? [GROUP.x25519mlkem768, ...SUPPORTED_GROUPS] : SUPPORTED_GROUPS);
|
|
312
|
+
let offerGroups =
|
|
313
|
+
options.offerGroups ?? (mlkem ? [GROUP.x25519mlkem768, SUPPORTED_GROUPS[0]] : DEFAULT_OFFER_GROUPS);
|
|
314
|
+
if (!mlkem) {
|
|
315
|
+
groups = groups.filter((g) => g !== GROUP.x25519mlkem768);
|
|
316
|
+
offerGroups = offerGroups.filter((g) => g !== GROUP.x25519mlkem768);
|
|
317
|
+
}
|
|
318
|
+
|
|
282
319
|
// Suites for every offered version, 1.3 first. negotiateCipher later re-checks the family of
|
|
283
320
|
// the server's pick against the negotiated version, so a union offer cannot be abused to run
|
|
284
|
-
// a 1.3 suite under 1.2 or the reverse.
|
|
285
|
-
|
|
286
|
-
|
|
321
|
+
// a 1.3 suite under 1.2 or the reverse. With ChaCha20 injected the DEFAULT switches to curl's
|
|
322
|
+
// captured TLS 1.3 order (AES-256-GCM, ChaCha20, AES-128-GCM); an explicit list keeps its own
|
|
323
|
+
// order and simply has 0x1303 filtered out when no implementation is present.
|
|
324
|
+
let ciphers = options.ciphers ?? [
|
|
325
|
+
...(offers13 ? (chacha ? TLS13_CIPHERS_WITH_CHACHA : TLS13_CIPHERS) : []),
|
|
287
326
|
...(offers12 ? TLS12_CIPHERS : []),
|
|
288
327
|
];
|
|
328
|
+
if (!chacha) ciphers = ciphers.filter((c) => c !== CIPHER.TLS_CHACHA20_POLY1305_SHA256);
|
|
289
329
|
|
|
290
330
|
// --- resumption offer ----------------------------------------------------------------------
|
|
291
331
|
// Everything about the offered PSK that later steps need is derived once, up front: the Early
|
|
@@ -334,6 +374,7 @@ async function drive({ record, hostname, verifyPeer, options, deps, versions })
|
|
|
334
374
|
versions,
|
|
335
375
|
extensionOrder: options.extensionOrder,
|
|
336
376
|
sigSchemes: options.sigSchemes,
|
|
377
|
+
grease: options.grease ?? false,
|
|
337
378
|
random: options.clientRandom,
|
|
338
379
|
legacySessionId: options.legacySessionId,
|
|
339
380
|
psk: pskOffer && {
|
package/src/tls/constants.js
CHANGED
|
@@ -81,7 +81,7 @@ export const CIPHER = {
|
|
|
81
81
|
// TLS 1.3
|
|
82
82
|
TLS_AES_128_GCM_SHA256: 0x1301,
|
|
83
83
|
TLS_AES_256_GCM_SHA384: 0x1302,
|
|
84
|
-
TLS_CHACHA20_POLY1305_SHA256: 0x1303, //
|
|
84
|
+
TLS_CHACHA20_POLY1305_SHA256: 0x1303, // offered only when an implementation is injected
|
|
85
85
|
// TLS 1.2, ECDHE + AEAD only
|
|
86
86
|
TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256: 0xc02b,
|
|
87
87
|
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: 0xc02f,
|
|
@@ -93,13 +93,39 @@ export const CIPHER = {
|
|
|
93
93
|
export const CIPHER_NAME = Object.fromEntries(Object.entries(CIPHER).map(([k, v]) => [v, k]));
|
|
94
94
|
|
|
95
95
|
/** Offered in ClientHello, in preference order. */
|
|
96
|
-
|
|
96
|
+
// curl's order, captured off the wire: `0x1302 0x1303 0x1301`, AES-256 before AES-128. Everything
|
|
97
|
+
// through 1.3.0 offered the reverse — the extension order and the header order were matched to curl
|
|
98
|
+
// and the CIPHER order was never checked, though JA3 hashes it just as directly.
|
|
99
|
+
//
|
|
100
|
+
// Client preference is advisory: most servers impose their own, so what this mainly changes is the
|
|
101
|
+
// fingerprint. Where a server does follow the client it now picks AES-256, which on this runtime
|
|
102
|
+
// costs a little more CPU per byte than AES-128 and is what curl asks for.
|
|
103
|
+
export const TLS13_CIPHERS = [CIPHER.TLS_AES_256_GCM_SHA384, CIPHER.TLS_AES_128_GCM_SHA256];
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* curl 8.21.0 / OpenSSL 3.6.3 offers its TLS 1.3 suites in this exact order — AES-256-GCM,
|
|
107
|
+
* ChaCha20-Poly1305, AES-128-GCM — captured off the wire 2026-08-01 (`0x1302 0x1303 0x1301`).
|
|
108
|
+
* ChaCha20 is SECOND, right after AES-256-GCM; that is "curl's position" for it.
|
|
109
|
+
*
|
|
110
|
+
* TLS13_CIPHERS above leads with AES-128, which is the order this package has always offered and
|
|
111
|
+
* which the offline test server keys its default suite selection off; reordering it would change
|
|
112
|
+
* the negotiated suite across the whole suite. So this curl-faithful order is used ONLY when a
|
|
113
|
+
* ChaCha20 implementation has been injected — i.e. when the caller has opted into being able to
|
|
114
|
+
* perform every suite curl offers — and never otherwise. See connect.js.
|
|
115
|
+
*/
|
|
116
|
+
export const TLS13_CIPHERS_WITH_CHACHA = [
|
|
117
|
+
CIPHER.TLS_AES_256_GCM_SHA384,
|
|
118
|
+
CIPHER.TLS_CHACHA20_POLY1305_SHA256,
|
|
119
|
+
CIPHER.TLS_AES_128_GCM_SHA256,
|
|
120
|
+
];
|
|
97
121
|
|
|
122
|
+
// Likewise curl's relative order among the four suites this package implements: of its full list,
|
|
123
|
+
// `0xc02c 0xc030 ... 0xc02b 0xc02f` — ECDSA before RSA within each strength, AES-256 before AES-128.
|
|
98
124
|
export const TLS12_CIPHERS = [
|
|
99
|
-
CIPHER.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
|
100
|
-
CIPHER.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
|
101
125
|
CIPHER.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
|
102
126
|
CIPHER.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
|
127
|
+
CIPHER.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
|
128
|
+
CIPHER.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
|
103
129
|
];
|
|
104
130
|
|
|
105
131
|
/**
|
|
@@ -118,6 +144,14 @@ export const TLS12_CIPHERS = [
|
|
|
118
144
|
export const CIPHER_PARAMS = {
|
|
119
145
|
[CIPHER.TLS_AES_128_GCM_SHA256]: { hash: 'SHA-256', hashLen: 32, keyLen: 16, ivLen: 12, tagLen: 16 },
|
|
120
146
|
[CIPHER.TLS_AES_256_GCM_SHA384]: { hash: 'SHA-384', hashLen: 48, keyLen: 32, ivLen: 12, tagLen: 16 },
|
|
147
|
+
// Parameters only. Deliberately NOT in TLS13_CIPHERS, so it is never offered unless a caller
|
|
148
|
+
// supplies an implementation — this runtime has no WebCrypto ChaCha20, and its only native path
|
|
149
|
+
// is node:crypto, which the package will not require. Having the parameters here lets the AEAD
|
|
150
|
+
// layer refuse with "no implementation supplied" rather than "unknown suite", which is the
|
|
151
|
+
// difference between a fixable configuration and an apparent dead end.
|
|
152
|
+
[CIPHER.TLS_CHACHA20_POLY1305_SHA256]: {
|
|
153
|
+
hash: 'SHA-256', hashLen: 32, keyLen: 32, ivLen: 12, tagLen: 16,
|
|
154
|
+
},
|
|
121
155
|
[CIPHER.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256]: {
|
|
122
156
|
hash: 'SHA-256', hashLen: 32, keyLen: 16, ivLen: 12, tagLen: 16, fixedIvLen: 4, sig: 'ecdsa',
|
|
123
157
|
},
|
|
@@ -141,6 +175,11 @@ export const GROUP = {
|
|
|
141
175
|
x25519: 0x001d,
|
|
142
176
|
x448: 0x001e,
|
|
143
177
|
ffdhe2048: 0x0100,
|
|
178
|
+
// Post-quantum hybrid (draft-kwiatkowski-tls-ecdhe-mlkem): ML-KEM-768 + X25519. Reachable only
|
|
179
|
+
// when an ML-KEM implementation is injected — its key exchange is not a WebCrypto primitive on
|
|
180
|
+
// this runtime — so like ChaCha20 it is never offered unless the capability was supplied. The
|
|
181
|
+
// combiner lives in hybrid.js; GROUP_PARAMS below records only the wire sizes, not an algorithm.
|
|
182
|
+
x25519mlkem768: 0x11ec,
|
|
144
183
|
};
|
|
145
184
|
|
|
146
185
|
export const GROUP_NAME = Object.fromEntries(Object.entries(GROUP).map(([k, v]) => [v, k]));
|
|
@@ -154,10 +193,14 @@ export const SUPPORTED_GROUPS = [GROUP.x25519, GROUP.secp256r1, GROUP.secp384r1,
|
|
|
154
193
|
|
|
155
194
|
/**
|
|
156
195
|
* WebCrypto parameters per group, discriminated on `kind` because X25519 sizes its shared
|
|
157
|
-
* secret in bytes while ECDH sizes it in bits
|
|
196
|
+
* secret in bytes while ECDH sizes it in bits, and the ML-KEM hybrid is not a WebCrypto
|
|
197
|
+
* primitive at all — it carries wire sizes only, and hybrid.js owns the crypto.
|
|
158
198
|
* @typedef {{ kind: 'x25519', algorithm: { name: string }, publicLen: number, secretLen: number }
|
|
159
199
|
* | { kind: 'ec', algorithm: { name: string, namedCurve: string }, publicLen: number,
|
|
160
|
-
* secretBits: number }
|
|
200
|
+
* secretBits: number }
|
|
201
|
+
* | { kind: 'hybrid', clientShareLen: number, serverShareLen: number, secretLen: number,
|
|
202
|
+
* mlkemPublicLen: number, mlkemSecretKeyLen: number, mlkemCiphertextLen: number,
|
|
203
|
+
* classicalPublicLen: number, classicalSecretLen: number }} GroupParams
|
|
161
204
|
*/
|
|
162
205
|
|
|
163
206
|
/**
|
|
@@ -175,6 +218,17 @@ export const GROUP_PARAMS = {
|
|
|
175
218
|
[GROUP.secp521r1]: {
|
|
176
219
|
kind: 'ec', algorithm: { name: 'ECDH', namedCurve: 'P-521' }, publicLen: 133, secretBits: 528,
|
|
177
220
|
},
|
|
221
|
+
// X25519MLKEM768 (draft-kwiatkowski-tls-ecdhe-mlkem). The wire sizes below are FIPS 203
|
|
222
|
+
// ML-KEM-768 (ek 1184, ct 1088, dk 2400) alongside X25519 (32). The client's key_share is
|
|
223
|
+
// 1184 + 32 = 1216 bytes and the server's is 1088 + 32 = 1120; the shared secret fed to the
|
|
224
|
+
// key schedule is 32 + 32 = 64. Ordering (ML-KEM before X25519, in both the shares and the
|
|
225
|
+
// secret) is spelled out and enforced in hybrid.js — it is the whole subtlety of this group.
|
|
226
|
+
[GROUP.x25519mlkem768]: {
|
|
227
|
+
kind: 'hybrid',
|
|
228
|
+
clientShareLen: 1216, serverShareLen: 1120, secretLen: 64,
|
|
229
|
+
mlkemPublicLen: 1184, mlkemSecretKeyLen: 2400, mlkemCiphertextLen: 1088,
|
|
230
|
+
classicalPublicLen: 32, classicalSecretLen: 32,
|
|
231
|
+
},
|
|
178
232
|
};
|
|
179
233
|
|
|
180
234
|
// -------------------------------------------------------------------- signature schemes
|
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,9 +11,13 @@
|
|
|
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';
|
|
18
|
+
// The X25519MLKEM768 hybrid is a group like any other to the driver, but its key exchange is not
|
|
19
|
+
// a WebCrypto primitive, so its keygen/derive live in their own module and are dispatched to here.
|
|
20
|
+
import { HYBRID_GROUP, deriveHybridSecret, generateHybridKeyShare } from './hybrid.js';
|
|
17
21
|
import {
|
|
18
22
|
CIPHER_NAME,
|
|
19
23
|
CIPHER_PARAMS,
|
|
@@ -49,6 +53,7 @@ import {
|
|
|
49
53
|
encodePreSharedKey,
|
|
50
54
|
encodePskKeyExchangeModes,
|
|
51
55
|
encodeRenegotiationInfo,
|
|
56
|
+
encodeRawExtension,
|
|
52
57
|
encodeServerName,
|
|
53
58
|
encodeSignatureAlgorithms,
|
|
54
59
|
encodeStatusRequest,
|
|
@@ -75,16 +80,20 @@ const defaultRandom = (n) => crypto.getRandomValues(new Uint8Array(n));
|
|
|
75
80
|
/**
|
|
76
81
|
* Generate an ephemeral key share for one group.
|
|
77
82
|
* `generateKeyPair` is injectable so a recorded handshake can be replayed with the exact private
|
|
78
|
-
* key that produced it.
|
|
83
|
+
* key that produced it. X25519MLKEM768 dispatches to hybrid.js, using the injected ML-KEM
|
|
84
|
+
* implementation from `deps.kem`.
|
|
79
85
|
*
|
|
80
86
|
* @param {number} group
|
|
81
87
|
* @param {import('./connect.js').TlsDeps} [deps]
|
|
82
88
|
* @returns {Promise<KeyShare>}
|
|
83
89
|
*/
|
|
84
|
-
export async function generateKeyShare(group,
|
|
90
|
+
export async function generateKeyShare(group, deps = {}) {
|
|
91
|
+
if (group === HYBRID_GROUP) {
|
|
92
|
+
return generateHybridKeyShare(deps.kem?.x25519mlkem768, deps);
|
|
93
|
+
}
|
|
85
94
|
const params = requireSupportedGroup(group, 'ClientHello');
|
|
86
95
|
const gen =
|
|
87
|
-
generateKeyPair ??
|
|
96
|
+
deps.generateKeyPair ??
|
|
88
97
|
((algorithm) => crypto.subtle.generateKey(algorithm, false, ['deriveBits']));
|
|
89
98
|
const pair = await gen(params.algorithm, group);
|
|
90
99
|
const raw = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey));
|
|
@@ -104,11 +113,17 @@ export async function generateKeyShare(group, { generateKeyPair } = {}) {
|
|
|
104
113
|
* so it is checked here first.
|
|
105
114
|
*
|
|
106
115
|
* @param {number} group
|
|
107
|
-
* @param {CryptoKey} privateKey our ephemeral private key
|
|
116
|
+
* @param {CryptoKey | import('./hybrid.js').HybridPrivate} privateKey our ephemeral private key
|
|
117
|
+
* for the group (a compound value for the ML-KEM hybrid)
|
|
108
118
|
* @param {Uint8Array} peerKey the server's raw public key from its key_share
|
|
119
|
+
* @param {import('./connect.js').TlsDeps} [deps] carries the injected ML-KEM implementation the
|
|
120
|
+
* hybrid group needs; unused by the classical groups
|
|
109
121
|
* @returns {Promise<Uint8Array>} throws on any degenerate or malformed peer key
|
|
110
122
|
*/
|
|
111
|
-
export async function deriveSharedSecret(group, privateKey, peerKey) {
|
|
123
|
+
export async function deriveSharedSecret(group, privateKey, peerKey, deps = {}) {
|
|
124
|
+
if (group === HYBRID_GROUP) {
|
|
125
|
+
return deriveHybridSecret(deps.kem?.x25519mlkem768, privateKey, peerKey);
|
|
126
|
+
}
|
|
112
127
|
const params = requireSupportedGroup(group, 'ServerHello');
|
|
113
128
|
if (peerKey.byteLength !== params.publicLen) {
|
|
114
129
|
throw new TlsError(
|
|
@@ -230,6 +245,9 @@ export async function deriveSharedSecret(group, privateKey, peerKey) {
|
|
|
230
245
|
* whatever the caller asks for, because RFC 8446 s4.2.11 defines the binder transcript as the hello
|
|
231
246
|
* truncated just before the binders — a range that only exists if nothing follows them.
|
|
232
247
|
*/
|
|
248
|
+
/** `extensionOrder: SHUFFLE_EXTENSIONS` reproduces what Chromium does — see grease.js. */
|
|
249
|
+
export const SHUFFLE_EXTENSIONS = 'shuffle';
|
|
250
|
+
|
|
233
251
|
export const CURL_EXTENSION_ORDER = Object.freeze([
|
|
234
252
|
EXTENSION.renegotiation_info,
|
|
235
253
|
EXTENSION.server_name,
|
|
@@ -282,8 +300,16 @@ export function buildClientHello({
|
|
|
282
300
|
extensionOrder = CURL_EXTENSION_ORDER,
|
|
283
301
|
extraExtensions = [],
|
|
284
302
|
psk = null,
|
|
303
|
+
grease = false,
|
|
285
304
|
randomBytes = defaultRandom,
|
|
286
305
|
}) {
|
|
306
|
+
// A seed rather than ambient randomness: repo-hygiene forbids Math.random in src/ so that every
|
|
307
|
+
// byte this package puts on the wire is reproducible in a test, and a fingerprint that cannot be
|
|
308
|
+
// reproduced cannot be asserted byte-for-byte.
|
|
309
|
+
const g = grease === false || grease == null
|
|
310
|
+
? null
|
|
311
|
+
: greaseSource(typeof grease === 'number' ? grease : ((randomBytes(4)[0] << 24) |
|
|
312
|
+
(randomBytes(4)[1] << 16) | (randomBytes(4)[2] << 8) | randomBytes(4)[3]) >>> 0);
|
|
287
313
|
const clientRandom = random ?? randomBytes(32);
|
|
288
314
|
if (clientRandom.byteLength !== 32) {
|
|
289
315
|
throw new TlsError(codes.CONFIG_INVALID, `ClientHello.random must be 32 bytes, got ${clientRandom.byteLength}`);
|
|
@@ -302,7 +328,9 @@ export function buildClientHello({
|
|
|
302
328
|
throw new TlsError(codes.CONFIG_INVALID, 'ClientHello would offer no cipher suites');
|
|
303
329
|
}
|
|
304
330
|
|
|
331
|
+
// GREASE goes FIRST in each list, which is where Chromium puts it — captured, not recalled.
|
|
305
332
|
const suiteBytes = new Builder();
|
|
333
|
+
if (g) suiteBytes.u16(g.take());
|
|
306
334
|
for (const s of suites) suiteBytes.u16(s);
|
|
307
335
|
|
|
308
336
|
if (psk && !offersTls13) {
|
|
@@ -318,11 +346,16 @@ export function buildClientHello({
|
|
|
318
346
|
// Always offered, for either version: without it a server may not staple (RFC 6066 s8), and
|
|
319
347
|
// a stapled OCSP response is the only revocation signal this package can consume.
|
|
320
348
|
encodeStatusRequest(),
|
|
321
|
-
encodeSupportedGroups(groups),
|
|
349
|
+
encodeSupportedGroups(g ? [g.take(), ...groups] : groups),
|
|
322
350
|
encodeSignatureAlgorithms(sigSchemes),
|
|
323
351
|
alpn.length ? encodeAlpn(alpn) : null,
|
|
324
|
-
offersTls13 ? encodeSupportedVersions(versions) : null,
|
|
325
|
-
offersTls13
|
|
352
|
+
offersTls13 ? encodeSupportedVersions(g ? [g.take(), ...versions] : versions) : null,
|
|
353
|
+
offersTls13
|
|
354
|
+
? encodeKeyShare([
|
|
355
|
+
...(g ? [greaseKeyShare(g.take())] : []),
|
|
356
|
+
...keyShares.map(({ group, keyExchange }) => ({ group, keyExchange })),
|
|
357
|
+
])
|
|
358
|
+
: null,
|
|
326
359
|
offersTls13 ? encodePskKeyExchangeModes() : null,
|
|
327
360
|
offersTls12 ? encodeExtendedMasterSecret() : null,
|
|
328
361
|
offersTls12 ? encodeEcPointFormats() : null,
|
|
@@ -339,13 +372,36 @@ export function buildClientHello({
|
|
|
339
372
|
if (part) offered.add((part[0] << 8) | part[1]);
|
|
340
373
|
}
|
|
341
374
|
|
|
375
|
+
const typeOf = (e) => (e[0] << 8) | e[1];
|
|
376
|
+
// Order (or shuffle) the real extensions FIRST, then bracket them with GREASE. Doing it the
|
|
377
|
+
// other way round put both GREASE extensions at the end under a fixed order, because neither
|
|
378
|
+
// reserved type appears in any order list — and it put the trailing one after pre_shared_key,
|
|
379
|
+
// which RFC 8446 s4.2.11 forbids.
|
|
380
|
+
const laidReal =
|
|
381
|
+
extensionOrder === SHUFFLE_EXTENSIONS
|
|
382
|
+
? shuffleExtensions(extensionParts.filter(Boolean), g ?? greaseSource(1), typeOf,
|
|
383
|
+
EXTENSION.pre_shared_key)
|
|
384
|
+
: orderExtensions(extensionParts, extensionOrder);
|
|
385
|
+
|
|
386
|
+
let laid = laidReal;
|
|
387
|
+
if (g) {
|
|
388
|
+
// Leading GREASE is empty, trailing carries a single zero byte — as captured from Chromium.
|
|
389
|
+
// The trailing one goes BEFORE any pre_shared_key, which must stay last of all.
|
|
390
|
+
const head = encodeRawExtension(g.take(), new Uint8Array(0));
|
|
391
|
+
const tail = encodeRawExtension(g.take(), Uint8Array.of(0));
|
|
392
|
+
const pskAt = laidReal.findIndex((e) => typeOf(e) === EXTENSION.pre_shared_key);
|
|
393
|
+
laid = pskAt === -1
|
|
394
|
+
? [head, ...laidReal, tail]
|
|
395
|
+
: [head, ...laidReal.slice(0, pskAt), tail, ...laidReal.slice(pskAt)];
|
|
396
|
+
}
|
|
397
|
+
|
|
342
398
|
const body = new Builder()
|
|
343
399
|
.u16(LEGACY_VERSION)
|
|
344
400
|
.push(clientRandom)
|
|
345
401
|
.vector(1, sessionId)
|
|
346
402
|
.vector(2, suiteBytes.build())
|
|
347
403
|
.vector(1, Uint8Array.from([0])) // legacy_compression_methods: null only
|
|
348
|
-
.push(encodeExtensionBlock(
|
|
404
|
+
.push(encodeExtensionBlock(laid))
|
|
349
405
|
.build();
|
|
350
406
|
|
|
351
407
|
const message = handshakeMessage(HANDSHAKE_TYPE.client_hello, body);
|
|
@@ -506,6 +562,19 @@ export function negotiateVersion(serverHello, { offeredVersions }) {
|
|
|
506
562
|
*/
|
|
507
563
|
export function negotiateCipher(serverHello, { offeredCiphers, version }) {
|
|
508
564
|
const suite = serverHello.cipherSuite;
|
|
565
|
+
// A GREASE value is reserved and MUST be ignored by a server (RFC 8701 s3). One that negotiates
|
|
566
|
+
// it is broken, and because we offered it the "was it offered" test below would let it through —
|
|
567
|
+
// so it is refused here, naming GREASE, rather than falling to the missing-parameters check
|
|
568
|
+
// whose message would blame this package's own offer list.
|
|
569
|
+
if (isGrease(suite)) {
|
|
570
|
+
throw new TlsUnsupportedError(
|
|
571
|
+
codes.TLS_CIPHER_UNSUPPORTED,
|
|
572
|
+
`server selected the GREASE cipher suite ${hex16(suite)}, which RFC 8701 reserves and ` +
|
|
573
|
+
'requires a server to ignore. It exists in the offer precisely to detect peers that do ' +
|
|
574
|
+
'not.',
|
|
575
|
+
{ cipherSuite: suite },
|
|
576
|
+
);
|
|
577
|
+
}
|
|
509
578
|
if (!offeredCiphers.includes(suite)) {
|
|
510
579
|
throw new TlsUnsupportedError(
|
|
511
580
|
codes.TLS_CIPHER_UNSUPPORTED,
|