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,595 @@
|
|
|
1
|
+
// Cost profile, measured on the target runtime, recorded because it is counter-intuitive enough
|
|
2
|
+
// that it has already sent one optimisation effort at the wrong target.
|
|
3
|
+
//
|
|
4
|
+
// Validating a chain costs ~3.5 ms for an all-ECDSA chain and ~0.8 ms for an RSA one. Almost all
|
|
5
|
+
// of that is signature verification, not parsing: parseCertificate over a whole chain is ~158 us,
|
|
6
|
+
// while a single ECDSA P-384 verify is ~665-816 us — 12x a P-256 verify (~56 us) and ~27x an
|
|
7
|
+
// RSA-2048 verify (~25 us). A typical EC chain carries two P-384 links, so two operations account
|
|
8
|
+
// for most of the total.
|
|
9
|
+
//
|
|
10
|
+
// This is not a runtime defect and it is not worth reporting as one: the same measurement in Node
|
|
11
|
+
// gives RSA 26 us, P-256 53 us, P-384 371 us, so P-384 is intrinsically expensive and this runtime
|
|
12
|
+
// is only ~2x slower at it. (An earlier investigation reported Node verifying all three in ~16 us
|
|
13
|
+
// and concluded the runtime had a pathological P-384 implementation; that Node measurement did not
|
|
14
|
+
// reproduce, and a P-384 verify faster than an RSA-2048 verify is not a plausible result.)
|
|
15
|
+
//
|
|
16
|
+
// The consequences that matter: there is no headroom here, because a signature check cannot be
|
|
17
|
+
// skipped and hand-rolling P-384 in JavaScript would be both slower and a new security-critical
|
|
18
|
+
// implementation this package exists to avoid. The cost is paid once per CONNECTION, so reuse is
|
|
19
|
+
// what amortises it — a pooled request is ~0.88 ms against ~9-12 ms for a new EC connection. And
|
|
20
|
+
// an origin whose certificate chain is RSA or P-256 validates several times cheaper here than one
|
|
21
|
+
// using P-384, which is worth knowing if you control the origin.
|
|
22
|
+
|
|
23
|
+
// RFC 5280 s6.1 certification path building and validation.
|
|
24
|
+
//
|
|
25
|
+
// The chain arrives leaf-first, but nothing else about it is trusted: real servers ship
|
|
26
|
+
// intermediates out of order, append irrelevant certificates, and include their own root. The
|
|
27
|
+
// path is therefore rebuilt here from the leaf up — issuer matched to subject by exact DN bytes,
|
|
28
|
+
// disambiguated by key identifiers, terminated at a caller-supplied trust anchor — and only the
|
|
29
|
+
// certificates on that rebuilt path are judged.
|
|
30
|
+
//
|
|
31
|
+
// A trust anchor is a (name, public key, constraints) triple per RFC 5280 s6.1.1, not a
|
|
32
|
+
// certificate: the anchor's own self-signature proves nothing (anyone can self-sign any name)
|
|
33
|
+
// and is never verified. For the same reason an anchor's notBefore/notAfter are recorded but not
|
|
34
|
+
// enforced — expiring a root out from under otherwise-valid chains is exactly the failure that
|
|
35
|
+
// took down half the internet when AddTrust expired; root lifetime is store-curation policy, not
|
|
36
|
+
// path validity.
|
|
37
|
+
//
|
|
38
|
+
// Deliberately not implemented, and why that is safe or announced rather than silent:
|
|
39
|
+
// * Policy processing (certificatePolicies / policyConstraints / inhibitAnyPolicy): with no
|
|
40
|
+
// required policy set, RFC 5280 policy processing cannot fail a path. policyConstraints and
|
|
41
|
+
// inhibitAnyPolicy — which would change that — are always critical and are NOT in
|
|
42
|
+
// KNOWN_EXTENSIONS, so a path carrying them is rejected, never quietly mis-validated.
|
|
43
|
+
// * Revocation FETCHING (CRL downloads, responder queries): unreachable from a metered edge
|
|
44
|
+
// runtime mid-handshake, and a privacy leak besides. Revocation is instead checked from a
|
|
45
|
+
// stapled OCSP response when the handshake carries one — see src/trust/ocsp.js for the
|
|
46
|
+
// verification and src/trust/index.js for the policy on absence.
|
|
47
|
+
|
|
48
|
+
import { CertificateError, ConfigError, codes } from '../errors.js';
|
|
49
|
+
import { equal, toHex } from '../util/bytes.js';
|
|
50
|
+
import { SIG_SCHEME, SIG_SCHEME_PARAMS } from '../tls/constants.js';
|
|
51
|
+
import {
|
|
52
|
+
OID, parseCertificate, parseNameConstraints, parseSubjectPublicKeyInfo, resolveSignatureScheme,
|
|
53
|
+
} from './x509.js';
|
|
54
|
+
import {
|
|
55
|
+
TAG,
|
|
56
|
+
readAll,
|
|
57
|
+
expectTlv,
|
|
58
|
+
children,
|
|
59
|
+
readInteger,
|
|
60
|
+
ecdsaDerToRaw as derEcdsaToRaw,
|
|
61
|
+
} from './der.js';
|
|
62
|
+
import { matchesIdentity, dnsWithinSubtree, ipWithinSubtree } from './name.js';
|
|
63
|
+
|
|
64
|
+
const constraintError = (message, detail) =>
|
|
65
|
+
new CertificateError(codes.CERT_CONSTRAINT, message, detail);
|
|
66
|
+
|
|
67
|
+
// ------------------------------------------------------------------ anchors
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The RFC 5280 s6.1.1 trust-anchor triple, normalised. Validity bounds are recorded but never
|
|
71
|
+
* enforced — see the module comment for why expiring a root is store policy, not path policy.
|
|
72
|
+
* @typedef {object} TrustAnchor
|
|
73
|
+
* @property {Uint8Array} subjectBytes exact subject DN DER
|
|
74
|
+
* @property {string} subjectText
|
|
75
|
+
* @property {Uint8Array} spkiDer
|
|
76
|
+
* @property {Uint8Array | null} subjectKeyIdentifier
|
|
77
|
+
* @property {import('./x509.js').NameConstraints | null} nameConstraints
|
|
78
|
+
* @property {number | null} notBefore
|
|
79
|
+
* @property {number | null} notAfter
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Anything normalizeAnchor accepts: DER, a parsed certificate, or an anchor-shaped record.
|
|
84
|
+
* The record form also admits `nameConstraintsBytes` (raw extnValue), which is how the bundled
|
|
85
|
+
* store defers parsing to the one anchor a handshake actually lands on.
|
|
86
|
+
* @typedef {object} AnchorRecord
|
|
87
|
+
* @property {Uint8Array} subjectBytes
|
|
88
|
+
* @property {Uint8Array} spkiDer
|
|
89
|
+
* @property {string} [subjectText]
|
|
90
|
+
* @property {Uint8Array | null} [subjectKeyIdentifier]
|
|
91
|
+
* @property {import('./x509.js').NameConstraints | null} [nameConstraints]
|
|
92
|
+
* @property {Uint8Array | null} [nameConstraintsBytes]
|
|
93
|
+
* @property {number | null} [notBefore]
|
|
94
|
+
* @property {number | null} [notAfter]
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/** @typedef {Uint8Array | import('./x509.js').Certificate | AnchorRecord} AnchorLike */
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* An indexed anchor lookup: everything path building needs from a root store. The bundled
|
|
101
|
+
* store implements it with a subject-hash index so a handshake touches one anchor, not all.
|
|
102
|
+
* @typedef {{ forIssuer: (subjectDn: Uint8Array) => AnchorLike[] | Promise<AnchorLike[]> }}
|
|
103
|
+
* AnchorSource
|
|
104
|
+
*/
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Strip a parsed certificate down to the RFC 5280 s6.1.1 trust-anchor triple. Used for the
|
|
108
|
+
* `mode:'anchors'` knob and by the root-store generator, so both feed path validation through
|
|
109
|
+
* the identical shape.
|
|
110
|
+
* @param {import('./x509.js').Certificate} cert
|
|
111
|
+
* @returns {TrustAnchor}
|
|
112
|
+
*/
|
|
113
|
+
export function anchorFromCertificate(cert) {
|
|
114
|
+
return Object.freeze({
|
|
115
|
+
subjectBytes: cert.subject.bytes,
|
|
116
|
+
subjectText: cert.subject.text,
|
|
117
|
+
spkiDer: cert.spki.spkiDer,
|
|
118
|
+
subjectKeyIdentifier: cert.subjectKeyIdentifier,
|
|
119
|
+
nameConstraints: cert.nameConstraints,
|
|
120
|
+
notBefore: cert.notBefore,
|
|
121
|
+
notAfter: cert.notAfter,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Accept an anchor object (from roots.js or anchorFromCertificate), DER, or a parsed cert.
|
|
127
|
+
* @param {AnchorLike} a
|
|
128
|
+
* @param {number} [index]
|
|
129
|
+
* @returns {TrustAnchor}
|
|
130
|
+
*/
|
|
131
|
+
function normalizeAnchor(a, index) {
|
|
132
|
+
if (a instanceof Uint8Array) return anchorFromCertificate(parseCertificate(a));
|
|
133
|
+
if (a && a.tbsBytes) return anchorFromCertificate(a);
|
|
134
|
+
if (a && a.subjectBytes instanceof Uint8Array && a.spkiDer instanceof Uint8Array) {
|
|
135
|
+
return {
|
|
136
|
+
subjectBytes: a.subjectBytes,
|
|
137
|
+
subjectText: a.subjectText ?? '<anchor>',
|
|
138
|
+
spkiDer: a.spkiDer,
|
|
139
|
+
subjectKeyIdentifier: a.subjectKeyIdentifier ?? null,
|
|
140
|
+
// roots.js stores the raw extension value and defers parsing to the one anchor a
|
|
141
|
+
// handshake actually lands on.
|
|
142
|
+
nameConstraints: a.nameConstraints ??
|
|
143
|
+
(a.nameConstraintsBytes ? parseNameConstraints(a.nameConstraintsBytes) : null),
|
|
144
|
+
notBefore: a.notBefore ?? null,
|
|
145
|
+
notAfter: a.notAfter ?? null,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
throw new ConfigError(codes.CONFIG_INVALID,
|
|
149
|
+
`trust anchor at index ${index} is neither DER, a parsed certificate, nor an anchor object`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Normalise `anchors` into a lookup source. An array is scanned; an object with `forIssuer` is
|
|
154
|
+
* used as-is — the bundled root store implements it with a subject-hash index so a handshake
|
|
155
|
+
* touches one anchor, not the whole store.
|
|
156
|
+
*/
|
|
157
|
+
function toAnchorSource(anchors) {
|
|
158
|
+
if (anchors && typeof anchors.forIssuer === 'function') return anchors;
|
|
159
|
+
if (!Array.isArray(anchors)) {
|
|
160
|
+
throw new ConfigError(codes.CONFIG_INVALID,
|
|
161
|
+
'anchors must be an array or an anchor source with forIssuer()');
|
|
162
|
+
}
|
|
163
|
+
const normalized = anchors.map(normalizeAnchor);
|
|
164
|
+
return {
|
|
165
|
+
forIssuer: async (dnBytes) => normalized.filter((a) => equal(a.subjectBytes, dnBytes)),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ------------------------------------------------------------------ signature verification
|
|
170
|
+
|
|
171
|
+
const CURVE_TO_SCHEME = {
|
|
172
|
+
[OID.secp256r1]: SIG_SCHEME.ecdsa_secp256r1_sha256,
|
|
173
|
+
[OID.secp384r1]: SIG_SCHEME.ecdsa_secp384r1_sha384,
|
|
174
|
+
[OID.secp521r1]: SIG_SCHEME.ecdsa_secp521r1_sha512,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* ECDSA-Sig-Value to the fixed-width r||s form WebCrypto verifies. The conversion itself lives in
|
|
179
|
+
* der.js because the TLS CertificateVerify path needs exactly the same one, and two copies of a
|
|
180
|
+
* signature parser is two chances to be subtly different.
|
|
181
|
+
*/
|
|
182
|
+
function ecdsaDerToRaw(sig, orderLen, subject) {
|
|
183
|
+
return derEcdsaToRaw(sig, orderLen, (why) =>
|
|
184
|
+
new CertificateError(codes.CERT_SIGNATURE_INVALID,
|
|
185
|
+
`ECDSA signature on "${subject}" is malformed: ${why}`, { subject }));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Verify `cert`'s signature over its original to-be-signed bytes with the signer's public key.
|
|
190
|
+
*
|
|
191
|
+
* The scheme comes from resolveSignatureScheme (which is where MD5/SHA-1 die, before any
|
|
192
|
+
* cryptography runs). For ECDSA the curve belongs to the issuer's key, not the OID, so the
|
|
193
|
+
* WebCrypto import parameters are chosen from the issuer's SPKI and only the hash from the OID —
|
|
194
|
+
* both looked up in SIG_SCHEME_PARAMS rather than re-declared here.
|
|
195
|
+
*
|
|
196
|
+
* Exported (as verifySignedObject) for the OCSP checker: a BasicOCSPResponse is signed exactly
|
|
197
|
+
* like a certificate — an AlgorithmIdentifier, a BIT STRING over the original DER of a TBS
|
|
198
|
+
* element — and two implementations of "check an X.509-style signature" is two chances for one
|
|
199
|
+
* of them to be subtly the weaker. `cert` is therefore the structural subset both callers can
|
|
200
|
+
* supply: `{ tbsBytes, signature, signatureAlgorithm, subject: { text } }`, which a parsed
|
|
201
|
+
* Certificate satisfies as-is and the OCSP checker fakes up from response fields.
|
|
202
|
+
*
|
|
203
|
+
* @param {{ tbsBytes: Uint8Array, signature: Uint8Array,
|
|
204
|
+
* signatureAlgorithm: import('./x509.js').AlgorithmId,
|
|
205
|
+
* subject: { text: string } }} cert what was signed, certificate-shaped
|
|
206
|
+
* @param {Uint8Array} issuerSpkiDer the signer's SubjectPublicKeyInfo, DER
|
|
207
|
+
* @param {string} issuerText the signer's name, for error messages
|
|
208
|
+
* @returns {Promise<void>} every failure throws a typed CertificateError
|
|
209
|
+
*/
|
|
210
|
+
export async function verifySignedObject(cert, issuerSpkiDer, issuerText) {
|
|
211
|
+
const scheme = resolveSignatureScheme(cert); // throws CERT_SIGNATURE_WEAK / _UNSUPPORTED
|
|
212
|
+
const issuerSpki = parseSubjectPublicKeyInfo(issuerSpkiDer);
|
|
213
|
+
const mismatch = (why) =>
|
|
214
|
+
new CertificateError(codes.CERT_SIGNATURE_INVALID,
|
|
215
|
+
`signature on "${cert.subject.text}" (${scheme.name}) cannot be by issuer "${issuerText}": ${why}`,
|
|
216
|
+
{ subject: cert.subject.text, issuer: issuerText, scheme: scheme.name });
|
|
217
|
+
|
|
218
|
+
let importParams;
|
|
219
|
+
let verifyParams;
|
|
220
|
+
let sigBytes = cert.signature;
|
|
221
|
+
if (scheme.kind === 'ecdsa') {
|
|
222
|
+
if (issuerSpki.algorithmOid !== OID.ecPublicKey) throw mismatch('issuer key is not an EC key');
|
|
223
|
+
const schemeId = CURVE_TO_SCHEME[issuerSpki.curveOid];
|
|
224
|
+
if (!schemeId) {
|
|
225
|
+
throw new CertificateError(codes.CERT_SIGNATURE_UNSUPPORTED,
|
|
226
|
+
`issuer "${issuerText}" uses EC curve ${issuerSpki.curveOid}, which is not supported`,
|
|
227
|
+
{ curveOid: issuerSpki.curveOid });
|
|
228
|
+
}
|
|
229
|
+
const table = SIG_SCHEME_PARAMS[schemeId];
|
|
230
|
+
importParams = table.import;
|
|
231
|
+
verifyParams = { name: 'ECDSA', hash: scheme.hash };
|
|
232
|
+
sigBytes = ecdsaDerToRaw(cert.signature, table.curveOrderLen, cert.subject.text);
|
|
233
|
+
} else if (scheme.kind === 'rsa-pkcs1') {
|
|
234
|
+
// An RSASSA-PSS-restricted key must never validate PKCS#1 v1.5 signatures (RFC 4055 s1.2) —
|
|
235
|
+
// accepting cross-protocol use of one key is a known signature-confusion primitive.
|
|
236
|
+
if (issuerSpki.algorithmOid !== OID.rsaEncryption) {
|
|
237
|
+
throw mismatch('issuer key is not an rsaEncryption key');
|
|
238
|
+
}
|
|
239
|
+
const table = SIG_SCHEME_PARAMS[scheme.scheme];
|
|
240
|
+
importParams = table.import;
|
|
241
|
+
verifyParams = table.verify;
|
|
242
|
+
} else if (scheme.kind === 'rsa-pss') {
|
|
243
|
+
if (issuerSpki.algorithmOid !== OID.rsaEncryption && issuerSpki.algorithmOid !== OID.rsassaPss) {
|
|
244
|
+
throw mismatch('issuer key is not an RSA key');
|
|
245
|
+
}
|
|
246
|
+
const table = SIG_SCHEME_PARAMS[scheme.scheme];
|
|
247
|
+
importParams = table.import;
|
|
248
|
+
verifyParams = table.verify;
|
|
249
|
+
} else if (scheme.kind === 'ed25519') {
|
|
250
|
+
if (issuerSpki.algorithmOid !== OID.ed25519) throw mismatch('issuer key is not an Ed25519 key');
|
|
251
|
+
importParams = SIG_SCHEME_PARAMS[SIG_SCHEME.ed25519].import;
|
|
252
|
+
verifyParams = SIG_SCHEME_PARAMS[SIG_SCHEME.ed25519].verify;
|
|
253
|
+
} else {
|
|
254
|
+
throw mismatch(`unhandled scheme kind ${scheme.kind}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let ok = false;
|
|
258
|
+
try {
|
|
259
|
+
const key = await crypto.subtle.importKey('spki', issuerSpkiDer, importParams, false, ['verify']);
|
|
260
|
+
ok = await crypto.subtle.verify(verifyParams, key, sigBytes, cert.tbsBytes);
|
|
261
|
+
} catch (e) {
|
|
262
|
+
throw mismatch(`WebCrypto refused the key or signature (${e?.message ?? e})`);
|
|
263
|
+
}
|
|
264
|
+
if (!ok) {
|
|
265
|
+
throw new CertificateError(codes.CERT_SIGNATURE_INVALID,
|
|
266
|
+
`signature on "${cert.subject.text}" by "${issuerText}" (${scheme.name}) did not verify`,
|
|
267
|
+
{ subject: cert.subject.text, issuer: issuerText, scheme: scheme.name });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ------------------------------------------------------------------ path building
|
|
272
|
+
|
|
273
|
+
/** Key-identifier compatibility: a constraint only when both sides actually carry one. */
|
|
274
|
+
const kidCompatible = (childAki, parentSki) => !childAki || !parentSki || equal(childAki, parentSki);
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Build the path leaf-to-anchor. Anchors are preferred at every step, so a self-signed (or
|
|
278
|
+
* cross-signed) root the server helpfully included is simply never reached. Where several
|
|
279
|
+
* same-named candidates exist (cross-signs, key rollovers), signatures disambiguate; a lone
|
|
280
|
+
* candidate is accepted structurally and verified in the validation pass, where failure produces
|
|
281
|
+
* the precise CERT_SIGNATURE_INVALID rather than a vague "no path".
|
|
282
|
+
*/
|
|
283
|
+
async function buildPath(certs, anchorSource, maxPathLength) {
|
|
284
|
+
const leaf = certs[0];
|
|
285
|
+
const path = [leaf];
|
|
286
|
+
const used = new Set([0]);
|
|
287
|
+
let current = leaf;
|
|
288
|
+
let sigFailure = null;
|
|
289
|
+
|
|
290
|
+
for (;;) {
|
|
291
|
+
if (path.length > maxPathLength) {
|
|
292
|
+
throw constraintError(
|
|
293
|
+
`certification path exceeded ${maxPathLength} certificates without reaching a trust anchor`,
|
|
294
|
+
{ maxPathLength });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 1) Trust anchors for the current issuer name. The anchor link is verified eagerly: it both
|
|
298
|
+
// selects among same-named anchors and lets a failed anchor fall back to a supplied
|
|
299
|
+
// cross-sign, which is how root rollovers actually deploy. Every anchor a source returns is
|
|
300
|
+
// re-normalised here — the bundled store hands back packed records whose name constraints
|
|
301
|
+
// are still raw bytes, and consuming those un-normalised would silently drop the
|
|
302
|
+
// constraints (found the hard way by the generator's integration test).
|
|
303
|
+
const anchorCandidates = (await anchorSource.forIssuer(current.issuer.bytes))
|
|
304
|
+
.map(normalizeAnchor)
|
|
305
|
+
.filter((a) => kidCompatible(current.authorityKeyIdentifier, a.subjectKeyIdentifier ?? null));
|
|
306
|
+
for (const anchor of anchorCandidates) {
|
|
307
|
+
try {
|
|
308
|
+
await verifySignedObject(current, anchor.spkiDer, anchor.subjectText ?? '<anchor>');
|
|
309
|
+
return { path, anchor, topVerified: true };
|
|
310
|
+
} catch (e) {
|
|
311
|
+
// Only "this particular key did not make this signature" keeps the search going; a weak
|
|
312
|
+
// or unsupported algorithm is a property of the child and no other parent can fix it.
|
|
313
|
+
if (e.code !== codes.CERT_SIGNATURE_INVALID) throw e;
|
|
314
|
+
sigFailure = e;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 2) Supplied certificates.
|
|
319
|
+
const candidates = [];
|
|
320
|
+
for (let i = 0; i < certs.length; i++) {
|
|
321
|
+
if (used.has(i)) continue;
|
|
322
|
+
const c = certs[i];
|
|
323
|
+
if (!equal(c.subject.bytes, current.issuer.bytes)) continue;
|
|
324
|
+
if (!kidCompatible(current.authorityKeyIdentifier, c.subjectKeyIdentifier)) continue;
|
|
325
|
+
if (equal(c.der, current.der)) continue; // a duplicate of current can only build a loop
|
|
326
|
+
candidates.push(i);
|
|
327
|
+
}
|
|
328
|
+
let chosen = -1;
|
|
329
|
+
if (candidates.length === 1) {
|
|
330
|
+
chosen = candidates[0];
|
|
331
|
+
} else if (candidates.length > 1) {
|
|
332
|
+
for (const i of candidates) {
|
|
333
|
+
try {
|
|
334
|
+
await verifySignedObject(current, certs[i].spki.spkiDer, certs[i].subject.text);
|
|
335
|
+
chosen = i;
|
|
336
|
+
break;
|
|
337
|
+
} catch (e) {
|
|
338
|
+
if (e.code !== codes.CERT_SIGNATURE_INVALID) throw e;
|
|
339
|
+
sigFailure = e;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (chosen === -1) {
|
|
344
|
+
// Dead end. Prefer the most specific story: a candidate existed but its key did not make
|
|
345
|
+
// the signature; the chain ends at an untrusted self-signed cert; or the issuer is simply
|
|
346
|
+
// absent from both the chain and the store.
|
|
347
|
+
if (sigFailure) throw sigFailure;
|
|
348
|
+
if (current.isSelfIssued) {
|
|
349
|
+
throw new CertificateError(codes.CERT_UNTRUSTED_ROOT,
|
|
350
|
+
`certification path ends at self-signed "${current.subject.text}", which is not a trust anchor`,
|
|
351
|
+
{ subject: current.subject.text });
|
|
352
|
+
}
|
|
353
|
+
throw new CertificateError(codes.CERT_CHAIN_INCOMPLETE,
|
|
354
|
+
`no certificate for issuer "${current.issuer.text}" of "${current.subject.text}" was ` +
|
|
355
|
+
'supplied, and no trust anchor has that subject',
|
|
356
|
+
{ subject: current.subject.text, issuer: current.issuer.text });
|
|
357
|
+
}
|
|
358
|
+
used.add(chosen);
|
|
359
|
+
path.push(certs[chosen]);
|
|
360
|
+
current = certs[chosen];
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ------------------------------------------------------------------ name-constraint state
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Constraint state as layers: each certificate that imposes permittedSubtrees adds one layer per
|
|
368
|
+
* name type, and a name is acceptable only if EVERY layer of its type covers it. Keeping layers
|
|
369
|
+
* instead of computing intersections is exactly RFC 5280's intersection semantics without the
|
|
370
|
+
* subtlety of intersecting suffix sets. Exclusions are a flat union — one hit anywhere rejects.
|
|
371
|
+
*/
|
|
372
|
+
function makeConstraintState(anchor) {
|
|
373
|
+
const state = {
|
|
374
|
+
dnsPermittedLayers: [], // {bases: string[], by: string}[]
|
|
375
|
+
ipPermittedLayers: [], // {bases: {addr, mask}[], by: string}[]
|
|
376
|
+
dnsExcluded: [], // {base: string, by: string}
|
|
377
|
+
ipExcluded: [], // {addr, mask, by}
|
|
378
|
+
};
|
|
379
|
+
if (anchor.nameConstraints) {
|
|
380
|
+
addConstraints(state, anchor.nameConstraints, anchor.subjectText ?? '<anchor>', true);
|
|
381
|
+
}
|
|
382
|
+
return state;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function addConstraints(state, nc, by, critical) {
|
|
386
|
+
const collect = (subtrees, which) => {
|
|
387
|
+
const dns = [];
|
|
388
|
+
const ip = [];
|
|
389
|
+
for (const t of subtrees) {
|
|
390
|
+
if (t.type === 'dns') dns.push(t.value);
|
|
391
|
+
else if (t.type === 'ip') ip.push({ addr: t.addr, mask: t.mask });
|
|
392
|
+
else if (critical) {
|
|
393
|
+
// RFC 5280 s4.2.1.10: constraint forms we cannot enforce (directoryName, rfc822Name,
|
|
394
|
+
// URI, otherName, or out-of-spec minimum/maximum) in a CRITICAL extension must reject
|
|
395
|
+
// the path — silently ignoring a constraint the CA insisted on is failing open.
|
|
396
|
+
throw constraintError(
|
|
397
|
+
`"${by}" imposes a ${which} name constraint of an unsupported type in a critical ` +
|
|
398
|
+
'nameConstraints extension; refusing to ignore it',
|
|
399
|
+
{ by, which });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return { dns, ip };
|
|
403
|
+
};
|
|
404
|
+
if (nc.permitted) {
|
|
405
|
+
const { dns, ip } = collect(nc.permitted, 'permitted');
|
|
406
|
+
if (dns.length) state.dnsPermittedLayers.push({ bases: dns, by });
|
|
407
|
+
if (ip.length) state.ipPermittedLayers.push({ bases: ip, by });
|
|
408
|
+
}
|
|
409
|
+
if (nc.excluded) {
|
|
410
|
+
const { dns, ip } = collect(nc.excluded, 'excluded');
|
|
411
|
+
for (const base of dns) state.dnsExcluded.push({ base, by });
|
|
412
|
+
for (const e of ip) state.ipExcluded.push({ ...e, by });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Check every SAN name of `cert` against the accumulated constraint state. */
|
|
417
|
+
function checkConstraints(state, cert) {
|
|
418
|
+
const subject = cert.subject.text;
|
|
419
|
+
for (const name of cert.subjectAltNames.dns) {
|
|
420
|
+
for (const layer of state.dnsPermittedLayers) {
|
|
421
|
+
if (!layer.bases.some((b) => dnsWithinSubtree(name, b))) {
|
|
422
|
+
throw constraintError(
|
|
423
|
+
`dNSName "${name}" of "${subject}" is outside the permitted subtrees imposed by ` +
|
|
424
|
+
`"${layer.by}" (permitted: ${layer.bases.join(', ')})`,
|
|
425
|
+
{ name, subject, by: layer.by });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
for (const ex of state.dnsExcluded) {
|
|
429
|
+
if (dnsWithinSubtree(name, ex.base)) {
|
|
430
|
+
throw constraintError(
|
|
431
|
+
`dNSName "${name}" of "${subject}" is inside the subtree "${ex.base}" excluded by "${ex.by}"`,
|
|
432
|
+
{ name, subject, by: ex.by });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
for (const ip of cert.subjectAltNames.ip) {
|
|
437
|
+
const shown = toHex(ip);
|
|
438
|
+
for (const layer of state.ipPermittedLayers) {
|
|
439
|
+
if (!layer.bases.some((b) => ipWithinSubtree(ip, b.addr, b.mask))) {
|
|
440
|
+
throw constraintError(
|
|
441
|
+
`iPAddress ${shown} of "${subject}" is outside the permitted subtrees imposed by "${layer.by}"`,
|
|
442
|
+
{ subject, by: layer.by });
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
for (const ex of state.ipExcluded) {
|
|
446
|
+
if (ipWithinSubtree(ip, ex.addr, ex.mask)) {
|
|
447
|
+
throw constraintError(
|
|
448
|
+
`iPAddress ${shown} of "${subject}" is inside a subtree excluded by "${ex.by}"`,
|
|
449
|
+
{ subject, by: ex.by });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ------------------------------------------------------------------ validation
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Build and validate a certification path. Every failure throws a typed CertificateError;
|
|
459
|
+
* there is no boolean to forget to check.
|
|
460
|
+
*
|
|
461
|
+
* @param {object} opts
|
|
462
|
+
* @param {Array<Uint8Array | import('./x509.js').Certificate>} opts.chain DER (or
|
|
463
|
+
* already-parsed) certificates, leaf first, in whatever order and with whatever extras the
|
|
464
|
+
* server chose to send
|
|
465
|
+
* @param {AnchorLike[] | AnchorSource} opts.anchors trust anchors, or an indexed anchor source
|
|
466
|
+
* @param {string | null} [opts.hostname] identity to require of the leaf; omit to skip
|
|
467
|
+
* (index.js never omits)
|
|
468
|
+
* @param {number} [opts.now] epoch ms
|
|
469
|
+
* @param {number} [opts.maxPathLength] hard cap on path certificates — this runs on a metered
|
|
470
|
+
* runtime and a pathological chain must cost O(cap), not O(chain²)
|
|
471
|
+
* @returns {Promise<{ leaf: import('./x509.js').Certificate,
|
|
472
|
+
* path: import('./x509.js').Certificate[], anchor: TrustAnchor }>} parsed leaf, the
|
|
473
|
+
* validated path (leaf first), and the anchor that terminated it
|
|
474
|
+
*/
|
|
475
|
+
export async function validatePath(
|
|
476
|
+
{ chain, anchors, hostname = null, now = Date.now(), maxPathLength = 10 },
|
|
477
|
+
) {
|
|
478
|
+
if (!Array.isArray(chain) || chain.length === 0) {
|
|
479
|
+
throw new CertificateError(codes.CERT_CHAIN_INCOMPLETE, 'the peer supplied no certificates');
|
|
480
|
+
}
|
|
481
|
+
const certs = chain.map((c, i) => {
|
|
482
|
+
if (c instanceof Uint8Array) return parseCertificate(c);
|
|
483
|
+
if (c && c.tbsBytes) return c;
|
|
484
|
+
throw new ConfigError(codes.CONFIG_INVALID, `chain[${i}] is neither DER nor a parsed certificate`);
|
|
485
|
+
});
|
|
486
|
+
const anchorSource = toAnchorSource(anchors);
|
|
487
|
+
const { path, anchor, topVerified } = await buildPath(certs, anchorSource, maxPathLength);
|
|
488
|
+
|
|
489
|
+
const state = makeConstraintState(anchor);
|
|
490
|
+
// RFC 5280 s6.1.4 (l): the working constraint starts at the path length; each non-self-issued
|
|
491
|
+
// intermediate consumes one slot, and any certificate may lower — never raise — the remainder.
|
|
492
|
+
let pathLenRemaining = { value: maxPathLength, by: null };
|
|
493
|
+
|
|
494
|
+
// Process from the certificate under the anchor down to the leaf, as s6.1.3/6.1.4 do: state
|
|
495
|
+
// (name constraints, path length) flows downward.
|
|
496
|
+
for (let j = path.length - 1; j >= 0; j--) {
|
|
497
|
+
const cert = path[j];
|
|
498
|
+
const isLeaf = j === 0;
|
|
499
|
+
const subject = cert.subject.text;
|
|
500
|
+
|
|
501
|
+
// RFC 5280 s6.1: an unrecognised critical extension anywhere on the path is a hard stop.
|
|
502
|
+
// This check runs before any use of the certificate — an extension we cannot read may change
|
|
503
|
+
// the meaning of everything we can.
|
|
504
|
+
if (cert.unknownCriticalExtensions.length > 0) {
|
|
505
|
+
throw constraintError(
|
|
506
|
+
`certificate "${subject}" carries unrecognised critical extension(s) ` +
|
|
507
|
+
`${cert.unknownCriticalExtensions.join(', ')}; RFC 5280 s6.1 requires rejection`,
|
|
508
|
+
{ subject, oids: [...cert.unknownCriticalExtensions] });
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const parent = j === path.length - 1 ? null : path[j + 1];
|
|
512
|
+
if (!(topVerified && parent === null)) {
|
|
513
|
+
await verifySignedObject(
|
|
514
|
+
cert,
|
|
515
|
+
parent ? parent.spki.spkiDer : anchor.spkiDer,
|
|
516
|
+
parent ? parent.subject.text : (anchor.subjectText ?? '<anchor>'),
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (now < cert.notBefore) {
|
|
521
|
+
throw new CertificateError(codes.CERT_NOT_YET_VALID,
|
|
522
|
+
`certificate "${subject}" is not valid until ${new Date(cert.notBefore).toISOString()} ` +
|
|
523
|
+
`(now ${new Date(now).toISOString()})`,
|
|
524
|
+
{ subject, notBefore: cert.notBefore, now });
|
|
525
|
+
}
|
|
526
|
+
if (now > cert.notAfter) {
|
|
527
|
+
throw new CertificateError(codes.CERT_EXPIRED,
|
|
528
|
+
`certificate "${subject}" expired ${new Date(cert.notAfter).toISOString()} ` +
|
|
529
|
+
`(now ${new Date(now).toISOString()})`,
|
|
530
|
+
{ subject, notAfter: cert.notAfter, now });
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Name constraints bind every certificate below the imposer. Self-issued intermediates are
|
|
534
|
+
// exempt (s6.1.3 (b)) — they re-certify the same CA, not a new name — but the leaf never is.
|
|
535
|
+
if (isLeaf || !cert.isSelfIssued) checkConstraints(state, cert);
|
|
536
|
+
|
|
537
|
+
if (!isLeaf) {
|
|
538
|
+
// s6.1.4 (k): every intermediate must be a v3 CA. A certificate without basicConstraints
|
|
539
|
+
// (v1 certs included) never gets to issue — "it is old" is not a capability grant.
|
|
540
|
+
if (!cert.basicConstraints.present || !cert.basicConstraints.ca) {
|
|
541
|
+
throw constraintError(
|
|
542
|
+
`certificate "${subject}" signed others but ` +
|
|
543
|
+
(cert.basicConstraints.present
|
|
544
|
+
? 'its basicConstraints do not assert cA'
|
|
545
|
+
: 'has no basicConstraints extension'),
|
|
546
|
+
{ subject });
|
|
547
|
+
}
|
|
548
|
+
// s6.1.4 (n): a CA that carries keyUsage must include keyCertSign.
|
|
549
|
+
if (cert.keyUsage && !cert.keyUsage.keyCertSign) {
|
|
550
|
+
throw constraintError(
|
|
551
|
+
`certificate "${subject}" signed others but its keyUsage lacks keyCertSign`, { subject });
|
|
552
|
+
}
|
|
553
|
+
// s6.1.4 (l)/(m): path length accounting. Self-issued intermediates do not consume a slot.
|
|
554
|
+
if (!cert.isSelfIssued) {
|
|
555
|
+
if (pathLenRemaining.value <= 0) {
|
|
556
|
+
throw constraintError(
|
|
557
|
+
`pathLenConstraint ${pathLenRemaining.limit} imposed by "${pathLenRemaining.by}" ` +
|
|
558
|
+
`does not allow "${subject}" to appear as a further intermediate`,
|
|
559
|
+
{ subject, by: pathLenRemaining.by });
|
|
560
|
+
}
|
|
561
|
+
pathLenRemaining = { ...pathLenRemaining, value: pathLenRemaining.value - 1 };
|
|
562
|
+
}
|
|
563
|
+
const pl = cert.basicConstraints.pathLenConstraint;
|
|
564
|
+
if (pl !== null && pl < pathLenRemaining.value) {
|
|
565
|
+
pathLenRemaining = { value: pl, limit: pl, by: subject };
|
|
566
|
+
}
|
|
567
|
+
if (cert.nameConstraints) {
|
|
568
|
+
addConstraints(state, cert.nameConstraints, subject,
|
|
569
|
+
cert.extensions.get(OID.nameConstraints)?.critical ?? false);
|
|
570
|
+
}
|
|
571
|
+
} else {
|
|
572
|
+
// End-entity role checks. A leaf that is also a CA is legal (s6.1 has no prohibition);
|
|
573
|
+
// what matters is that its stated purposes include TLS server authentication.
|
|
574
|
+
if (cert.extendedKeyUsage &&
|
|
575
|
+
!cert.extendedKeyUsage.includes(OID.serverAuth) &&
|
|
576
|
+
!cert.extendedKeyUsage.includes(OID.anyExtendedKeyUsage)) {
|
|
577
|
+
throw constraintError(
|
|
578
|
+
`certificate "${subject}" has an extendedKeyUsage (${cert.extendedKeyUsage.join(', ')}) ` +
|
|
579
|
+
'that does not include serverAuth or anyExtendedKeyUsage',
|
|
580
|
+
{ subject, eku: [...cert.extendedKeyUsage] });
|
|
581
|
+
}
|
|
582
|
+
// Every key-exchange this package negotiates (TLS 1.3, TLS 1.2 ECDHE) authenticates the
|
|
583
|
+
// server with a handshake signature, which keyUsage must permit when present.
|
|
584
|
+
if (cert.keyUsage && !cert.keyUsage.digitalSignature) {
|
|
585
|
+
throw constraintError(
|
|
586
|
+
`certificate "${subject}" has a keyUsage without digitalSignature, which every ` +
|
|
587
|
+
'supported key exchange requires',
|
|
588
|
+
{ subject });
|
|
589
|
+
}
|
|
590
|
+
if (hostname !== null) matchesIdentity(cert, hostname);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return { leaf: path[0], path, anchor };
|
|
595
|
+
}
|