tunnelfetch 1.0.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/LICENSE +28 -0
- package/README.md +617 -0
- package/README.zh-CN.md +470 -0
- package/package.json +74 -0
- package/src/client/cookies.js +429 -0
- package/src/client/decode.js +346 -0
- package/src/client/redirect.js +249 -0
- package/src/client.js +704 -0
- package/src/errors.js +181 -0
- package/src/http1/chunked.js +289 -0
- package/src/http1/index.js +10 -0
- package/src/http1/request.js +143 -0
- package/src/http1/response.js +493 -0
- package/src/http2/connection.js +1170 -0
- package/src/http2/constants.js +129 -0
- package/src/http2/frames.js +291 -0
- package/src/http2/hpack.js +420 -0
- package/src/http2/huffman.js +203 -0
- package/src/http2/index.js +21 -0
- package/src/index.js +46 -0
- package/src/pool.js +256 -0
- package/src/proxy/direct.js +62 -0
- package/src/proxy/http-connect.js +206 -0
- package/src/proxy/index.js +197 -0
- package/src/proxy/socks5.js +344 -0
- package/src/tls/aead.js +263 -0
- package/src/tls/connect.js +407 -0
- package/src/tls/constants.js +334 -0
- package/src/tls/extensions.js +376 -0
- package/src/tls/handshake-messages.js +901 -0
- package/src/tls/handshake.js +568 -0
- package/src/tls/handshake12.js +507 -0
- package/src/tls/index.js +44 -0
- package/src/tls/keyschedule.js +473 -0
- package/src/tls/record.js +872 -0
- package/src/tls/tickets.js +145 -0
- package/src/tls/transcript.js +101 -0
- package/src/tls/wire.js +224 -0
- package/src/transport.js +296 -0
- package/src/trust/der.js +551 -0
- package/src/trust/index.js +375 -0
- package/src/trust/name.js +235 -0
- package/src/trust/ocsp.js +759 -0
- package/src/trust/path.js +595 -0
- package/src/trust/roots.js +454 -0
- package/src/trust/x509.js +902 -0
- package/src/util/bytes.js +470 -0
- package/src/util/deadline.js +266 -0
- package/src/warmup-fixture.js +85 -0
- package/src/warmup.js +243 -0
- package/types/client/cookies.d.ts +159 -0
- package/types/client/decode.d.ts +54 -0
- package/types/client/redirect.d.ts +96 -0
- package/types/client.d.ts +323 -0
- package/types/errors.d.ts +141 -0
- package/types/http1/chunked.d.ts +48 -0
- package/types/http1/index.d.ts +3 -0
- package/types/http1/request.d.ts +44 -0
- package/types/http1/response.d.ts +183 -0
- package/types/http2/connection.d.ts +282 -0
- package/types/http2/constants.d.ts +95 -0
- package/types/http2/frames.d.ts +116 -0
- package/types/http2/hpack.d.ts +99 -0
- package/types/http2/huffman.d.ts +21 -0
- package/types/http2/index.d.ts +5 -0
- package/types/index.d.ts +17 -0
- package/types/pool.d.ts +135 -0
- package/types/proxy/direct.d.ts +26 -0
- package/types/proxy/http-connect.d.ts +37 -0
- package/types/proxy/index.d.ts +62 -0
- package/types/proxy/socks5.d.ts +47 -0
- package/types/tls/aead.d.ts +67 -0
- package/types/tls/connect.d.ts +280 -0
- package/types/tls/constants.d.ts +275 -0
- package/types/tls/extensions.d.ts +195 -0
- package/types/tls/handshake-messages.d.ts +430 -0
- package/types/tls/handshake.d.ts +90 -0
- package/types/tls/handshake12.d.ts +35 -0
- package/types/tls/index.d.ts +9 -0
- package/types/tls/keyschedule.d.ts +272 -0
- package/types/tls/record.d.ts +361 -0
- package/types/tls/tickets.d.ts +66 -0
- package/types/tls/transcript.d.ts +52 -0
- package/types/tls/wire.d.ts +106 -0
- package/types/transport.d.ts +222 -0
- package/types/trust/der.d.ts +239 -0
- package/types/trust/index.d.ts +194 -0
- package/types/trust/name.d.ts +33 -0
- package/types/trust/ocsp.d.ts +138 -0
- package/types/trust/path.d.ts +139 -0
- package/types/trust/roots.d.ts +36 -0
- package/types/trust/x509.d.ts +401 -0
- package/types/util/bytes.d.ts +183 -0
- package/types/util/deadline.d.ts +133 -0
- package/types/warmup-fixture.d.ts +11 -0
- package/types/warmup.d.ts +45 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
// TLS 1.3 client handshake driver.
|
|
2
|
+
//
|
|
3
|
+
// This is the file that has to be right. On this runtime the platform will not verify a tunnelled
|
|
4
|
+
// peer for us — it checks the certificate against whatever hostname was passed to connect(), which
|
|
5
|
+
// inside a proxy tunnel is the proxy, so the platform's own answer is structurally the wrong
|
|
6
|
+
// question. Everything protecting the caller from a hostile proxy therefore happens here and in
|
|
7
|
+
// the trust layer, and nothing may reach the application before it has.
|
|
8
|
+
//
|
|
9
|
+
// Three ordering rules are load-bearing:
|
|
10
|
+
//
|
|
11
|
+
// 1. The certificate chain is validated, and CertificateVerify checked against the very key that
|
|
12
|
+
// validation blessed, BEFORE the client's Finished is sent and before any application byte
|
|
13
|
+
// moves in either direction. No code path returns a usable duplex otherwise.
|
|
14
|
+
// 2. Every message is folded into the transcript at the exact point RFC 8446 says. The
|
|
15
|
+
// transcript is what binds the certificate to this connection; an off-by-one-message
|
|
16
|
+
// transcript still looks like a working handshake against an honest server and provides no
|
|
17
|
+
// security at all against a dishonest one.
|
|
18
|
+
// 3. The transcript's hash is chosen by the cipher suite, which is not known until ServerHello.
|
|
19
|
+
// So ClientHello is held as raw bytes and the transcript is constructed once, with the right
|
|
20
|
+
// hash, rather than started under a guess and rebuilt — rebuilding cannot reproduce the
|
|
21
|
+
// HelloRetryRequest substitution, and a transcript that is subtly wrong only after an HRR is
|
|
22
|
+
// the kind of bug that survives every test against a server that never sends one.
|
|
23
|
+
//
|
|
24
|
+
// The ClientHello/ServerHello preamble that rules 3 rests on lives in connect.js, shared with the
|
|
25
|
+
// 1.2 driver so that one hello can offer both versions; continueTls13 below is everything that
|
|
26
|
+
// happens after the ServerHello routed the connection here. handshakeTls13 remains the
|
|
27
|
+
// single-version entry: it is connectTls with the offer pinned to [TLS 1.3].
|
|
28
|
+
//
|
|
29
|
+
// Verification is injected rather than imported so this module has no opinion about trust policy,
|
|
30
|
+
// and so the handshake can be exercised offline against a scripted peer.
|
|
31
|
+
|
|
32
|
+
import { TlsError, TlsUnsupportedError, codes, hex8, hex16 } from '../errors.js';
|
|
33
|
+
import { EXTENSION, HANDSHAKE_TYPE, SUPPORTED_GROUPS, TLS13 } from './constants.js';
|
|
34
|
+
import {
|
|
35
|
+
applicationTrafficSecrets,
|
|
36
|
+
deriveHandshakeSecret,
|
|
37
|
+
deriveMasterSecret,
|
|
38
|
+
earlySecret,
|
|
39
|
+
finishedVerifyData,
|
|
40
|
+
handshakeTrafficSecrets,
|
|
41
|
+
resumptionMasterSecret,
|
|
42
|
+
resumptionPsk,
|
|
43
|
+
} from './keyschedule.js';
|
|
44
|
+
import {
|
|
45
|
+
buildClientHello,
|
|
46
|
+
certificateVerifyContent,
|
|
47
|
+
checkAlpn,
|
|
48
|
+
checkFinished,
|
|
49
|
+
checkSessionIdEcho,
|
|
50
|
+
deriveSharedSecret,
|
|
51
|
+
generateKeyShare,
|
|
52
|
+
negotiateCipher,
|
|
53
|
+
negotiateVersion,
|
|
54
|
+
parseCertificate13,
|
|
55
|
+
parseCertificateVerify,
|
|
56
|
+
parseHelloRetryRequest,
|
|
57
|
+
parseNewSessionTicket,
|
|
58
|
+
parseServerHello,
|
|
59
|
+
selectServerKeyShare,
|
|
60
|
+
setPskBinder,
|
|
61
|
+
verifyHandshakeSignature,
|
|
62
|
+
} from './handshake-messages.js';
|
|
63
|
+
import {
|
|
64
|
+
decodeExtensionBlock,
|
|
65
|
+
decodeServerPreSharedKey,
|
|
66
|
+
describeVersion,
|
|
67
|
+
rejectUnofferedExtensions,
|
|
68
|
+
} from './extensions.js';
|
|
69
|
+
import { Builder, Cursor, handshakeMessage } from './wire.js';
|
|
70
|
+
import { connectTls } from './connect.js';
|
|
71
|
+
|
|
72
|
+
const HS_NAME = Object.fromEntries(Object.entries(HANDSHAKE_TYPE).map(([k, v]) => [v, k]));
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Only one key share is offered by default. A second costs a key generation and 30-odd bytes for
|
|
76
|
+
* a group the server is unlikely to prefer; a HelloRetryRequest recovers the rare case at the
|
|
77
|
+
* cost of one round trip.
|
|
78
|
+
*/
|
|
79
|
+
export const DEFAULT_OFFER_GROUPS = [SUPPORTED_GROUPS[0]];
|
|
80
|
+
|
|
81
|
+
const describeType = (t) => `${hex8(t)} (${HS_NAME[t] ?? 'unknown'})`;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The injected trust decision. Must throw to reject; resolves with the validated leaf, whose
|
|
85
|
+
* SPKI is the only key a driver will accept a handshake signature from. `details.ocspResponse`
|
|
86
|
+
* is the peer's stapled DER OCSPResponse when it sent one — delivered here, at the same moment
|
|
87
|
+
* as the chain, because revocation is part of deciding whether to believe the certificate and
|
|
88
|
+
* must be settled before anything of ours goes on the wire.
|
|
89
|
+
* @typedef {(chain: Uint8Array[], hostname: string,
|
|
90
|
+
* details?: { ocspResponse: Uint8Array | null })
|
|
91
|
+
* => Promise<{ spki: { spkiDer: Uint8Array } }>} VerifyPeer
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Driver context assembled by connect.js after the ServerHello routed the connection: the
|
|
96
|
+
* record layer, the transcript (created under the negotiated suite's hash, ClientHello already
|
|
97
|
+
* folded in), the ClientHello metadata, the parsed ServerHello with its raw bytes, and the
|
|
98
|
+
* offer that produced them. Both continue* drivers consume exactly this shape.
|
|
99
|
+
* @typedef {object} HandshakeContext
|
|
100
|
+
* @property {import('./record.js').RecordLayer} record
|
|
101
|
+
* @property {import('./transcript.js').Transcript} transcript
|
|
102
|
+
* @property {import('./handshake-messages.js').ClientHello} hello
|
|
103
|
+
* @property {import('./handshake-messages.js').ServerHello} serverHello
|
|
104
|
+
* @property {Uint8Array} rawServerHello
|
|
105
|
+
* @property {number} suite
|
|
106
|
+
* @property {import('./constants.js').CipherParams} params
|
|
107
|
+
* @property {string} hostname
|
|
108
|
+
* @property {VerifyPeer} verifyPeer
|
|
109
|
+
* @property {import('./connect.js').TlsOptions} options
|
|
110
|
+
* @property {import('./connect.js').TlsDeps} deps
|
|
111
|
+
* @property {{ versions: number[], ciphers: number[], groups: number[], offerGroups: number[],
|
|
112
|
+
* alpn: string[], keyShares: import('./handshake-messages.js').KeyShare[],
|
|
113
|
+
* psk: OfferedPsk | null }} offer
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The resumption PSK as prepared by connect.js: the caller's offer plus the secrets derived
|
|
118
|
+
* from it once (Early Secret, binder key) so neither hello build nor acceptance re-derives.
|
|
119
|
+
* @typedef {object} OfferedPsk
|
|
120
|
+
* @property {Uint8Array} identity
|
|
121
|
+
* @property {Uint8Array} psk
|
|
122
|
+
* @property {import('./keyschedule.js').ScheduleHash} hash
|
|
123
|
+
* @property {() => number} obfuscatedTicketAge
|
|
124
|
+
* @property {object | null} peer
|
|
125
|
+
* @property {Uint8Array} earlySecret
|
|
126
|
+
* @property {Uint8Array} binderKey
|
|
127
|
+
* @property {number} binderLen
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
/** Demand a specific handshake message, turning every other outcome into a named failure. */
|
|
131
|
+
function expect(msg, type, where) {
|
|
132
|
+
if (msg === null) {
|
|
133
|
+
throw new TlsError(
|
|
134
|
+
codes.TLS_TRUNCATED,
|
|
135
|
+
`server closed the connection during the handshake while ${where} was expected`,
|
|
136
|
+
{ expected: HS_NAME[type] ?? type },
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (msg.ccs) {
|
|
140
|
+
throw new TlsError(codes.TLS_RECORD, `change_cipher_spec arrived where ${where} was expected`, {
|
|
141
|
+
expected: HS_NAME[type] ?? type,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (msg.type !== type) {
|
|
145
|
+
throw new TlsError(
|
|
146
|
+
codes.TLS_HANDSHAKE,
|
|
147
|
+
`server sent handshake type ${describeType(msg.type)} where ${where} was expected`,
|
|
148
|
+
{ got: msg.type, expected: type },
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return msg;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Run a TLS 1.3 handshake over a byte duplex and return the plaintext duplex above it.
|
|
156
|
+
*
|
|
157
|
+
* @param {object} args
|
|
158
|
+
* @param {import('./connect.js').ByteDuplex} args.transport
|
|
159
|
+
* @param {string} args.hostname the identity the certificate must prove, and the SNI sent
|
|
160
|
+
* @param {VerifyPeer} args.verifyPeer
|
|
161
|
+
* Must throw to reject. Resolves with the validated leaf; its SPKI is the only key this
|
|
162
|
+
* handshake will accept a CertificateVerify signature from.
|
|
163
|
+
* @param {import('./connect.js').TlsOptions} [args.options] `versions` is ignored: this entry
|
|
164
|
+
* pins the offer to [TLS 1.3]
|
|
165
|
+
* @param {import('./connect.js').TlsDeps} [args.deps]
|
|
166
|
+
* @returns {Promise<import('./connect.js').TlsSession>}
|
|
167
|
+
*/
|
|
168
|
+
export async function handshakeTls13({ transport, hostname, verifyPeer, options = {}, deps = {} }) {
|
|
169
|
+
if (typeof verifyPeer !== 'function') {
|
|
170
|
+
// Refusing to start is the only safe default. A missing verifier must never read as "skip".
|
|
171
|
+
throw new TlsError(
|
|
172
|
+
codes.CONFIG_INVALID,
|
|
173
|
+
'handshakeTls13 requires a verifyPeer function; there is no unverified mode',
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
// The offer is pinned to [TLS 1.3] no matter what options.versions says: callers of THIS
|
|
177
|
+
// function chose the version by choosing the function, and a widened offer sneaking in through
|
|
178
|
+
// options would silently change which downgrade guards apply.
|
|
179
|
+
return connectTls({
|
|
180
|
+
transport,
|
|
181
|
+
hostname,
|
|
182
|
+
verifyPeer,
|
|
183
|
+
deps,
|
|
184
|
+
options: { ...options, versions: [TLS13] },
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Continue a TLS 1.3 handshake from the first ServerHello (which may be a HelloRetryRequest).
|
|
190
|
+
* Called by connect.js once negotiation routed the connection here.
|
|
191
|
+
* @param {HandshakeContext} ctx
|
|
192
|
+
* @returns {Promise<import('./connect.js').TlsSession>}
|
|
193
|
+
*/
|
|
194
|
+
export async function continueTls13(ctx) {
|
|
195
|
+
const { record, transcript, hostname, verifyPeer, options, deps } = ctx;
|
|
196
|
+
const { versions, ciphers, groups, offerGroups, alpn } = ctx.offer;
|
|
197
|
+
let { hello, serverHello: sh, rawServerHello, suite, params } = ctx;
|
|
198
|
+
let { keyShares } = ctx.offer;
|
|
199
|
+
let hash = params.hash;
|
|
200
|
+
// The PSK the CURRENT hello carries. Starts as what connect.js offered in ClientHello1 and
|
|
201
|
+
// can only narrow: a HelloRetryRequest that pins a suite of a different hash removes it from
|
|
202
|
+
// ClientHello2 (s4.1.4), and every acceptance check below runs against this variable — never
|
|
203
|
+
// against the original offer — so a server cannot select what the live hello does not carry.
|
|
204
|
+
let offeredPsk = ctx.offer.psk ?? null;
|
|
205
|
+
|
|
206
|
+
// --- HelloRetryRequest ---------------------------------------------------------------------
|
|
207
|
+
if (sh.isHelloRetryRequest) {
|
|
208
|
+
const { group, cookie } = parseHelloRetryRequest(sh, { offeredGroups: groups });
|
|
209
|
+
if (offerGroups.includes(group)) {
|
|
210
|
+
throw new TlsError(
|
|
211
|
+
codes.TLS_HANDSHAKE,
|
|
212
|
+
`HelloRetryRequest demanded group ${hex16(group)}, for which a key share was already ` +
|
|
213
|
+
'sent; the server is looping',
|
|
214
|
+
{ group },
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
checkSessionIdEcho(sh, hello.legacySessionId);
|
|
218
|
+
|
|
219
|
+
await transcript.replaceWithMessageHash();
|
|
220
|
+
transcript.update(rawServerHello);
|
|
221
|
+
|
|
222
|
+
// s4.1.4: the second hello SHOULD NOT offer a PSK whose hash differs from the suite the
|
|
223
|
+
// HelloRetryRequest just pinned — the server could never legally select it, and computing
|
|
224
|
+
// its binder would need a second transcript under the other hash. Dropping it here is what
|
|
225
|
+
// makes a later pre_shared_key in the real ServerHello provably a violation.
|
|
226
|
+
if (offeredPsk && offeredPsk.hash !== params.hash) offeredPsk = null;
|
|
227
|
+
|
|
228
|
+
keyShares = [await generateKeyShare(group, deps)];
|
|
229
|
+
// RFC 8446 s4.1.2: ClientHello2 reuses the random and the legacy session id verbatim — and
|
|
230
|
+
// everything else about the offer, including the full VERSION list. If both versions were
|
|
231
|
+
// offered, ClientHello2 offers both again; changing the extension set between hellos is not
|
|
232
|
+
// among the modifications s4.1.2 permits (recomputing obfuscated_ticket_age and the binder
|
|
233
|
+
// IS: s4.1.2 lists "pre_shared_key" as one of the fields a second hello updates), and a
|
|
234
|
+
// strict server checks.
|
|
235
|
+
hello = buildClientHello({
|
|
236
|
+
hostname,
|
|
237
|
+
keyShares,
|
|
238
|
+
groups,
|
|
239
|
+
alpn,
|
|
240
|
+
ciphers,
|
|
241
|
+
versions,
|
|
242
|
+
random: hello.clientRandom,
|
|
243
|
+
legacySessionId: hello.legacySessionId,
|
|
244
|
+
extraExtensions: cookie ? [cookieExtension(cookie)] : [],
|
|
245
|
+
psk: offeredPsk && {
|
|
246
|
+
identity: offeredPsk.identity,
|
|
247
|
+
obfuscatedTicketAge: offeredPsk.obfuscatedTicketAge(),
|
|
248
|
+
binderLen: offeredPsk.binderLen,
|
|
249
|
+
},
|
|
250
|
+
randomBytes: deps.randomBytes,
|
|
251
|
+
});
|
|
252
|
+
if (offeredPsk) {
|
|
253
|
+
// The ClientHello2 binder covers Transcript-Hash(message_hash(CH1) || HRR ||
|
|
254
|
+
// Truncate(CH2)) (s4.2.11.2). The transcript object holds exactly the first two at this
|
|
255
|
+
// point, and hashWith appends the truncation without ever folding it in — the transcript
|
|
256
|
+
// proper receives the full patched hello below. The suite's hash and the PSK's hash are
|
|
257
|
+
// equal here by the drop above, so one digest serves both bookkeepings.
|
|
258
|
+
const truncatedHash = await transcript.hashWith(
|
|
259
|
+
hello.message.subarray(0, hello.truncatedLength));
|
|
260
|
+
setPskBinder(hello,
|
|
261
|
+
await finishedVerifyData(offeredPsk.hash, offeredPsk.binderKey, truncatedHash));
|
|
262
|
+
}
|
|
263
|
+
transcript.update(hello.message);
|
|
264
|
+
await record.writeHandshake([hello.message]);
|
|
265
|
+
|
|
266
|
+
const second = expect(
|
|
267
|
+
await record.nextHandshakeMessage(),
|
|
268
|
+
HANDSHAKE_TYPE.server_hello,
|
|
269
|
+
'ServerHello',
|
|
270
|
+
);
|
|
271
|
+
rawServerHello = second.raw;
|
|
272
|
+
sh = parseServerHello(second.body);
|
|
273
|
+
if (sh.isHelloRetryRequest) {
|
|
274
|
+
throw new TlsError(codes.TLS_HANDSHAKE, 'server sent a second HelloRetryRequest');
|
|
275
|
+
}
|
|
276
|
+
// RFC 8446 s4.1.4: the real ServerHello must keep the suite the HelloRetryRequest named,
|
|
277
|
+
// otherwise the transcript hash we already committed to would be the wrong one.
|
|
278
|
+
const again = negotiateCipher(sh, { offeredCiphers: ciphers, version: TLS13 });
|
|
279
|
+
if (again.suite !== suite) {
|
|
280
|
+
throw new TlsError(
|
|
281
|
+
codes.TLS_CIPHER_UNSUPPORTED,
|
|
282
|
+
`ServerHello selected ${hex16(again.suite)} after HelloRetryRequest selected ${hex16(suite)}`,
|
|
283
|
+
{ hrr: suite, serverHello: again.suite },
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
({ suite, params } = again);
|
|
287
|
+
hash = params.hash;
|
|
288
|
+
|
|
289
|
+
// The HelloRetryRequest pinned this connection to 1.3, but the real ServerHello must still
|
|
290
|
+
// say so itself. negotiateVersion runs with the full offered list so the downgrade guards
|
|
291
|
+
// stay live; when 1.2 was also offered it can legitimately RETURN TLS 1.2 for a hello with
|
|
292
|
+
// no supported_versions, and that answer — a version change across the retry — is a splice.
|
|
293
|
+
const version = negotiateVersion(sh, { offeredVersions: versions });
|
|
294
|
+
if (version !== TLS13) {
|
|
295
|
+
throw new TlsUnsupportedError(
|
|
296
|
+
codes.TLS_VERSION_UNSUPPORTED,
|
|
297
|
+
`server negotiated ${describeVersion(version)} in the ServerHello after its ` +
|
|
298
|
+
'HelloRetryRequest; a HelloRetryRequest exists only in TLS 1.3, so the connection ' +
|
|
299
|
+
'cannot continue at any other version',
|
|
300
|
+
{ version },
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// --- negotiation ---------------------------------------------------------------------------
|
|
306
|
+
// The non-HRR ServerHello already went through negotiateVersion in connect.js with the full
|
|
307
|
+
// offered list — that is what routed the connection here.
|
|
308
|
+
checkSessionIdEcho(sh, hello.legacySessionId);
|
|
309
|
+
transcript.update(rawServerHello);
|
|
310
|
+
|
|
311
|
+
// Did the server take the PSK? Every check is against the CURRENT hello's offer (s4.2.11:
|
|
312
|
+
// "Clients MUST verify that the server's selected_identity is within the range supplied by
|
|
313
|
+
// the client [and] that the server selected a cipher suite indicating a Hash associated with
|
|
314
|
+
// the PSK"). Fail closed on each: continuing after any of these means client and server
|
|
315
|
+
// disagree about which secret protects the connection.
|
|
316
|
+
let acceptedPsk = null;
|
|
317
|
+
const pskExt = sh.extensions.get(EXTENSION.pre_shared_key);
|
|
318
|
+
if (pskExt !== undefined) {
|
|
319
|
+
if (!offeredPsk) {
|
|
320
|
+
throw new TlsError(
|
|
321
|
+
codes.TLS_PSK,
|
|
322
|
+
'ServerHello selected a pre-shared key, but the ClientHello it answers offered none',
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
const selected = decodeServerPreSharedKey(pskExt);
|
|
326
|
+
if (selected !== 0) {
|
|
327
|
+
// Exactly one identity is ever offered, so the only selectable index is 0.
|
|
328
|
+
throw new TlsError(
|
|
329
|
+
codes.TLS_PSK,
|
|
330
|
+
`ServerHello selected pre-shared key identity ${selected}, but only one identity ` +
|
|
331
|
+
'(index 0) was offered',
|
|
332
|
+
{ selected },
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
if (params.hash !== offeredPsk.hash) {
|
|
336
|
+
throw new TlsError(
|
|
337
|
+
codes.TLS_PSK,
|
|
338
|
+
`ServerHello accepted the offered PSK but selected cipher suite ${hex16(suite)} ` +
|
|
339
|
+
`(${params.hash}), while the PSK was minted under ${offeredPsk.hash}; a PSK may only ` +
|
|
340
|
+
'be used with the hash it was derived for (RFC 8446 s4.2.11)',
|
|
341
|
+
{ suite, suiteHash: params.hash, pskHash: offeredPsk.hash },
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
acceptedPsk = offeredPsk;
|
|
345
|
+
}
|
|
346
|
+
// No pre_shared_key in the ServerHello means the server declined: the FULL handshake — with
|
|
347
|
+
// Certificate, CertificateVerify, and chain validation — continues on this same connection.
|
|
348
|
+
// No reconnect, no re-offer, no downgrade dance; declining costs nothing but the bytes.
|
|
349
|
+
|
|
350
|
+
// Only psk_dhe_ke is ever offered (s4.2.9), so a key exchange happens whether or not the PSK
|
|
351
|
+
// was taken; selectServerKeyShare fails closed on a ServerHello without key_share.
|
|
352
|
+
const server = selectServerKeyShare(sh, keyShares);
|
|
353
|
+
const shared = await deriveSharedSecret(server.group, server.privateKey, server.keyExchange);
|
|
354
|
+
|
|
355
|
+
// s7.1: with a PSK in use the Early Secret is extracted from it; otherwise from zeros. The
|
|
356
|
+
// accepted offer already carries that extraction (connect.js derived it for the binder), and
|
|
357
|
+
// its hash equals the negotiated hash by the acceptance check above.
|
|
358
|
+
const early = acceptedPsk ? acceptedPsk.earlySecret : await earlySecret(hash);
|
|
359
|
+
const handshakeSecret = await deriveHandshakeSecret(hash, early, shared);
|
|
360
|
+
const hsSecrets = await handshakeTrafficSecrets(hash, handshakeSecret, await transcript.hash());
|
|
361
|
+
|
|
362
|
+
// From here the server speaks encrypted. Our send direction stays plaintext until the client
|
|
363
|
+
// Finished, which is why the send key is installed later rather than here.
|
|
364
|
+
await record.setReceiveKeys({ cipher: suite, secret: hsSecrets.server });
|
|
365
|
+
|
|
366
|
+
// --- server flight -------------------------------------------------------------------------
|
|
367
|
+
const ee = expect(
|
|
368
|
+
await record.nextHandshakeMessage(),
|
|
369
|
+
HANDSHAKE_TYPE.encrypted_extensions,
|
|
370
|
+
'EncryptedExtensions',
|
|
371
|
+
);
|
|
372
|
+
transcript.update(ee.raw);
|
|
373
|
+
const eeCursor = new Cursor(ee.body, 'EncryptedExtensions');
|
|
374
|
+
const eeExts = decodeExtensionBlock(eeCursor.vector(2, 'extensions'), 'EncryptedExtensions');
|
|
375
|
+
eeCursor.end('EncryptedExtensions');
|
|
376
|
+
rejectUnofferedExtensions(eeExts, hello.offeredExtensions, 'EncryptedExtensions');
|
|
377
|
+
if (eeExts.has(EXTENSION.status_request)) {
|
|
378
|
+
// We DID offer status_request, so the unoffered-extension check above cannot catch this —
|
|
379
|
+
// but RFC 8446 s4.2 places the server's answer in the leaf's CertificateEntry, never in
|
|
380
|
+
// EncryptedExtensions, and an extension in a message it is not specified for is a fatal
|
|
381
|
+
// illegal_parameter, not a tolerable relocation. Accepting a staple from here would also
|
|
382
|
+
// move it outside the certificate it is supposed to be bound to.
|
|
383
|
+
throw new TlsError(
|
|
384
|
+
codes.TLS_HANDSHAKE,
|
|
385
|
+
'server sent status_request in EncryptedExtensions; in TLS 1.3 a stapled certificate ' +
|
|
386
|
+
"status belongs in the leaf's CertificateEntry (RFC 8446 s4.4.2.1)",
|
|
387
|
+
{ extension: EXTENSION.status_request },
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
const alpnProtocol = checkAlpn(eeExts, alpn, 'EncryptedExtensions');
|
|
391
|
+
|
|
392
|
+
let next = await record.nextHandshakeMessage();
|
|
393
|
+
let certificateRequested = false;
|
|
394
|
+
let peer;
|
|
395
|
+
if (acceptedPsk) {
|
|
396
|
+
// Resumed: the server authenticates by proving it holds the PSK — its Finished MAC, under
|
|
397
|
+
// keys extracted from that PSK, is the proof — and s4.3.2 forbids it from sending
|
|
398
|
+
// CertificateRequest (and s2.2 from re-authenticating with a certificate) in this handshake.
|
|
399
|
+
// A server that sends either after selecting the PSK is not confused about framing, it is
|
|
400
|
+
// violating the PSK contract, so the error names that rather than a generic wrong-type.
|
|
401
|
+
if (next && !next.ccs &&
|
|
402
|
+
(next.type === HANDSHAKE_TYPE.certificate ||
|
|
403
|
+
next.type === HANDSHAKE_TYPE.certificate_request)) {
|
|
404
|
+
throw new TlsError(
|
|
405
|
+
codes.TLS_PSK,
|
|
406
|
+
`server resumed with the offered pre-shared key but then sent ${describeType(next.type)}; ` +
|
|
407
|
+
'a PSK handshake must not carry certificate messages (RFC 8446 s2.2, s4.3.2)',
|
|
408
|
+
{ type: next.type },
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
// The certificate validated in the ORIGINAL handshake vouches for this connection, exactly
|
|
412
|
+
// as far as the ticket store's keying lets it: the store binds tickets to the full trust
|
|
413
|
+
// configuration, so this peer was validated under the same policy this caller asked for.
|
|
414
|
+
peer = acceptedPsk.peer;
|
|
415
|
+
} else {
|
|
416
|
+
if (next && !next.ccs && next.type === HANDSHAKE_TYPE.certificate_request) {
|
|
417
|
+
// We hold no client certificate, but the protocol still demands an answer: an empty
|
|
418
|
+
// Certificate message and no CertificateVerify (RFC 8446 s4.4.2).
|
|
419
|
+
certificateRequested = true;
|
|
420
|
+
transcript.update(next.raw);
|
|
421
|
+
next = await record.nextHandshakeMessage();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const certMsg = expect(next, HANDSHAKE_TYPE.certificate, 'Certificate');
|
|
425
|
+
transcript.update(certMsg.raw);
|
|
426
|
+
// The leaf's CertificateEntry may carry a stapled OCSP response (RFC 8446 s4.4.2.1); it rides
|
|
427
|
+
// to the trust layer alongside the chain and is judged there, under the same fail-closed rules.
|
|
428
|
+
const { chain, ocspResponse } = parseCertificate13(certMsg.body, {
|
|
429
|
+
offeredExtensions: hello.offeredExtensions,
|
|
430
|
+
});
|
|
431
|
+
const transcriptThroughCertificate = await transcript.hash();
|
|
432
|
+
|
|
433
|
+
const cv = expect(
|
|
434
|
+
await record.nextHandshakeMessage(),
|
|
435
|
+
HANDSHAKE_TYPE.certificate_verify,
|
|
436
|
+
'CertificateVerify',
|
|
437
|
+
);
|
|
438
|
+
const { algorithm, signature } = parseCertificateVerify(cv.body);
|
|
439
|
+
transcript.update(cv.raw);
|
|
440
|
+
|
|
441
|
+
// Trust first. The signature check below is only meaningful once the key performing it has been
|
|
442
|
+
// tied by the trust layer to a chain we accept for this hostname; done the other way round it
|
|
443
|
+
// merely proves that whoever holds the socket also holds a key, which is no evidence at all.
|
|
444
|
+
peer = await verifyPeer(chain, hostname, { ocspResponse });
|
|
445
|
+
const spki = peer?.spki?.spkiDer;
|
|
446
|
+
if (!spki) {
|
|
447
|
+
throw new TlsError(
|
|
448
|
+
codes.CONFIG_INVALID,
|
|
449
|
+
'verifyPeer must resolve with the validated leaf certificate, including spki.spkiDer',
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
await verifyHandshakeSignature({
|
|
453
|
+
scheme: algorithm,
|
|
454
|
+
spki,
|
|
455
|
+
signature,
|
|
456
|
+
content: certificateVerifyContent(transcriptThroughCertificate, true),
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
next = await record.nextHandshakeMessage();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const sf = expect(next, HANDSHAKE_TYPE.finished, 'server Finished');
|
|
463
|
+
const expectedFinished = await finishedVerifyData(hash, hsSecrets.server, await transcript.hash());
|
|
464
|
+
checkFinished(sf.body, expectedFinished);
|
|
465
|
+
transcript.update(sf.raw);
|
|
466
|
+
|
|
467
|
+
// Application secrets are bound to the transcript through the server Finished (RFC 8446 s7.1),
|
|
468
|
+
// so they are derived here, before anything the client sends is folded in.
|
|
469
|
+
const masterSecret = await deriveMasterSecret(hash, handshakeSecret);
|
|
470
|
+
// No exporter interface is exposed, so exporter_master_secret would be dead work every handshake.
|
|
471
|
+
const appSecrets = await applicationTrafficSecrets(hash, masterSecret, await transcript.hash(),
|
|
472
|
+
{ exporter: false });
|
|
473
|
+
|
|
474
|
+
// --- client flight -------------------------------------------------------------------------
|
|
475
|
+
if (options.compatibilityCcs !== false) await record.writeChangeCipherSpec();
|
|
476
|
+
await record.setSendKeys({ cipher: suite, secret: hsSecrets.client });
|
|
477
|
+
|
|
478
|
+
const clientFlight = [];
|
|
479
|
+
if (certificateRequested) {
|
|
480
|
+
const empty = handshakeMessage(
|
|
481
|
+
HANDSHAKE_TYPE.certificate,
|
|
482
|
+
new Builder().vector(1, new Uint8Array(0)).vector(3, new Uint8Array(0)).build(),
|
|
483
|
+
);
|
|
484
|
+
clientFlight.push(empty);
|
|
485
|
+
transcript.update(empty);
|
|
486
|
+
}
|
|
487
|
+
const clientFinished = handshakeMessage(
|
|
488
|
+
HANDSHAKE_TYPE.finished,
|
|
489
|
+
await finishedVerifyData(hash, hsSecrets.client, await transcript.hash()),
|
|
490
|
+
);
|
|
491
|
+
clientFlight.push(clientFinished);
|
|
492
|
+
// Our own Finished joins the transcript too: resumption_master_secret is derived from the
|
|
493
|
+
// transcript THROUGH the client Finished (s7.1), so without this line every PSK minted from a
|
|
494
|
+
// ticket would be wrong — and wrong in a way a loopback test with the same omission on both
|
|
495
|
+
// sides would never notice.
|
|
496
|
+
transcript.update(clientFinished);
|
|
497
|
+
await record.writeHandshake(clientFlight);
|
|
498
|
+
|
|
499
|
+
await record.setSendKeys({ cipher: suite, secret: appSecrets.client });
|
|
500
|
+
await record.setReceiveKeys({ cipher: suite, secret: appSecrets.server });
|
|
501
|
+
record.markHandshakeComplete();
|
|
502
|
+
|
|
503
|
+
if (options.onSessionTicket) {
|
|
504
|
+
// NewSessionTicket arrives under application keys at the server's leisure; the record layer
|
|
505
|
+
// surfaces it here. Everything is derived lazily on the first ticket so a connection whose
|
|
506
|
+
// server never sends one pays nothing, and resumption_master_secret is computed once then
|
|
507
|
+
// reused — one server flight routinely carries two tickets. Parse or derivation failures
|
|
508
|
+
// propagate out of the read path and fail the connection: a peer whose post-handshake
|
|
509
|
+
// messages are malformed does not get to keep talking (same stance as KeyUpdate).
|
|
510
|
+
//
|
|
511
|
+
// 0-RTT, stated as a decision and not an omission: a ticket may advertise early_data, and
|
|
512
|
+
// this client records but never uses it. Early data is replayable by design — an attacker
|
|
513
|
+
// who captures the flight can replay it, and the server may accept both copies — which is
|
|
514
|
+
// unsafe for exactly the requests a proxy client carries (a caller's POST must not be
|
|
515
|
+
// executable twice by a third party). No option enables it.
|
|
516
|
+
let resMaster = null;
|
|
517
|
+
record.setPostHandshake(async ({ body }) => {
|
|
518
|
+
const t = parseNewSessionTicket(body);
|
|
519
|
+
resMaster ??= await resumptionMasterSecret(hash, masterSecret, await transcript.hash());
|
|
520
|
+
options.onSessionTicket({
|
|
521
|
+
identity: t.ticket,
|
|
522
|
+
psk: await resumptionPsk(hash, resMaster, t.nonce),
|
|
523
|
+
hash,
|
|
524
|
+
cipherSuite: suite,
|
|
525
|
+
lifetimeSec: t.lifetimeSec,
|
|
526
|
+
ageAdd: t.ageAdd,
|
|
527
|
+
maxEarlyDataSize: t.maxEarlyDataSize,
|
|
528
|
+
alpnProtocol: alpnProtocol ?? null,
|
|
529
|
+
peer,
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// The duplex is created on first access, not eagerly: consumers that drive the record layer
|
|
535
|
+
// directly (record.readAppData/writeAppData) never need the platform-stream wrappers, and the
|
|
536
|
+
// runtime this package targets forbids even CONSTRUCTING platform streams in global scope —
|
|
537
|
+
// where the opt-in warmup replay runs. Memoized, so every consumer sees one stable pair.
|
|
538
|
+
let duplex = null;
|
|
539
|
+
const lazyDuplex = () => (duplex ??= record.plaintextDuplex());
|
|
540
|
+
return {
|
|
541
|
+
get readable() {
|
|
542
|
+
return lazyDuplex().readable;
|
|
543
|
+
},
|
|
544
|
+
get writable() {
|
|
545
|
+
return lazyDuplex().writable;
|
|
546
|
+
},
|
|
547
|
+
record,
|
|
548
|
+
peer,
|
|
549
|
+
info: {
|
|
550
|
+
version: TLS13,
|
|
551
|
+
cipherSuite: suite,
|
|
552
|
+
group: server.group,
|
|
553
|
+
alpnProtocol: alpnProtocol ?? null,
|
|
554
|
+
certificateRequested,
|
|
555
|
+
hostname,
|
|
556
|
+
// True only when the server took the offered PSK. A declined offer reports false: the
|
|
557
|
+
// connection ran the full handshake and was authenticated by certificate, and a caller
|
|
558
|
+
// reading this field is usually asking "was the chain re-validated on this connection".
|
|
559
|
+
resumed: acceptedPsk !== null,
|
|
560
|
+
},
|
|
561
|
+
close: () => record.close(),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** A HelloRetryRequest cookie must be echoed verbatim in ClientHello2 (RFC 8446 s4.2.2). */
|
|
566
|
+
function cookieExtension(cookie) {
|
|
567
|
+
return new Builder().u16(EXTENSION.cookie).vector(2, cookie).build();
|
|
568
|
+
}
|