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,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
|
+
}
|