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.
Files changed (96) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +617 -0
  3. package/README.zh-CN.md +470 -0
  4. package/package.json +74 -0
  5. package/src/client/cookies.js +429 -0
  6. package/src/client/decode.js +346 -0
  7. package/src/client/redirect.js +249 -0
  8. package/src/client.js +704 -0
  9. package/src/errors.js +181 -0
  10. package/src/http1/chunked.js +289 -0
  11. package/src/http1/index.js +10 -0
  12. package/src/http1/request.js +143 -0
  13. package/src/http1/response.js +493 -0
  14. package/src/http2/connection.js +1170 -0
  15. package/src/http2/constants.js +129 -0
  16. package/src/http2/frames.js +291 -0
  17. package/src/http2/hpack.js +420 -0
  18. package/src/http2/huffman.js +203 -0
  19. package/src/http2/index.js +21 -0
  20. package/src/index.js +46 -0
  21. package/src/pool.js +256 -0
  22. package/src/proxy/direct.js +62 -0
  23. package/src/proxy/http-connect.js +206 -0
  24. package/src/proxy/index.js +197 -0
  25. package/src/proxy/socks5.js +344 -0
  26. package/src/tls/aead.js +263 -0
  27. package/src/tls/connect.js +407 -0
  28. package/src/tls/constants.js +334 -0
  29. package/src/tls/extensions.js +376 -0
  30. package/src/tls/handshake-messages.js +901 -0
  31. package/src/tls/handshake.js +568 -0
  32. package/src/tls/handshake12.js +507 -0
  33. package/src/tls/index.js +44 -0
  34. package/src/tls/keyschedule.js +473 -0
  35. package/src/tls/record.js +872 -0
  36. package/src/tls/tickets.js +145 -0
  37. package/src/tls/transcript.js +101 -0
  38. package/src/tls/wire.js +224 -0
  39. package/src/transport.js +296 -0
  40. package/src/trust/der.js +551 -0
  41. package/src/trust/index.js +375 -0
  42. package/src/trust/name.js +235 -0
  43. package/src/trust/ocsp.js +759 -0
  44. package/src/trust/path.js +595 -0
  45. package/src/trust/roots.js +454 -0
  46. package/src/trust/x509.js +902 -0
  47. package/src/util/bytes.js +470 -0
  48. package/src/util/deadline.js +266 -0
  49. package/src/warmup-fixture.js +85 -0
  50. package/src/warmup.js +243 -0
  51. package/types/client/cookies.d.ts +159 -0
  52. package/types/client/decode.d.ts +54 -0
  53. package/types/client/redirect.d.ts +96 -0
  54. package/types/client.d.ts +323 -0
  55. package/types/errors.d.ts +141 -0
  56. package/types/http1/chunked.d.ts +48 -0
  57. package/types/http1/index.d.ts +3 -0
  58. package/types/http1/request.d.ts +44 -0
  59. package/types/http1/response.d.ts +183 -0
  60. package/types/http2/connection.d.ts +282 -0
  61. package/types/http2/constants.d.ts +95 -0
  62. package/types/http2/frames.d.ts +116 -0
  63. package/types/http2/hpack.d.ts +99 -0
  64. package/types/http2/huffman.d.ts +21 -0
  65. package/types/http2/index.d.ts +5 -0
  66. package/types/index.d.ts +17 -0
  67. package/types/pool.d.ts +135 -0
  68. package/types/proxy/direct.d.ts +26 -0
  69. package/types/proxy/http-connect.d.ts +37 -0
  70. package/types/proxy/index.d.ts +62 -0
  71. package/types/proxy/socks5.d.ts +47 -0
  72. package/types/tls/aead.d.ts +67 -0
  73. package/types/tls/connect.d.ts +280 -0
  74. package/types/tls/constants.d.ts +275 -0
  75. package/types/tls/extensions.d.ts +195 -0
  76. package/types/tls/handshake-messages.d.ts +430 -0
  77. package/types/tls/handshake.d.ts +90 -0
  78. package/types/tls/handshake12.d.ts +35 -0
  79. package/types/tls/index.d.ts +9 -0
  80. package/types/tls/keyschedule.d.ts +272 -0
  81. package/types/tls/record.d.ts +361 -0
  82. package/types/tls/tickets.d.ts +66 -0
  83. package/types/tls/transcript.d.ts +52 -0
  84. package/types/tls/wire.d.ts +106 -0
  85. package/types/transport.d.ts +222 -0
  86. package/types/trust/der.d.ts +239 -0
  87. package/types/trust/index.d.ts +194 -0
  88. package/types/trust/name.d.ts +33 -0
  89. package/types/trust/ocsp.d.ts +138 -0
  90. package/types/trust/path.d.ts +139 -0
  91. package/types/trust/roots.d.ts +36 -0
  92. package/types/trust/x509.d.ts +401 -0
  93. package/types/util/bytes.d.ts +183 -0
  94. package/types/util/deadline.d.ts +133 -0
  95. package/types/warmup-fixture.d.ts +11 -0
  96. package/types/warmup.d.ts +45 -0
@@ -0,0 +1,375 @@
1
+ // Public certificate-trust entry point.
2
+ //
3
+ // The runtime hands this package a tunnelled TLS peer it refuses to verify; this module is the
4
+ // entire difference between "encrypted to the server" and "encrypted to whoever answered". Its
5
+ // contract is shaped so misuse fails loudly:
6
+ //
7
+ // * verifyChain returns the parsed leaf or THROWS. There is no boolean — a caller cannot
8
+ // forget to check one.
9
+ // * Disabling verification requires `mode:'none'` AND `insecureAcceptAnyCertificate:true`.
10
+ // One flag can be a typo or a copy-paste; the pair is a signed confession.
11
+ // * Config nonsense (unknown mode, pins on the wrong mode, an unpopulated root store) is
12
+ // CONFIG_INVALID at call time, never a silent downgrade.
13
+
14
+ import { CertificateError, ConfigError, codes } from '../errors.js';
15
+ import { timingSafeEqual } from '../util/bytes.js';
16
+ import { parseCertificate, decodePem } from './x509.js';
17
+ import { validatePath, anchorFromCertificate } from './path.js';
18
+ import { verifyOcspStaple } from './ocsp.js';
19
+ import { matchesIdentity } from './name.js';
20
+ import { systemAnchors, provenance } from './roots.js';
21
+
22
+ export { parseCertificate, decodePem } from './x509.js';
23
+ export { validatePath, anchorFromCertificate } from './path.js';
24
+ export { verifyOcspStaple, parseOcspResponse } from './ocsp.js';
25
+ export { matchesIdentity } from './name.js';
26
+ export { systemAnchors, provenance as rootStoreProvenance } from './roots.js';
27
+
28
+ const MODES = ['system', 'anchors', 'pinned', 'none', 'custom'];
29
+
30
+ const invalid = (message) => new ConfigError(codes.CONFIG_INVALID, message);
31
+
32
+ // ------------------------------------------------------------------ revocation policy
33
+ //
34
+ // Revocation is checked via stapled OCSP only (see src/trust/ocsp.js for why fetching is not an
35
+ // option here), which forces a policy DECISION about the case stapling cannot cover: most
36
+ // servers simply do not staple, and an attacker who holds a revoked-but-otherwise-valid
37
+ // certificate can present it WITHOUT a staple. The choices and their costs:
38
+ //
39
+ // * Hard-fail (no staple = no connection) is the only stance with teeth against that attacker
40
+ // — and it breaks the majority of the honest web, which would make this package unusable as
41
+ // a default and teach every consumer to switch the check off, the worst outcome of all.
42
+ // * Soft-fail (tolerate absence) is what every browser ships, and it is honestly close to
43
+ // worthless against an active attacker, who can just omit the staple. Its real value is
44
+ // against the common non-adversarial case: an honestly-compromised or mis-issued certificate
45
+ // on a well-run server that DOES staple gets caught.
46
+ //
47
+ // The default here is therefore: ABSENCE of a staple is not a failure, but a staple that IS
48
+ // present must verify completely, and a verified `revoked` (or `unknown`) is always fatal —
49
+ // there is no configuration that ignores a revoked verdict, the same way there is no single
50
+ // flag that disables verification. This asymmetry is principled, not timid: the staple is the
51
+ // server operator's own signed statement about their certificate, so a present-but-invalid one
52
+ // is either misconfiguration worth failing loudly on or an attack, while a missing one is
53
+ // overwhelmingly just a server that never opted in.
54
+ //
55
+ // A caller whose peers are known to staple buys the real guarantee with
56
+ // `revocation: 'require-staple'`, which turns absence into OCSP_REQUIRED — the OCSP equivalent
57
+ // of pinning: opt-in strictness where the deployment can afford it. There is deliberately NO
58
+ // 'off' value: the weakest expressible policy still refuses a revoked certificate, because a
59
+ // caller who wants to talk to a peer the CA has disowned should have to say `mode: 'none'` and
60
+ // own everything that implies.
61
+ const REVOCATION_MODES = ['staple', 'require-staple'];
62
+
63
+ /** Validate the `revocation` knob for the modes that verify chains. */
64
+ function revocationPolicy(trust, mode) {
65
+ const value = trust.revocation ?? 'staple';
66
+ if (!REVOCATION_MODES.includes(value)) {
67
+ throw invalid(`trust.revocation must be one of ${REVOCATION_MODES.map((m) => `'${m}'`).join(', ')} ` +
68
+ `with mode '${mode}', got ${JSON.stringify(value)}. There is no value that ignores a ` +
69
+ 'revoked certificate.');
70
+ }
71
+ return value;
72
+ }
73
+
74
+ /**
75
+ * Enforce the policy above for one validated path: judge the staple when present, and demand one
76
+ * when the caller opted into 'require-staple'.
77
+ *
78
+ * The issuer handed to the OCSP checker comes from the VALIDATED path — the certificate that
79
+ * actually signed the leaf, or the trust anchor when the leaf sits directly under one — because
80
+ * a staple's signature is only meaningful against a key that is already trusted to speak for
81
+ * the leaf's issuer.
82
+ */
83
+ async function checkRevocation({ ocspResponse, revocation, leaf, path, anchor, hostname, now }) {
84
+ if (ocspResponse == null) {
85
+ if (revocation === 'require-staple') {
86
+ throw new CertificateError(codes.OCSP_REQUIRED,
87
+ `no OCSP response was stapled for "${hostname}" and trust.revocation is ` +
88
+ "'require-staple'; without a staple this certificate's revocation status is unknown. " +
89
+ 'Either the server must enable OCSP stapling or this policy must be relaxed',
90
+ { hostname });
91
+ }
92
+ return; // absence tolerated by default; the policy comment above is the argument
93
+ }
94
+ const issuer = path.length > 1
95
+ ? {
96
+ subjectBytes: path[1].subject.bytes,
97
+ spkiDer: path[1].spki.spkiDer,
98
+ subjectText: path[1].subject.text,
99
+ }
100
+ : {
101
+ subjectBytes: anchor.subjectBytes,
102
+ spkiDer: anchor.spkiDer,
103
+ subjectText: anchor.subjectText ?? '<anchor>',
104
+ };
105
+ await verifyOcspStaple({ staple: ocspResponse, leaf, issuer, now });
106
+ }
107
+
108
+ /** Refuse config keys that belong to a different mode: a mismatched knob is a misunderstanding. */
109
+ function forbidKeys(trust, mode, keys) {
110
+ for (const key of keys) {
111
+ if (trust[key] !== undefined) {
112
+ throw invalid(`trust.${key} is not meaningful with mode '${mode}'; refusing a config that ` +
113
+ 'says two different things');
114
+ }
115
+ }
116
+ }
117
+
118
+ /** 'sha256/BASE64' -> 32 raw bytes, rejecting anything that is not exactly that. */
119
+ function parsePins(pins) {
120
+ if (!Array.isArray(pins) || pins.length === 0) {
121
+ throw invalid("trust.pins must be a non-empty array of 'sha256/BASE64' strings");
122
+ }
123
+ return pins.map((p) => {
124
+ if (typeof p !== 'string' || !p.startsWith('sha256/')) {
125
+ throw invalid(`pin ${JSON.stringify(p)} must look like 'sha256/BASE64'`);
126
+ }
127
+ let raw;
128
+ try {
129
+ raw = atob(p.slice('sha256/'.length));
130
+ } catch {
131
+ throw invalid(`pin "${p}" is not valid base64`);
132
+ }
133
+ if (raw.length !== 32) {
134
+ throw invalid(`pin "${p}" decodes to ${raw.length} bytes; a SHA-256 pin must be 32`);
135
+ }
136
+ const out = new Uint8Array(32);
137
+ for (let i = 0; i < 32; i++) out[i] = raw.charCodeAt(i);
138
+ return out;
139
+ });
140
+ }
141
+
142
+ async function spkiSha256(cert) {
143
+ return new Uint8Array(await crypto.subtle.digest('SHA-256', cert.spki.spkiDer));
144
+ }
145
+
146
+ const toPinString = (bytes) => `sha256/${btoa(String.fromCharCode(...bytes))}`;
147
+
148
+ /**
149
+ * Require at least one certificate in `certs` (plus optionally the anchor SPKI) to match a pin.
150
+ * HPKP semantics: a pin may name any element of the chain, so operators can pin an intermediate
151
+ * or root and survive leaf rotation. Comparison is timing-safe — the pins themselves may be
152
+ * secret-adjacent config even though the certificates are public.
153
+ */
154
+ async function checkPins(pins, certs, anchor) {
155
+ const observed = [];
156
+ for (const cert of certs) {
157
+ const digest = await spkiSha256(cert);
158
+ if (pins.some((p) => timingSafeEqual(p, digest))) return;
159
+ observed.push(toPinString(digest));
160
+ }
161
+ if (anchor) {
162
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', anchor.spkiDer));
163
+ if (pins.some((p) => timingSafeEqual(p, digest))) return;
164
+ observed.push(toPinString(digest));
165
+ }
166
+ throw new CertificateError(codes.CERT_PIN_MISMATCH,
167
+ `no certificate in the chain matches any configured pin (observed: ${observed.join(', ')})`,
168
+ { observed });
169
+ }
170
+
171
+ /** Expand user anchors: PEM text (possibly many blocks), DER, parsed certs, or anchor objects. */
172
+ function expandAnchors(list) {
173
+ if (!Array.isArray(list) || list.length === 0) {
174
+ throw invalid('trust.anchors must be a non-empty array of PEM/DER certificates or anchors');
175
+ }
176
+ const out = [];
177
+ for (const entry of list) {
178
+ if (typeof entry === 'string') {
179
+ for (const der of decodePem(entry)) out.push(anchorFromCertificate(parseCertificate(der)));
180
+ } else if (entry instanceof Uint8Array) {
181
+ out.push(anchorFromCertificate(parseCertificate(entry)));
182
+ } else {
183
+ out.push(entry); // parsed certificate or anchor object; validatePath vets the shape
184
+ }
185
+ }
186
+ return out;
187
+ }
188
+
189
+ /** The bundled store, refusing to pass off "never generated" as "trust nothing". */
190
+ function requireSystemStore() {
191
+ if (provenance.anchorCount === 0) {
192
+ throw invalid('the bundled root store is unpopulated; run `npm run roots:refresh` to generate ' +
193
+ "it, or supply anchors explicitly with trust mode 'anchors'");
194
+ }
195
+ return systemAnchors;
196
+ }
197
+
198
+ /**
199
+ * A trust anchor as supplied by a caller: PEM text or raw DER.
200
+ * @typedef {string | Uint8Array} AnchorInput
201
+ */
202
+
203
+ /**
204
+ * Revocation policy, for the modes that validate chains. Checking is via stapled OCSP only, so
205
+ * the knob decides what a MISSING staple means: `'staple'` (the default) tolerates absence but
206
+ * fully verifies any staple that is present; `'require-staple'` makes absence OCSP_REQUIRED.
207
+ * A verified `revoked` or `unknown` verdict is fatal under both — no value ignores it.
208
+ * @typedef {'staple' | 'require-staple'} RevocationPolicy
209
+ */
210
+
211
+ /**
212
+ * Verify against the bundled CCADB root store. The default.
213
+ * @typedef {{ mode?: 'system', revocation?: RevocationPolicy }} SystemTrust
214
+ */
215
+
216
+ /**
217
+ * Verify against exactly these anchors and nothing else. The bundled store is not consulted.
218
+ * @typedef {{ mode: 'anchors', anchors: AnchorInput[],
219
+ * revocation?: RevocationPolicy }} AnchorsTrust
220
+ */
221
+
222
+ /**
223
+ * Full path validation, plus a requirement that some certificate in the accepted path (or its
224
+ * anchor) match one of `pins`. Pins are `sha256/` followed by the base64 SHA-256 of a
225
+ * SubjectPublicKeyInfo, the same spelling HPKP used.
226
+ * @typedef {{ mode: 'pinned', pins: string[], anchors?: AnchorInput[],
227
+ * revocation?: RevocationPolicy }} PinnedTrust
228
+ */
229
+
230
+ /**
231
+ * No path validation at all. `insecureAcceptAnyCertificate` is mandatory and must be `true`, so
232
+ * that this mode can never be reached by a typo in `mode`. Supplying `pins` turns it into
233
+ * pin-only trust: no chain is built, but a pin must still match. `revocation` is refused here:
234
+ * without a validated issuer there is no trusted key to verify a staple against, so the check
235
+ * cannot be performed honestly and pretending otherwise would be worse.
236
+ * @typedef {{ mode: 'none', insecureAcceptAnyCertificate: true, pins?: string[] }} NoTrust
237
+ */
238
+
239
+ /**
240
+ * Caller-supplied policy. Returning normally accepts the chain; throwing rejects it. The third
241
+ * argument carries the peer's stapled OCSP response (DER, or null) so a custom policy can judge
242
+ * revocation itself — `verifyOcspStaple` is exported for exactly that.
243
+ * @typedef {{ mode: 'custom',
244
+ * verify: (chain: ParsedCertificate[], hostname: string,
245
+ * details?: { ocspResponse: Uint8Array | null })
246
+ * => void | Promise<void> }} CustomTrust
247
+ */
248
+
249
+ /**
250
+ * The `verify=` knob, in httpx's spirit. Written as a discriminated union so that a TypeScript
251
+ * caller cannot ask for pinning without pins, or reach `mode: 'none'` without spelling out the
252
+ * flag that says they meant it — both of which are otherwise runtime errors discovered in
253
+ * production rather than compile errors discovered while typing.
254
+ * @typedef {SystemTrust | AnchorsTrust | PinnedTrust | NoTrust | CustomTrust} TrustConfig
255
+ */
256
+
257
+ /**
258
+ * A parsed certificate, as returned by `parseCertificate`. Only the members other layers rely on
259
+ * are named here; the object carries the full parse.
260
+ * @typedef {object} ParsedCertificate
261
+ * @property {Uint8Array} der the original bytes, never re-encoded
262
+ * @property {Uint8Array} tbsBytes exact TBSCertificate slice the signature covers
263
+ * @property {{ spkiDer: Uint8Array, algorithmOid: string, keyBytes: Uint8Array }} spki
264
+ * @property {{ text: string }} subject
265
+ * @property {{ text: string }} issuer
266
+ * @property {number} notBefore epoch ms
267
+ * @property {number} notAfter epoch ms
268
+ * @property {{ dns: string[], ip: Uint8Array[], uri: string[], email: string[] }} subjectAltNames
269
+ */
270
+
271
+ /**
272
+ * Verify a TLS-delivered certificate chain for `hostname`.
273
+ *
274
+ * @param {object} opts
275
+ * @param {Uint8Array[]} opts.chain DER certificates, leaf first, as the peer sent them
276
+ * @param {string} opts.hostname identity from the request URL (DNS name or IP literal)
277
+ * @param {TrustConfig} [opts.trust] the verification policy; defaults to the bundled roots
278
+ * @param {number} [opts.now] epoch ms, for tests and for callers with a better clock
279
+ * @param {Uint8Array | null} [opts.ocspResponse] the peer's stapled DER OCSPResponse, when the
280
+ * handshake carried one; judged under `trust.revocation` (see the policy comment above)
281
+ * @returns {Promise<ParsedCertificate>} the parsed leaf. Every other outcome throws.
282
+ */
283
+ export async function verifyChain({
284
+ chain, hostname, trust = { mode: 'system' }, now = Date.now(), ocspResponse = null,
285
+ }) {
286
+ if (trust === null || typeof trust !== 'object') {
287
+ throw invalid("trust must be an object like { mode: 'system' }");
288
+ }
289
+ const mode = trust.mode ?? 'system';
290
+ if (!MODES.includes(mode)) {
291
+ throw invalid(`unknown trust mode ${JSON.stringify(mode)}; expected one of ${MODES.join(', ')}`);
292
+ }
293
+ if (mode !== 'none' && trust.insecureAcceptAnyCertificate !== undefined) {
294
+ throw invalid(`trust.insecureAcceptAnyCertificate is not meaningful with mode '${mode}'`);
295
+ }
296
+ if (!Array.isArray(chain) || chain.length === 0) {
297
+ throw new CertificateError(codes.CERT_CHAIN_INCOMPLETE, 'the peer supplied no certificates');
298
+ }
299
+
300
+ if (mode === 'none') {
301
+ // Reaching "no verification" by accident must be impossible: the mode alone is refused
302
+ // without its explicit confession flag.
303
+ if (trust.insecureAcceptAnyCertificate !== true) {
304
+ throw invalid("trust mode 'none' additionally requires insecureAcceptAnyCertificate: true; " +
305
+ 'refusing to disable certificate verification on a single flag');
306
+ }
307
+ forbidKeys(trust, 'none', ['anchors', 'verify', 'revocation']);
308
+ if (trust.pins !== undefined) {
309
+ // Pin-only trust: no path validation, but the pin check itself must not fail open — a
310
+ // chain we cannot parse cannot be pinned, so it throws.
311
+ const pins = parsePins(trust.pins);
312
+ const parsed = chain.map((der) => parseCertificate(der));
313
+ await checkPins(pins, parsed, null);
314
+ return parsed[0];
315
+ }
316
+ // Even here the leaf must parse, and the reason is not about trust: the handshake authenticates
317
+ // the key exchange with a signature it checks against the leaf's public key, so a certificate
318
+ // whose SPKI cannot be read leaves nothing to check the signature against. 'none' switches off
319
+ // deciding whether to BELIEVE the certificate; it cannot conjure a key out of bytes that are
320
+ // not a certificate.
321
+ //
322
+ // This used to return null and let the drivers refuse a few frames later with CONFIG_INVALID
323
+ // ("verifyPeer must resolve with the validated leaf"), which blamed the caller's configuration
324
+ // for the peer's malformed certificate — precisely the sort of error that sends someone
325
+ // auditing their own code for an hour.
326
+ try {
327
+ return parseCertificate(chain[0]);
328
+ } catch (cause) {
329
+ throw new CertificateError(
330
+ codes.CERT_PARSE,
331
+ 'the peer\'s leaf certificate could not be parsed, so its public key is unavailable and ' +
332
+ 'the handshake signature cannot be checked against anything. Verification is disabled ' +
333
+ `(trust mode 'none'), which does not help here: ${cause?.message ?? cause}`,
334
+ { mode: 'none', cause: cause?.message ?? String(cause) },
335
+ );
336
+ }
337
+ }
338
+
339
+ if (mode === 'custom') {
340
+ // `revocation` is refused for the same reason `pins` is: custom mode owns policy entirely.
341
+ // The staple is handed to the callback instead, with verifyOcspStaple exported so a custom
342
+ // policy can run the standard check against whichever issuer its own validation blessed.
343
+ forbidKeys(trust, 'custom', ['anchors', 'pins', 'revocation']);
344
+ if (typeof trust.verify !== 'function') {
345
+ throw invalid("trust mode 'custom' requires a verify(chain, hostname) function");
346
+ }
347
+ const parsed = chain.map((der) => parseCertificate(der));
348
+ // The callback owns policy entirely: throwing rejects the connection, returning accepts it.
349
+ // Its errors propagate untouched so callers see their own diagnostics.
350
+ await trust.verify(parsed, hostname, { ocspResponse });
351
+ return parsed[0];
352
+ }
353
+
354
+ let anchors;
355
+ let pins = null;
356
+ if (mode === 'system') {
357
+ forbidKeys(trust, 'system', ['anchors', 'pins', 'verify']);
358
+ anchors = requireSystemStore();
359
+ } else if (mode === 'anchors') {
360
+ forbidKeys(trust, 'anchors', ['pins', 'verify']);
361
+ anchors = expandAnchors(trust.anchors);
362
+ } else { // pinned
363
+ forbidKeys(trust, 'pinned', ['verify']);
364
+ pins = parsePins(trust.pins);
365
+ anchors = trust.anchors !== undefined ? expandAnchors(trust.anchors) : requireSystemStore();
366
+ }
367
+ // Validated before any network-derived bytes are judged, like every other config error: a
368
+ // misspelled policy must fail the connection even when the peer stapled nothing.
369
+ const revocation = revocationPolicy(trust, mode);
370
+
371
+ const { leaf, path, anchor } = await validatePath({ chain, anchors, hostname, now });
372
+ if (pins) await checkPins(pins, path, anchor);
373
+ await checkRevocation({ ocspResponse, revocation, leaf, path, anchor, hostname, now });
374
+ return leaf;
375
+ }
@@ -0,0 +1,235 @@
1
+ // Certificate identity matching (RFC 6125 / RFC 9110 s4.3.4), plus the name-subtree predicates
2
+ // shared with name-constraint enforcement in path.js.
3
+ //
4
+ // Positions taken here, all in the fail-closed direction:
5
+ //
6
+ // * Only subjectAltName is consulted. The Common Name fallback died in browsers years ago
7
+ // because a CN is free text that CAs historically failed to police; resurrecting it would
8
+ // re-open every "CN=*" misissuance. A certificate with no SAN matches nothing.
9
+ // * No IDNA. Correct IDNA needs the full UTS-46 mapping tables; an approximation would happily
10
+ // equate names that real resolvers distinguish. Callers must present A-labels (punycode);
11
+ // a non-ASCII hostname is refused with a clear error instead of a wrong answer.
12
+ // * Wildcards per current CA/Browser Forum practice: one '*', alone in the leftmost label,
13
+ // covering exactly one label, and never for a name with fewer than two labels behind it.
14
+
15
+ import { CertificateError, ConfigError, codes } from '../errors.js';
16
+ import { equal, toHex } from '../util/bytes.js';
17
+
18
+ // ------------------------------------------------------------------ IP literal parsing
19
+
20
+ /** Strict dotted-quad. Leading zeros are refused: "010" is octal to some stacks, ten to others. */
21
+ function parseIpv4(text) {
22
+ const parts = text.split('.');
23
+ if (parts.length !== 4) return null;
24
+ const out = new Uint8Array(4);
25
+ for (let i = 0; i < 4; i++) {
26
+ const p = parts[i];
27
+ if (!/^[0-9]{1,3}$/.test(p) || (p.length > 1 && p[0] === '0')) return null;
28
+ const v = Number(p);
29
+ if (v > 255) return null;
30
+ out[i] = v;
31
+ }
32
+ return out;
33
+ }
34
+
35
+ /**
36
+ * IPv6 per RFC 4291 s2.2: hex groups, at most one '::' standing for one or more zero groups,
37
+ * optional embedded dotted-quad in the final 32 bits. Zone indices ("%eth0") are refused — they
38
+ * name a local interface and have no meaning in a certificate.
39
+ */
40
+ function parseIpv6(text) {
41
+ if (text.includes('%')) return null;
42
+ const halves = text.split('::');
43
+ if (halves.length > 2) return null;
44
+
45
+ // Parse one colon-separated run into 16-bit groups. `isTail` marks the run that ends the
46
+ // address, the only place an embedded IPv4 may appear.
47
+ const parseRun = (run, isTail) => {
48
+ if (run === '') return [];
49
+ const groups = [];
50
+ const parts = run.split(':');
51
+ for (let i = 0; i < parts.length; i++) {
52
+ const p = parts[i];
53
+ if (p === '') return null; // ':::' or stray leading/trailing ':'
54
+ if (p.includes('.')) {
55
+ if (!isTail || i !== parts.length - 1) return null;
56
+ const v4 = parseIpv4(p);
57
+ if (!v4) return null;
58
+ groups.push((v4[0] << 8) | v4[1], (v4[2] << 8) | v4[3]);
59
+ } else {
60
+ if (!/^[0-9a-fA-F]{1,4}$/.test(p)) return null;
61
+ groups.push(parseInt(p, 16));
62
+ }
63
+ }
64
+ return groups;
65
+ };
66
+
67
+ let groups;
68
+ if (halves.length === 2) {
69
+ const left = parseRun(halves[0], false);
70
+ const right = parseRun(halves[1], true);
71
+ if (!left || !right) return null;
72
+ const missing = 8 - left.length - right.length;
73
+ if (missing < 1) return null; // '::' must stand for at least one group
74
+ groups = [...left, ...new Array(missing).fill(0), ...right];
75
+ } else {
76
+ groups = parseRun(text, true);
77
+ if (!groups || groups.length !== 8) return null;
78
+ }
79
+ const out = new Uint8Array(16);
80
+ for (let i = 0; i < 8; i++) {
81
+ out[i * 2] = groups[i] >> 8;
82
+ out[i * 2 + 1] = groups[i] & 0xff;
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /**
88
+ * Parse an IP literal (v4 dotted-quad or v6, optionally [bracketed]) to raw bytes, else null.
89
+ * @param {string} text
90
+ * @returns {Uint8Array | null}
91
+ */
92
+ export function parseIp(text) {
93
+ let t = text;
94
+ if (t.startsWith('[') && t.endsWith(']')) t = t.slice(1, -1);
95
+ if (t.includes(':')) return parseIpv6(t);
96
+ if (/^[0-9.]+$/.test(t)) return parseIpv4(t);
97
+ return null;
98
+ }
99
+
100
+ // ------------------------------------------------------------------ hostname handling
101
+
102
+ const isAscii = (s) => /^[\x21-\x7e]*$/.test(s);
103
+
104
+ /**
105
+ * Normalise the caller's requested identity: exactly one trailing dot stripped (an FQDN marker,
106
+ * not part of the identity), ASCII lowercased. Anything unservable throws CONFIG_INVALID — this
107
+ * is the caller's own input, not the peer's, so it is a configuration error, not a mismatch.
108
+ */
109
+ function normalizeHostname(hostname) {
110
+ if (typeof hostname !== 'string' || hostname.length === 0) {
111
+ throw new ConfigError(codes.CONFIG_INVALID, 'hostname to verify must be a non-empty string');
112
+ }
113
+ if (hostname.includes('\0')) {
114
+ throw new ConfigError(codes.CONFIG_INVALID, 'hostname contains a NUL byte');
115
+ }
116
+ const ip = parseIp(hostname);
117
+ if (ip) return { ip, host: hostname };
118
+ let host = hostname.endsWith('.') ? hostname.slice(0, -1) : hostname;
119
+ if (host.length === 0) {
120
+ throw new ConfigError(codes.CONFIG_INVALID, `hostname "${hostname}" has no labels`);
121
+ }
122
+ if (!isAscii(host)) {
123
+ throw new ConfigError(
124
+ codes.CONFIG_INVALID,
125
+ `hostname "${hostname}" is not ASCII; IDNA is not implemented — pass the A-label (punycode) form`,
126
+ );
127
+ }
128
+ return { ip: null, host: host.toLowerCase() };
129
+ }
130
+
131
+ /**
132
+ * One SAN dNSName against the (lowercased, ASCII) query. Returns false rather than throwing for
133
+ * a malformed SAN entry: the certificate may carry other, valid entries, and a name that cannot
134
+ * be interpreted safely simply cannot match anything.
135
+ */
136
+ function dnsNameMatches(san, host) {
137
+ if (san.length === 0 || san.includes('\0') || !isAscii(san)) return false;
138
+ const s = san.toLowerCase();
139
+ if (!s.includes('*')) return s === host;
140
+ // Wildcard rules: the '*' must be the entire leftmost label ("w*.x" and "a.*.b" are refused),
141
+ // must be backed by at least two literal labels ("*.com" would cover a whole TLD), and matches
142
+ // exactly one label — "*.example.com" covers "a.example.com", never "a.b.example.com" and
143
+ // never the bare "example.com".
144
+ if (!s.startsWith('*.')) return false;
145
+ const rest = s.slice(2);
146
+ if (rest.includes('*')) return false;
147
+ const restLabels = rest.split('.');
148
+ if (restLabels.length < 2 || restLabels.some((l) => l.length === 0)) return false;
149
+ const hostLabels = host.split('.');
150
+ if (hostLabels.length !== restLabels.length + 1) return false;
151
+ if (hostLabels[0].length === 0) return false;
152
+ return hostLabels.slice(1).join('.') === rest;
153
+ }
154
+
155
+ const describeIp = (bytes) =>
156
+ bytes.byteLength === 4 ? Array.from(bytes).join('.') : toHex(bytes).replace(/(....)(?=.)/g, '$1:');
157
+
158
+ /**
159
+ * Verify that `cert` is a certificate for `hostname`. Returns nothing; every non-match throws
160
+ * CERT_NAME_MISMATCH with the entries that were considered, so one log line shows exactly how
161
+ * close the certificate was.
162
+ *
163
+ * @param {import('./x509.js').Certificate} cert
164
+ * @param {string} hostname DNS name (A-label form) or IP literal, per the request URL
165
+ * @returns {void}
166
+ */
167
+ export function matchesIdentity(cert, hostname) {
168
+ const { ip, host } = normalizeHostname(hostname);
169
+ const san = cert.subjectAltNames;
170
+ if (!san.present) {
171
+ throw new CertificateError(
172
+ codes.CERT_NAME_MISMATCH,
173
+ `certificate "${cert.subject.text}" has no subjectAltName extension; ` +
174
+ 'the Common Name is never consulted, so it cannot match any identity',
175
+ { hostname, subject: cert.subject.text },
176
+ );
177
+ }
178
+ if (ip) {
179
+ // An IP identity matches only iPAddress entries, by raw bytes. A dNSName that happens to
180
+ // spell the same address is a CA mistake, not an identity (RFC 6125 s1.7.2).
181
+ for (const entry of san.ip) {
182
+ if (equal(entry, ip)) return;
183
+ }
184
+ const listed = san.ip.length ? san.ip.map(describeIp).join(', ') : 'none';
185
+ throw new CertificateError(
186
+ codes.CERT_NAME_MISMATCH,
187
+ `IP address ${hostname} does not match certificate "${cert.subject.text}" ` +
188
+ `(iPAddress entries: ${listed})`,
189
+ { hostname, subject: cert.subject.text },
190
+ );
191
+ }
192
+ for (const entry of san.dns) {
193
+ if (dnsNameMatches(entry, host)) return;
194
+ }
195
+ const listed = san.dns.length ? san.dns.join(', ') : 'none';
196
+ throw new CertificateError(
197
+ codes.CERT_NAME_MISMATCH,
198
+ `hostname "${hostname}" does not match certificate "${cert.subject.text}" ` +
199
+ `(dNSName entries: ${listed})`,
200
+ { hostname, subject: cert.subject.text },
201
+ );
202
+ }
203
+
204
+ // ------------------------------------------------------------------ name-constraint predicates
205
+
206
+ /**
207
+ * RFC 5280 s4.2.1.10 dNSName subtree: a constraint "example.com" covers the host itself and any
208
+ * subdomain, at a label boundary. The seen-in-the-wild ".example.com" form covers subdomains
209
+ * only. An empty constraint covers every DNS name (used by permittedSubtrees to say "any DNS").
210
+ * @param {string} name
211
+ * @param {string} base
212
+ * @returns {boolean}
213
+ */
214
+ export function dnsWithinSubtree(name, base) {
215
+ const n = name.toLowerCase();
216
+ const b = base.toLowerCase();
217
+ if (b === '') return true;
218
+ if (b.startsWith('.')) return n.length > b.length && n.endsWith(b);
219
+ return n === b || n.endsWith(`.${b}`);
220
+ }
221
+
222
+ /**
223
+ * iPAddress subtree: same family, and (address & mask) equal on every byte.
224
+ * @param {Uint8Array} ip
225
+ * @param {Uint8Array} addr
226
+ * @param {Uint8Array} mask
227
+ * @returns {boolean}
228
+ */
229
+ export function ipWithinSubtree(ip, addr, mask) {
230
+ if (ip.byteLength !== addr.byteLength || addr.byteLength !== mask.byteLength) return false;
231
+ for (let i = 0; i < ip.byteLength; i++) {
232
+ if ((ip[i] & mask[i]) !== (addr[i] & mask[i])) return false;
233
+ }
234
+ return true;
235
+ }