@blamejs/pki 0.1.15 → 0.1.16

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.
@@ -0,0 +1,1393 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+
5
+ /**
6
+ * @module pki.path
7
+ * @nav Path validation
8
+ * @title Certification path validation (RFC 5280 6)
9
+ * @intro
10
+ * RFC 5280 6 certification-path validation as a pure, re-entrant algorithm
11
+ * over already-parsed certificates. `pki.path.validate(path, opts)` runs the
12
+ * 6.1 state machine — signature chaining, validity windows, name chaining,
13
+ * basic constraints and path length, key usage, name constraints, and the
14
+ * certificate-policy tree — and returns a structured verdict with a per-check
15
+ * reason code for every step. Validity-window enforcement is always on, with
16
+ * the check date an explicit input; the trust anchor is an input, never one of
17
+ * the validated certificates, and no input object is mutated.
18
+ *
19
+ * Revocation is a pluggable hook: `pki.path.crlChecker(crls)` ships a CRL
20
+ * consultation built on `pki.schema.crl.parse`; an OCSP checker satisfies the
21
+ * same interface. Signature verification derives its algorithm from the
22
+ * certificate and the issuer key — never from a value the message controls —
23
+ * and fails closed on an unknown critical extension, an undetermined
24
+ * revocation status, or any structural fault.
25
+ *
26
+ * @card
27
+ * RFC 5280 6 certification-path validation — run the 6.1 state machine over
28
+ * an ordered path and a trust anchor for a structured, fail-closed verdict
29
+ * with per-check reason codes. Pure and re-entrant.
30
+ */
31
+
32
+ var webcrypto = require("./webcrypto");
33
+ var pkix = require("./schema-pkix");
34
+ var oid = require("./oid");
35
+ var errors = require("./framework-error");
36
+ var asn1 = require("./asn1-der");
37
+ var x509 = require("./schema-x509");
38
+ var crl = require("./schema-crl");
39
+ var constants = require("./constants");
40
+
41
+ var PathError = errors.PathError;
42
+ function E(code, message, cause) { return new PathError(code, message, cause); }
43
+ // Every code placed into the public validate() verdict must be a path/* code.
44
+ // A direct DER decode (resolveDescriptor's asn1.decode, run outside the ns-
45
+ // wrapping schema engine) can throw a raw asn1/* Asn1Error; normalize any
46
+ // non-path error code to the given path/* fallback so an internal domain code
47
+ // never leaks into the documented verdict. The original error is kept as `error`.
48
+ function pathCode(e, fallback) {
49
+ return (e && typeof e.code === "string" && e.code.indexOf("path/") === 0) ? e.code : fallback;
50
+ }
51
+
52
+ var NS = pkix.makeNS("path", PathError, oid);
53
+ var EXT_DECODERS = pkix.certExtensionDecoders(NS).byOid;
54
+
55
+ var subtle = webcrypto.webcrypto.subtle;
56
+
57
+ var OID = {
58
+ basicConstraints: oid.byName("basicConstraints"),
59
+ keyUsage: oid.byName("keyUsage"),
60
+ nameConstraints: oid.byName("nameConstraints"),
61
+ certificatePolicies: oid.byName("certificatePolicies"),
62
+ policyMappings: oid.byName("policyMappings"),
63
+ policyConstraints: oid.byName("policyConstraints"),
64
+ inhibitAnyPolicy: oid.byName("inhibitAnyPolicy"),
65
+ subjectAltName: oid.byName("subjectAltName"),
66
+ anyPolicy: oid.byName("anyPolicy"),
67
+ emailAddress: oid.byName("emailAddress"),
68
+ };
69
+
70
+ // The set of extension OIDs the validator PROCESSES — an unrecognized critical
71
+ // extension outside this set fails the path (6.1.4(o), 6.1.5(e)).
72
+ var PROCESSED_EXTENSIONS = {};
73
+ [OID.basicConstraints, OID.keyUsage, OID.nameConstraints, OID.certificatePolicies,
74
+ OID.policyMappings, OID.policyConstraints, OID.inhibitAnyPolicy, OID.subjectAltName].
75
+ forEach(function (o) { PROCESSED_EXTENSIONS[o] = true; });
76
+
77
+ // ---- signature verify bridge (NEW 6) ---------------------------------------
78
+
79
+ // Signature-algorithm OID -> the WebCrypto verify descriptor + how to import
80
+ // the issuer SPKI. Keyed via oid.byName so no dotted-decimal OID literal
81
+ // appears in source (the registry owns arc↔name). The algorithm is a property
82
+ // of the CERTIFICATE and the issuer key, never of a message-selected field
83
+ // (CVE-2015-9235).
84
+ var SIG_ALGS = {};
85
+ // `params` is the REQUIRED AlgorithmIdentifier parameters shape: "null" (a DER
86
+ // NULL must be present — RSASSA-PKCS1-v1_5, RFC 4055 §5) or "absent" (parameters
87
+ // must be omitted — ECDSA/EdDSA/ML-DSA, RFC 5758/8410). A cert deviating from
88
+ // its algorithm's required shape is malformed and rejected before verify.
89
+ function _sig(name, verify, imp, params, ecdsa) {
90
+ var entry = { verify: verify, imp: imp, params: params };
91
+ if (ecdsa) entry.ecdsa = true;
92
+ SIG_ALGS[oid.byName(name)] = entry;
93
+ }
94
+ // RSASSA-PKCS1-v1_5 — parameters MUST be NULL.
95
+ _sig("sha256WithRSAEncryption", { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, "null");
96
+ _sig("sha384WithRSAEncryption", { name: "RSASSA-PKCS1-v1_5", hash: "SHA-384" }, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-384" }, "null");
97
+ _sig("sha512WithRSAEncryption", { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }, "null");
98
+ // ECDSA (hash in the OID; the curve comes from the imported key) — params absent.
99
+ _sig("ecdsaWithSHA256", { name: "ECDSA", hash: "SHA-256" }, { name: "ECDSA" }, "absent", true);
100
+ _sig("ecdsaWithSHA384", { name: "ECDSA", hash: "SHA-384" }, { name: "ECDSA" }, "absent", true);
101
+ _sig("ecdsaWithSHA512", { name: "ECDSA", hash: "SHA-512" }, { name: "ECDSA" }, "absent", true);
102
+ // EdDSA (one-shot, no hash parameter) — params absent.
103
+ _sig("Ed25519", { name: "Ed25519" }, { name: "Ed25519" }, "absent");
104
+ _sig("Ed448", { name: "Ed448" }, { name: "Ed448" }, "absent");
105
+ // ML-DSA (FIPS 204) — params absent.
106
+ _sig("id-ml-dsa-44", { name: "ML-DSA-44" }, { name: "ML-DSA-44" }, "absent");
107
+ _sig("id-ml-dsa-65", { name: "ML-DSA-65" }, { name: "ML-DSA-65" }, "absent");
108
+ _sig("id-ml-dsa-87", { name: "ML-DSA-87" }, { name: "ML-DSA-87" }, "absent");
109
+
110
+ // RSASSA-PSS resolves its hash + salt from the AlgorithmIdentifier parameters.
111
+ var OID_RSA_PSS = oid.byName("rsassaPss");
112
+ var OID_MGF1 = oid.byName("mgf1");
113
+ // SHA-1 is deliberately ABSENT — a SHA-1 signature (PKCS#1 or PSS) is rejected,
114
+ // matching the no-sha1WithRSAEncryption posture (SHAttered chosen-prefix).
115
+ var HASH_BY_OID = {};
116
+ HASH_BY_OID[oid.byName("sha256")] = "SHA-256";
117
+ HASH_BY_OID[oid.byName("sha384")] = "SHA-384";
118
+ HASH_BY_OID[oid.byName("sha512")] = "SHA-512";
119
+
120
+ var CURVE_FIELD_BYTES = { "P-256": 32, "P-384": 48, "P-521": 66 };
121
+ // Curve group orders n — a valid ECDSA signature has r,s in [1, n-1].
122
+ var CURVE_ORDER = {
123
+ "P-256": BigInt("0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551"),
124
+ "P-384": BigInt("0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFC7634D81F4372DDF581A0DB248B0A77AECEC196ACCC52973"),
125
+ "P-521": BigInt("0x01FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFA51868783BF2F966B7FCC0148F709A5D03BB5C9B8899C47AEBB6FB71E91386409"),
126
+ };
127
+
128
+ // The algorithm OID of an AlgorithmIdentifier SEQUENCE { algorithm OID,
129
+ // parameters OPTIONAL }. STRICT: a universal SEQUENCE with the OID and AT MOST
130
+ // one optional parameters element — a bare [n]-wrapped OID (no SEQUENCE) or a
131
+ // SEQUENCE carrying a spurious third element is malformed and must not be read
132
+ // leniently as its named algorithm.
133
+ function seqAlgOid(seq) {
134
+ if (!seq || seq.tagClass !== "universal" || seq.tagNumber !== asn1.TAGS.SEQUENCE || !seq.children || seq.children.length < 1 || seq.children.length > 2) {
135
+ throw E("path/unsupported-algorithm", "expected an AlgorithmIdentifier SEQUENCE { OID, parameters? }");
136
+ }
137
+ return asn1.read.oid(seq.children[0]);
138
+ }
139
+ // A hash AlgorithmIdentifier { OID, parameters? } whose parameters, when present,
140
+ // MUST be DER NULL (RFC 4055 §2.1 / RFC 5754) — never a SEQUENCE or arbitrary
141
+ // value. Used for the PSS hashAlgorithm and the MGF1 inner hash.
142
+ function hashAlgOid(seq) {
143
+ var o = seqAlgOid(seq);
144
+ if (seq.children.length === 2) {
145
+ var p = seq.children[1];
146
+ if (p.tagClass !== "universal" || p.tagNumber !== asn1.TAGS.NULL) throw E("path/unsupported-algorithm", "hash AlgorithmIdentifier parameters must be NULL or absent (RFC 4055)");
147
+ }
148
+ return o;
149
+ }
150
+ // The hash OID inside an EXPLICIT [n] wrapper around a hash AlgorithmIdentifier.
151
+ function explicitHashAlgOid(wrapper) {
152
+ if (!wrapper.children || wrapper.children.length !== 1) throw E("path/unsupported-algorithm", "malformed EXPLICIT hash AlgorithmIdentifier");
153
+ return hashAlgOid(wrapper.children[0]);
154
+ }
155
+
156
+ function resolveRsaPss(paramsBytes) {
157
+ // RSASSA-PSS-params ::= SEQUENCE { hashAlgorithm [0] DEFAULT sha1,
158
+ // maskGenAlgorithm [1] DEFAULT mgf1SHA1, saltLength [2] INTEGER DEFAULT 20,
159
+ // trailerField [3] DEFAULT 1 }. WebCrypto verifies with MGF1 keyed to the
160
+ // SAME hash as the signature and trailerField 0xBC (1); any declared value
161
+ // that deviates cannot be honored, so it is REJECTED rather than verified
162
+ // under WebCrypto's defaults (a signatureAlgorithm bypass otherwise).
163
+ // RFC 4055 DEFAULTs are SHA-1 (hashAlgorithm and mgf1SHA1). Because SHA-1 is
164
+ // rejected, an absent hashAlgorithm or maskGenAlgorithm would resolve to SHA-1
165
+ // and must be REJECTED — a supported PSS AlgorithmIdentifier must state both
166
+ // explicitly, with the MGF1 hash matching the signature hash.
167
+ var hash = null, saltLength = 20, mgfNode = null, trailer = 1;
168
+ if (!paramsBytes) throw E("path/unsupported-algorithm", "RSASSA-PSS requires explicit parameters (the SHA-1 defaults are rejected)");
169
+ var n = asn1.decode(paramsBytes);
170
+ if (n.tagClass !== "universal" || n.tagNumber !== asn1.TAGS.SEQUENCE || !n.children) {
171
+ throw E("path/unsupported-algorithm", "RSASSA-PSS parameters must be an RSASSA-PSS-params SEQUENCE (RFC 4055)");
172
+ }
173
+ var pssLastTag = -1;
174
+ n.children.forEach(function (f) {
175
+ if (f.tagClass !== "context") throw E("path/unsupported-algorithm", "RSASSA-PSS-params fields must be context-tagged (RFC 4055)");
176
+ // Fields are the OPTIONAL [0..3], each at most once and in ascending DER
177
+ // order; an unknown or repeated/out-of-order tag is malformed.
178
+ if (f.tagNumber > 3 || f.tagNumber <= pssLastTag) throw E("path/unsupported-algorithm", "RSASSA-PSS-params has an unexpected, duplicate, or out-of-order field [" + f.tagNumber + "]");
179
+ pssLastTag = f.tagNumber;
180
+ // Every RSASSA-PSS-params field is an EXPLICIT [n] wrapper (constructed)
181
+ // around EXACTLY ONE value (an AlgorithmIdentifier or an INTEGER); a
182
+ // primitive/childless or multi-child context field is malformed — reading
183
+ // f.children[0] and ignoring the rest would accept non-DER parameters.
184
+ if (!f.children || f.children.length !== 1) throw E("path/unsupported-algorithm", "malformed RSASSA-PSS parameter field [" + f.tagNumber + "] (an EXPLICIT wrapper carries exactly one value)");
185
+ if (f.tagNumber === 0) {
186
+ var h = explicitHashAlgOid(f);
187
+ if (!HASH_BY_OID[h]) throw E("path/unsupported-algorithm", "unsupported RSASSA-PSS hash algorithm " + h);
188
+ hash = HASH_BY_OID[h];
189
+ } else if (f.tagNumber === 1) {
190
+ mgfNode = f.children[0]; // MaskGenAlgorithm SEQUENCE { mgf1, HashAlgorithm }
191
+ } else if (f.tagNumber === 2) {
192
+ var sl = asn1.read.integer(f.children[0]);
193
+ // A negative saltLength must be rejected: the OpenSSL-backed verify shim
194
+ // reads -1/-2/-3 as RSA_PSS_SALTLEN_DIGEST/AUTO/MAX, and AUTO (-2) accepts
195
+ // a signature of ANY salt length — defeating the salt-length binding.
196
+ if (sl < 0n) throw E("path/unsupported-algorithm", "RSASSA-PSS saltLength must be non-negative");
197
+ saltLength = Number(sl);
198
+ } else if (f.tagNumber === 3) {
199
+ trailer = Number(asn1.read.integer(f.children[0]));
200
+ }
201
+ });
202
+ if (hash === null) throw E("path/unsupported-algorithm", "RSASSA-PSS hashAlgorithm must be stated explicitly (the SHA-1 default is rejected)");
203
+ if (!mgfNode) throw E("path/unsupported-algorithm", "RSASSA-PSS maskGenAlgorithm must be stated explicitly (the mgf1SHA1 default is rejected)");
204
+ var mgfOid = seqAlgOid(mgfNode);
205
+ if (mgfOid !== OID_MGF1) throw E("path/unsupported-algorithm", "unsupported RSASSA-PSS mask-generation function " + mgfOid);
206
+ if (!mgfNode.children[1]) throw E("path/unsupported-algorithm", "RSASSA-PSS MGF1 requires an explicit hash parameter");
207
+ var mgfHashOid = hashAlgOid(mgfNode.children[1]);
208
+ if (HASH_BY_OID[mgfHashOid] !== hash) throw E("path/unsupported-algorithm", "RSASSA-PSS MGF1 hash must match the signature hash (RFC 4055)");
209
+ if (trailer !== 1) throw E("path/unsupported-algorithm", "unsupported RSASSA-PSS trailerField " + trailer);
210
+ return { verify: { name: "RSA-PSS", saltLength: saltLength }, imp: { name: "RSA-PSS", hash: hash } };
211
+ }
212
+
213
+ // A DER NULL parameters field is the 2-byte 05 00.
214
+ function isDerNull(p) { return p && p.length === 2 && p[0] === 0x05 && p[1] === 0x00; }
215
+
216
+ function resolveDescriptor(sigAlg) {
217
+ if (sigAlg.oid === OID_RSA_PSS) return resolveRsaPss(sigAlg.parameters);
218
+ var d = SIG_ALGS[sigAlg.oid];
219
+ if (!d) throw E("path/unsupported-algorithm", "no verify descriptor for signature algorithm " + (sigAlg.name || sigAlg.oid));
220
+ // The signatureAlgorithm's parameters MUST match the algorithm's fixed shape:
221
+ // RSASSA-PKCS1-v1_5 requires a NULL; ECDSA/EdDSA/ML-DSA require absence. A
222
+ // deviating AlgorithmIdentifier is malformed and must not verify.
223
+ var p = sigAlg.parameters;
224
+ if (d.params === "null" && !isDerNull(p)) throw E("path/unsupported-algorithm", "signature algorithm parameters must be NULL (RFC 4055)");
225
+ if (d.params === "absent" && p !== null && p !== undefined) throw E("path/unsupported-algorithm", "signature algorithm parameters must be absent (RFC 5758/8410)");
226
+ return d;
227
+ }
228
+
229
+ // DER Ecdsa-Sig-Value SEQUENCE { r, s } -> fixed-width r||s (P1363), rejecting
230
+ // r or s outside [1, n-1] (CVE-2022-21449 "Psychic Signatures").
231
+ function ecdsaDerToP1363(der, curve) {
232
+ var width = CURVE_FIELD_BYTES[curve];
233
+ var order = CURVE_ORDER[curve];
234
+ if (!width || !order) throw E("path/bad-signature", "unsupported ECDSA curve " + curve);
235
+ var n;
236
+ try { n = asn1.decode(der); }
237
+ catch (e) { throw E("path/bad-signature", "ECDSA signature is not a DER SEQUENCE(r,s)", e); }
238
+ if (n.tagClass !== "universal" || n.tagNumber !== asn1.TAGS.SEQUENCE || !n.children || n.children.length !== 2) {
239
+ throw E("path/bad-signature", "ECDSA signature must be a SEQUENCE of exactly two INTEGERs");
240
+ }
241
+ var r = asn1.read.integer(n.children[0]);
242
+ var s = asn1.read.integer(n.children[1]);
243
+ if (r < 1n || s < 1n || r >= order || s >= order) {
244
+ throw E("path/bad-signature", "ECDSA signature component out of range [1, n-1] (CVE-2022-21449)");
245
+ }
246
+ function pad(v) {
247
+ var hex = v.toString(16);
248
+ if (hex.length % 2) hex = "0" + hex;
249
+ var buf = Buffer.from(hex, "hex");
250
+ if (buf.length > width) throw E("path/bad-signature", "ECDSA signature component wider than the curve field");
251
+ var out = Buffer.alloc(width);
252
+ buf.copy(out, width - buf.length);
253
+ return out;
254
+ }
255
+ return Buffer.concat([pad(r), pad(s)]);
256
+ }
257
+
258
+ // Verify cert.signatureValue over cert.tbsBytes with the working public key.
259
+ function builtinVerify(state, cert) {
260
+ var d;
261
+ try { d = resolveDescriptor(cert.signatureAlgorithm); }
262
+ catch (e) { return Promise.resolve({ ok: false, code: pathCode(e, "path/unsupported-algorithm"), error: e }); }
263
+ // The signature is an octet-aligned BIT STRING (no unused bits) for every
264
+ // supported algorithm; a non-zero unused-bit count is malformed.
265
+ if (cert.signatureValue.unusedBits !== 0) return Promise.resolve({ ok: false, code: "path/bad-signature" });
266
+ var key;
267
+ return subtle.importKey("spki", state.workingPublicKey, d.imp, false, ["verify"]).then(function (k) {
268
+ key = k;
269
+ var sig = cert.signatureValue.bytes;
270
+ if (d.ecdsa) sig = ecdsaDerToP1363(sig, key.algorithm.namedCurve);
271
+ return subtle.verify(d.verify, key, sig, cert.tbsBytes);
272
+ }).then(function (ok) {
273
+ return { ok: ok === true };
274
+ }, function (e) {
275
+ // A raw OpenSSL / WebCrypto fault (wrong key type for the declared
276
+ // algorithm — the algorithm-confusion case) is a signature failure, not a
277
+ // path/* verdict of its own; only a PathError code is preserved.
278
+ return { ok: false, code: pathCode(e, "path/bad-signature"), error: e };
279
+ });
280
+ }
281
+
282
+ // ---- 7.1 name comparison ---------------------------------------------------
283
+
284
+ function normalizeAttrValue(v) {
285
+ // RFC 5280 7.1 canonical DN comparison: case-fold + collapse internal
286
+ // whitespace. Every standard X.520 DN attribute (CN/O/OU/L/ST/C/serialNumber/
287
+ // dnQualifier) uses caseIgnoreMatch, and this canonical form matches OpenSSL's
288
+ // X509_NAME_cmp — applying it uniformly is the interoperable behavior, not a
289
+ // per-attribute caseExact rule that would reject a chain OpenSSL accepts.
290
+ // Reject embedded NUL / control bytes so a truncation attack (CVE-2009-2408)
291
+ // cannot make two different names compare equal.
292
+ if (typeof v !== "string") return v;
293
+ for (var i = 0; i < v.length; i++) {
294
+ var c = v.charCodeAt(i);
295
+ if (c === 0 || (c < 0x20 && c !== 0x09)) throw E("path/name-chaining", "distinguished name contains an embedded control byte (CVE-2009-2408)");
296
+ }
297
+ return v.trim().replace(/\s+/g, " ").toLowerCase();
298
+ }
299
+
300
+ function rdnEqual(a, b) {
301
+ if (a.length !== b.length) return false;
302
+ // An RDN is an unordered SET of type/value pairs; compare as multisets.
303
+ var used = [];
304
+ for (var i = 0; i < a.length; i++) {
305
+ var found = false;
306
+ for (var j = 0; j < b.length; j++) {
307
+ if (used[j]) continue;
308
+ if (a[i].type === b[j].type && normalizeAttrValue(a[i].value) === normalizeAttrValue(b[j].value)) {
309
+ used[j] = true; found = true; break;
310
+ }
311
+ }
312
+ if (!found) return false;
313
+ }
314
+ return true;
315
+ }
316
+
317
+ function dnEqual(rdnsA, rdnsB) {
318
+ if (rdnsA.length !== rdnsB.length) return false;
319
+ for (var i = 0; i < rdnsA.length; i++) {
320
+ if (!rdnEqual(rdnsA[i], rdnsB[i])) return false;
321
+ }
322
+ return true;
323
+ }
324
+
325
+ // ---- extension access ------------------------------------------------------
326
+
327
+ function findExt(cert, extOid) {
328
+ for (var i = 0; i < cert.extensions.length; i++) {
329
+ if (cert.extensions[i].oid === extOid) return cert.extensions[i];
330
+ }
331
+ return null;
332
+ }
333
+
334
+ // Decode an extension value, mapping the typed decoder throw to a check.
335
+ function decodeExt(cert, extOid) {
336
+ var ext = findExt(cert, extOid);
337
+ if (!ext) return null;
338
+ var dec = EXT_DECODERS[extOid];
339
+ return { critical: ext.critical, value: dec(ext.value) };
340
+ }
341
+
342
+ // RFC 5280 requires several CA-scoped extensions to be marked critical:
343
+ // basicConstraints (4.2.1.9), nameConstraints (4.2.1.10),
344
+ // policyConstraints (4.2.1.11), inhibitAnyPolicy (4.2.1.14). A conforming
345
+ // validator rejects the non-critical form — an extension a non-supporting
346
+ // relying party would ignore must not silently pass here either. Returns a
347
+ // typed PathError when a PRESENT extension is not critical, else null.
348
+ function requireCriticalExt(ext, name, checks) {
349
+ if (ext && ext.critical !== true) {
350
+ checks.push({ name: name, ok: false, code: "path/extension-not-critical" });
351
+ return E("path/extension-not-critical", name + " extension must be marked critical (RFC 5280 4.2.1)");
352
+ }
353
+ return null;
354
+ }
355
+
356
+ // ---- name constraints ------------------------------------------------------
357
+
358
+ // Split an addr-spec into [localPart, host] at its single "@". Returns null when
359
+ // there is no "@" (a bare host/domain constraint), or "ambiguous" when there are
360
+ // multiple "@": a conformant certificate rfc822Name is a simple addr-spec with
361
+ // EXACTLY ONE "@" (RFC 5280 §4.2.1.6 deprecates the quoted local part, and an
362
+ // addr-spec domain never contains "@"), so a multi-"@" mailbox like
363
+ // "a@b"@example.com cannot be split reliably and must fail closed.
364
+ function splitMailbox(addr) {
365
+ var first = addr.indexOf("@");
366
+ if (first === -1) return null;
367
+ if (first !== addr.lastIndexOf("@")) return "ambiguous";
368
+ return [addr.slice(0, first), addr.slice(first + 1)];
369
+ }
370
+
371
+ function emailMatch(constraint, mailbox) {
372
+ // RFC 5280 §4.2.1.10 rfc822Name: a constraint with an "@" is a full mailbox;
373
+ // a leading "." is a domain matching mailboxes at a SUBDOMAIN; otherwise it is
374
+ // a host matching mailboxes AT that host only. RFC 5321: the local part is
375
+ // CASE-SENSITIVE (exact); only the host is compared case-insensitively.
376
+ var mb = splitMailbox(mailbox);
377
+ if (mb === "ambiguous") return "unsupported"; // multi-"@" mailbox -> fail closed
378
+ if (constraint.indexOf("@") !== -1) {
379
+ // Full-mailbox constraint: exact local part + case-insensitive host. The
380
+ // host is canonicalized like dNSName/URI (strip the absolute-FQDN root dot)
381
+ // so a trailing-dot mailbox cannot escape the constraint.
382
+ var cb = splitMailbox(constraint);
383
+ if (cb === "ambiguous" || cb === null || mb === null) return "unsupported";
384
+ return mb[0] === cb[0] && stripTrailingDot(mb[1].toLowerCase()) === stripTrailingDot(cb[1].toLowerCase());
385
+ }
386
+ // Host/domain constraint: compare the mailbox host case-insensitively, with the
387
+ // trailing FQDN root dot stripped on both sides (as hostConstraintMatch does)
388
+ // so "user@evil.com." does not slip a constraint on "evil.com".
389
+ if (mb === null) return "unsupported"; // no host -> cannot determine domain
390
+ var host = stripTrailingDot(mb[1].toLowerCase());
391
+ if (host === "") return "unsupported";
392
+ var c = stripTrailingDot(constraint.toLowerCase());
393
+ if (c.charAt(0) === ".") return host.length > c.length && host.slice(-c.length) === c;
394
+ return host === c;
395
+ }
396
+
397
+ // Strip a single trailing dot (the absolute-FQDN root label) so "evil.com."
398
+ // and "evil.com" compare equal — otherwise a trailing-dot SAN would escape a
399
+ // dNSName constraint.
400
+ function stripTrailingDot(s) { return s.charAt(s.length - 1) === "." ? s.slice(0, -1) : s; }
401
+
402
+ // Host-suffix match with the RFC 5280 §4.2.1.10 leading-period rule shared by
403
+ // dNSName and uniformResourceIdentifier constraints on a host.
404
+ function hostConstraintMatch(constraint, host) {
405
+ var c = stripTrailingDot(constraint.toLowerCase()), h = stripTrailingDot(host.toLowerCase());
406
+ if (c === "") return true;
407
+ if (c.charAt(0) === ".") return h.length > c.length && h.slice(-c.length) === c; // subdomain only
408
+ return h === c || (h.length > c.length && h.slice(-(c.length + 1)) === "." + c); // host + subdomains
409
+ }
410
+
411
+ // Is `host` a fully qualified domain name (as a URI host constraint requires,
412
+ // RFC 5280 §4.2.1.10)? A dotless single label (localhost), an IPv4/IPv6 literal,
413
+ // or a value carrying non-hostname characters (a scheme "://", a path "/", a
414
+ // port ":") is NOT a FQDN and cannot be matched against a domain-suffix
415
+ // constraint. Only [A-Za-z0-9.-] with at least one dot qualifies.
416
+ function isFqdnHost(host) {
417
+ var h = stripTrailingDot(host);
418
+ if (h === "" || h.indexOf(".") === -1) return false; // empty or single-label (localhost)
419
+ if (!/^[a-z0-9.-]+$/i.test(h)) return false; // scheme/path/port/IPv6 chars, "@", etc.
420
+ if (/^[0-9.]+$/.test(h)) return false; // IPv4 dotted-quad literal
421
+ return true;
422
+ }
423
+
424
+ // A URI constraint applies to the host part: a leading "." matches subdomains
425
+ // only; a bare host matches that host EXACTLY (not subdomains, per §4.2.1.10).
426
+ // BOTH sides must be a fully qualified domain name: a URI SAN with no host / an
427
+ // IP literal, OR a malformed constraint that is not an FQDN (e.g. a full URI
428
+ // "http://blocked.example" rather than "blocked.example"), cannot be evaluated
429
+ // and returns "unsupported" so the caller fails closed rather than letting the
430
+ // name silently escape a critical constraint by never matching.
431
+ function uriMatch(constraint, uri) {
432
+ var host = uriHost(uri);
433
+ if (host === null) return "unsupported";
434
+ if (!isFqdnHost(host)) return "unsupported";
435
+ var c = stripTrailingDot(constraint.toLowerCase()), h = stripTrailingDot(host.toLowerCase());
436
+ // Validate the constraint's own host form (strip a single leading "." domain marker).
437
+ if (!isFqdnHost(c.charAt(0) === "." ? c.slice(1) : c)) return "unsupported";
438
+ if (c.charAt(0) === ".") return h.length > c.length && h.slice(-c.length) === c;
439
+ return h === c;
440
+ }
441
+
442
+ function uriHost(uri) {
443
+ var m = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)/i.exec(uri);
444
+ if (!m) return null;
445
+ var authority = m[1];
446
+ // authority = [ userinfo "@" ] host [ ":" port ]. RFC 3986 userinfo does not
447
+ // contain a raw "@", so a conformant authority has AT MOST ONE "@". Multiple
448
+ // "@" is ambiguous — the host cannot be determined reliably — so fail CLOSED
449
+ // (null -> uriMatch returns "unsupported" -> the caller rejects the path)
450
+ // rather than guess a host that could slip a URI name constraint.
451
+ var firstAt = authority.indexOf("@");
452
+ if (firstAt !== authority.lastIndexOf("@")) return null;
453
+ if (firstAt !== -1) authority = authority.slice(firstAt + 1);
454
+ var host = authority.replace(/:\d+$/, "");
455
+ return host === "" ? null : host; // an empty authority cannot be evaluated -> unsupported
456
+ }
457
+
458
+ function ipMatch(constraint, addr) {
459
+ // constraint = address+mask (8 or 32 octets); addr = 4 or 16.
460
+ var half = constraint.length / 2;
461
+ if (addr.length !== half) return false;
462
+ for (var i = 0; i < half; i++) {
463
+ if ((addr[i] & constraint[half + i]) !== (constraint[i] & constraint[half + i])) return false;
464
+ }
465
+ return true;
466
+ }
467
+
468
+ // Does GeneralName-like {tagNumber, value} match a constraint base of the same
469
+ // form? Returns true/false for a supported form, null when the forms differ
470
+ // (not comparable), or the string "unsupported" when the forms are the SAME
471
+ // but this validator does not implement that form's comparison — the caller
472
+ // must then fail closed rather than treat it as "no match".
473
+ function nameMatchesConstraint(gnTag, gnValue, base) {
474
+ if (base.tagNumber !== gnTag) return null; // different form -> not comparable
475
+ switch (gnTag) {
476
+ case 1: return emailMatch(base.value, gnValue); // rfc822Name
477
+ case 2: return hostConstraintMatch(base.value, gnValue); // dNSName
478
+ case 6: return uriMatch(base.value, gnValue); // uniformResourceIdentifier
479
+ case 7: return ipMatch(base.value, gnValue); // iPAddress
480
+ case 4: return dnStartsWith(gnValue, base.value); // directoryName
481
+ default: return "unsupported"; // otherName / x400 / ediParty / registeredID
482
+ }
483
+ }
484
+
485
+ // directoryName constraint: the name DN must contain the constraint DN as an
486
+ // initial RDN sequence.
487
+ function dnStartsWith(nameDn, constraintDn) {
488
+ if (constraintDn.rdns.length > nameDn.rdns.length) return false;
489
+ for (var i = 0; i < constraintDn.rdns.length; i++) {
490
+ if (!rdnEqual(nameDn.rdns[i], constraintDn.rdns[i])) return false;
491
+ }
492
+ return true;
493
+ }
494
+
495
+ // Collect the name forms a cert presents for constraint checking: its SAN
496
+ // entries plus (per 4.2.1.10 / 6.1.3) an emailAddress in the subject DN as an
497
+ // rfc822Name, plus the subject DN itself as a directoryName.
498
+ function certNameForms(cert) {
499
+ var forms = [];
500
+ var san = decodeExt(cert, OID.subjectAltName);
501
+ var hasRfc822San = false;
502
+ if (san) {
503
+ // Preserve EVERY SAN entry, including a form whose value the validator does
504
+ // not decode (x400Address [3] / ediPartyName [5]) — dropping it would let a
505
+ // critical constraint of that same unsupported form pass unenforced. The
506
+ // constraint check fails such a form closed (name-constraint-unsupported).
507
+ san.value.names.forEach(function (nm) {
508
+ if (nm.tagNumber === 1) hasRfc822San = true; // an rfc822Name SAN carries the email identity
509
+ forms.push({ tag: nm.tagNumber, value: nm.value === undefined ? null : nm.value });
510
+ });
511
+ }
512
+ // RFC 5280 §4.2.1.10: the legacy emailAddress in the subject DN is checked as
513
+ // an rfc822Name UNLESS the SAN already carries the email identity as an
514
+ // rfc822Name entry. A SAN of a DIFFERENT form (e.g. dNSName only) does NOT
515
+ // cover the email, so the subject-DN email must still be constrained — else an
516
+ // excluded/non-permitted mailbox would slip an rfc822Name constraint.
517
+ if (!hasRfc822San) {
518
+ cert.subject.rdns.forEach(function (rdn) {
519
+ rdn.forEach(function (atv) {
520
+ if (atv.type === OID.emailAddress && typeof atv.value === "string") forms.push({ tag: 1, value: atv.value });
521
+ });
522
+ });
523
+ }
524
+ if (cert.subject.rdns.length > 0) forms.push({ tag: 4, value: cert.subject });
525
+ return forms;
526
+ }
527
+
528
+ // Check a cert's own names against the accumulated constraints. `excluded` is
529
+ // a UNION (any match rejects). `permitted` is the INTERSECTION of every
530
+ // absorbing cert's permittedSubtrees (6.1.4(g)): the permitted set is tracked
531
+ // as one GENERATION per absorbing cert, and a name of form F must match a
532
+ // subtree of form F in EVERY generation that constrains form F. A flat pool
533
+ // would compute the UNION — letting a subordinate CA BROADEN what its parent
534
+ // permitted (a name-constraint bypass).
535
+ function checkNameConstraints(state, cert) {
536
+ var forms = certNameForms(cert);
537
+ // Excluded: any match -> reject. A constraint of the same form the validator
538
+ // cannot compare ("unsupported") means a critical exclusion it cannot
539
+ // enforce -> fail closed rather than treat it as "no match".
540
+ for (var e = 0; e < state.excludedSubtrees.length; e++) {
541
+ var ex = state.excludedSubtrees[e];
542
+ for (var i = 0; i < forms.length; i++) {
543
+ var m = nameMatchesConstraint(forms[i].tag, forms[i].value, { tagNumber: ex.tag, value: ex.base });
544
+ if (m === true) return { ok: false, code: "path/name-constraint-excluded" };
545
+ if (m === "unsupported") return { ok: false, code: "path/name-constraint-unsupported" };
546
+ }
547
+ }
548
+ // Permitted: for each name form, every generation that constrains that form
549
+ // must admit it (intersection across generations). If the only subtrees of
550
+ // that form are unenforceable, the name cannot be confirmed permitted.
551
+ for (var f = 0; f < forms.length; f++) {
552
+ var nf = forms[f];
553
+ for (var g = 0; g < state.permittedGenerations.length; g++) {
554
+ var formSubtrees = state.permittedGenerations[g].filter(function (s) { return s.tag === nf.tag; });
555
+ if (!formSubtrees.length) continue; // this generation does not constrain the form
556
+ var permitted = false, unsupported = false;
557
+ formSubtrees.forEach(function (s) {
558
+ var r = nameMatchesConstraint(nf.tag, nf.value, { tagNumber: s.tag, value: s.base });
559
+ if (r === true) permitted = true;
560
+ else if (r === "unsupported") unsupported = true;
561
+ });
562
+ if (!permitted) return { ok: false, code: unsupported ? "path/name-constraint-unsupported" : "path/name-constraint-not-permitted" };
563
+ }
564
+ }
565
+ return { ok: true };
566
+ }
567
+
568
+ // Absorb a cert's nameConstraints (6.1.4(g)): permittedSubtrees becomes a new
569
+ // GENERATION (intersection is enforced at check time by requiring a match in
570
+ // every generation); excludedSubtrees union into the flat excluded pool.
571
+ function absorbNameConstraints(state, decoded) {
572
+ if (decoded.permittedSubtrees.length) {
573
+ state.permittedGenerations.push(decoded.permittedSubtrees.map(function (st) {
574
+ return { tag: st.base.tagNumber, base: st.base.value };
575
+ }));
576
+ }
577
+ decoded.excludedSubtrees.forEach(function (st) {
578
+ state.excludedSubtrees.push({ tag: st.base.tagNumber, base: st.base.value });
579
+ });
580
+ }
581
+
582
+ // ---- certificate-policy tree ----------------------------------------------
583
+
584
+ function rootNode() {
585
+ return { depth: 0, validPolicy: OID.anyPolicy, qualifierSet: [], expectedPolicySet: [OID.anyPolicy], children: [], parent: null };
586
+ }
587
+ // A deep copy of the valid-policy tree WITHOUT the internal `parent` back-pointer,
588
+ // so the structured verdict returned to callers is acyclic (JSON.stringify-safe).
589
+ // The `parent` link is an implementation detail of the 6.1.3 processing, not part
590
+ // of the RFC 5280 valid_policy_tree a consumer inspects.
591
+ function treeWithoutParent(node) {
592
+ if (!node) return null;
593
+ return {
594
+ depth: node.depth,
595
+ validPolicy: node.validPolicy,
596
+ qualifierSet: node.qualifierSet,
597
+ expectedPolicySet: node.expectedPolicySet,
598
+ children: node.children.map(treeWithoutParent),
599
+ };
600
+ }
601
+ function leavesAt(tree, depth) {
602
+ var out = [];
603
+ if (!tree) return out; // a pruned-empty tree has no nodes
604
+ (function walk(node) {
605
+ if (node.depth === depth) { out.push(node); return; }
606
+ node.children.forEach(walk);
607
+ })(tree);
608
+ return out;
609
+ }
610
+ function pruneChildless(tree, depth) {
611
+ // Delete depth-`depth` nodes with no children, then propagate upward.
612
+ for (var d = depth; d > 0; d--) {
613
+ leavesAt(tree, d).forEach(function (node) {
614
+ if (node.children.length === 0 && node.parent) {
615
+ var idx = node.parent.children.indexOf(node);
616
+ if (idx !== -1) node.parent.children.splice(idx, 1);
617
+ }
618
+ });
619
+ }
620
+ }
621
+ function treeIsEmpty(tree) { return tree.children.length === 0; }
622
+
623
+ // ---- the state machine -----------------------------------------------------
624
+
625
+ function initialize(certs, params) {
626
+ var n = certs.length;
627
+ return {
628
+ validPolicyTree: rootNode(),
629
+ policyNodeCount: 1,
630
+ maxPolicyNodes: params.maxPolicyNodes || 4096,
631
+ // Each absorbing cert's permittedSubtrees is one generation; a name must be
632
+ // admitted by EVERY generation (intersection). An initial seed is generation 0.
633
+ permittedGenerations: (params.initialPermittedSubtrees && params.initialPermittedSubtrees.length) ? [params.initialPermittedSubtrees.slice()] : [],
634
+ excludedSubtrees: (params.initialExcludedSubtrees || []).slice(),
635
+ explicitPolicy: params.initialExplicitPolicy ? 0 : n + 1,
636
+ inhibitAnyPolicy: params.initialAnyPolicyInhibit ? 0 : n + 1,
637
+ policyMapping: params.initialPolicyMappingInhibit ? 0 : n + 1,
638
+ workingPublicKeyAlgorithm: params.trustAnchor.algorithm,
639
+ workingPublicKey: params.trustAnchor.publicKey,
640
+ workingPublicKeyParameters: params.trustAnchor.parameters || null,
641
+ workingIssuerName: params.trustAnchor.name,
642
+ maxPathLength: n,
643
+ userInitialPolicySet: params.userInitialPolicySet || [OID.anyPolicy],
644
+ results: [],
645
+ };
646
+ }
647
+
648
+ // self-issued = subject DN equals issuer DN. dnEqual throws on a NUL/control
649
+ // DN (CVE-2009-2408); a malformed-DN cert is never "self-issued" (and is failed
650
+ // by the name-chaining check), so swallow the throw rather than reject the
651
+ // whole validate() promise from these unwrapped call sites.
652
+ function selfIssued(cert) {
653
+ try { return dnEqual(cert.subject.rdns, cert.issuer.rdns); }
654
+ catch (_e) { return false; }
655
+ }
656
+
657
+ function processPolicies(state, cert, i, checks) {
658
+ var cp;
659
+ try { cp = decodeExt(cert, OID.certificatePolicies); }
660
+ catch (e) { checks.push({ name: "policies", ok: false, code: "path/bad-policy" }); return { fatal: true, error: e }; }
661
+
662
+ if (cp && state.validPolicyTree) {
663
+ var policies = cp.value;
664
+ var depth = i - 1;
665
+ // anyPolicy processing is active only while inhibit_anyPolicy > 0, or for a
666
+ // self-issued non-final cert (6.1.3(d)) — this gates BOTH the (d)(1)(ii)
667
+ // child-from-anyPolicy fallback and the (d)(2) expansion.
668
+ var anyPolicyActive = state.inhibitAnyPolicy > 0 || (i < state._n && selfIssued(cert));
669
+ var anyPolicyPresent = false;
670
+ policies.forEach(function (p) {
671
+ if (p.policyIdentifier === OID.anyPolicy) { anyPolicyPresent = true; return; }
672
+ var matched = false;
673
+ leavesAt(state.validPolicyTree, depth).forEach(function (node) {
674
+ if (node.expectedPolicySet.indexOf(p.policyIdentifier) !== -1) {
675
+ addChild(state, node, p.policyIdentifier, p.qualifiersBytes, [p.policyIdentifier], checks);
676
+ matched = true;
677
+ }
678
+ });
679
+ if (!matched && anyPolicyActive) {
680
+ // 6.1.3(d)(1)(ii): create the node from a depth-(i-1) anyPolicy node —
681
+ // ONLY while anyPolicy processing is active (else a leaf policy would
682
+ // wrongly survive after inhibit_anyPolicy reached 0).
683
+ leavesAt(state.validPolicyTree, depth).forEach(function (node) {
684
+ if (node.validPolicy === OID.anyPolicy) addChild(state, node, p.policyIdentifier, p.qualifiersBytes, [p.policyIdentifier], checks);
685
+ });
686
+ }
687
+ });
688
+ if (anyPolicyPresent && anyPolicyActive) {
689
+ // 6.1.3(d)(2): expand anyPolicy into unmatched expected-policy values.
690
+ leavesAt(state.validPolicyTree, depth).forEach(function (node) {
691
+ node.expectedPolicySet.forEach(function (ep) {
692
+ var already = node.children.some(function (ch) { return ch.validPolicy === ep; });
693
+ if (!already) addChild(state, node, ep, null, [ep], checks);
694
+ });
695
+ });
696
+ }
697
+ if (state._capHit) return { fatal: true, error: E("path/policy-tree-cap", "policy tree exceeded the node cap") };
698
+ pruneChildless(state.validPolicyTree, depth);
699
+ if (treeIsEmpty(state.validPolicyTree)) state.validPolicyTree = null;
700
+ } else if (!cp) {
701
+ state.validPolicyTree = null;
702
+ }
703
+
704
+ // 6.1.3(f): interim check.
705
+ if (!(state.explicitPolicy > 0 || state.validPolicyTree !== null)) {
706
+ checks.push({ name: "policy", ok: false, code: "path/policy-required" });
707
+ return { fatal: true, error: E("path/policy-required", "explicit policy required but the valid-policy tree is empty") };
708
+ }
709
+ return { fatal: false };
710
+ }
711
+
712
+ function addChild(state, parent, validPolicy, qualifiers, expectedPolicySet, checks) {
713
+ if (state.policyNodeCount >= state.maxPolicyNodes) { state._capHit = true; return; }
714
+ var node = { depth: parent.depth + 1, validPolicy: validPolicy, qualifierSet: qualifiers ? [qualifiers] : [], expectedPolicySet: expectedPolicySet, children: [], parent: parent };
715
+ parent.children.push(node);
716
+ state.policyNodeCount++;
717
+ void checks;
718
+ }
719
+
720
+ // A DER explicit NULL (05 00) as an AlgorithmIdentifier parameters field is
721
+ // treated identically to omitted parameters (RFC 5280 6.1.4(e)).
722
+ function isNullOrAbsentParams(p) {
723
+ return p === null || p === undefined || (p.length === 2 && p[0] === 0x05 && p[1] === 0x00);
724
+ }
725
+
726
+ // Rebuild a SubjectPublicKeyInfo with the given AlgorithmIdentifier parameters
727
+ // spliced in, so a key that inherited its domain parameters (an EC public key
728
+ // whose SPKI omits the namedCurve, RFC 5280 6.1.4(f)/6.1.5(d)) becomes a
729
+ // self-contained SPKI that importKey("spki", …) can consume.
730
+ function spliceSpkiParameters(spki, algOid, paramsBytes) {
731
+ return asn1.build.sequence([
732
+ asn1.build.sequence([asn1.build.oid(algOid), asn1.build.raw(paramsBytes)]),
733
+ asn1.build.bitString(spki.publicKey.bytes, spki.publicKey.unusedBits),
734
+ ]);
735
+ }
736
+
737
+ // RFC 5280 6.1.4(d,e,f) / 6.1.5(c,d): set working_public_key / _algorithm /
738
+ // _parameters from a certificate. Present non-null parameters are copied;
739
+ // NULL-or-absent parameters inherit the prior parameters iff the key algorithm
740
+ // is unchanged, else clear them.
741
+ function updateWorkingKey(state, cert) {
742
+ var keyAlg = cert.subjectPublicKeyInfo.algorithm;
743
+ if (!isNullOrAbsentParams(keyAlg.parameters)) {
744
+ state.workingPublicKeyParameters = keyAlg.parameters;
745
+ } else if (keyAlg.oid !== state.workingPublicKeyAlgorithm) {
746
+ state.workingPublicKeyParameters = null;
747
+ }
748
+ // When this cert's SPKI omits its algorithm parameters but the working set
749
+ // carries inherited ones, store a reconstructed SPKI so the next signature
750
+ // verify can import a complete key rather than failing on the bare bytes.
751
+ if (isNullOrAbsentParams(keyAlg.parameters) && state.workingPublicKeyParameters) {
752
+ state.workingPublicKey = spliceSpkiParameters(cert.subjectPublicKeyInfo, keyAlg.oid, state.workingPublicKeyParameters);
753
+ } else {
754
+ state.workingPublicKey = cert.subjectPublicKeyInfo.bytes;
755
+ }
756
+ state.workingPublicKeyAlgorithm = keyAlg.oid;
757
+ }
758
+
759
+ function prepareNext(state, cert, i, checks) {
760
+ var isSelfIssued = selfIssued(cert);
761
+
762
+ // (a),(b) policy mappings.
763
+ var pm;
764
+ try { pm = decodeExt(cert, OID.policyMappings); }
765
+ catch (e) { checks.push({ name: "policyMappings", ok: false, code: "path/bad-policy" }); return { fatal: true, error: e }; }
766
+ if (pm) {
767
+ var badAny = pm.value.some(function (m) { return m.issuerDomainPolicy === OID.anyPolicy || m.subjectDomainPolicy === OID.anyPolicy; });
768
+ if (badAny) { checks.push({ name: "policyMappings", ok: false, code: "path/bad-policy" }); return { fatal: true, error: E("path/bad-policy", "policyMappings must not map to or from anyPolicy (RFC 5280 6.1.4(a))") }; }
769
+ if (state.validPolicyTree) applyPolicyMappings(state, pm.value, i);
770
+ }
771
+
772
+ // (c) working issuer name; (d),(e),(f) working key + algorithm + parameters.
773
+ state.workingIssuerName = cert.subject;
774
+ updateWorkingKey(state, cert);
775
+
776
+ // (g) name constraints absorb (AFTER this cert's own names were checked).
777
+ var nc;
778
+ try { nc = decodeExt(cert, OID.nameConstraints); }
779
+ catch (e) { checks.push({ name: "nameConstraints", ok: false, code: pathCode(e, "path/bad-name-constraints") }); return { fatal: true, error: e }; }
780
+ var ncCritErr = requireCriticalExt(nc, "nameConstraints", checks);
781
+ if (ncCritErr) return { fatal: true, error: ncCritErr };
782
+ if (nc) absorbNameConstraints(state, nc.value);
783
+
784
+ // (h) decrement counters for a non-self-issued cert.
785
+ if (!isSelfIssued) {
786
+ if (state.explicitPolicy > 0) state.explicitPolicy--;
787
+ if (state.policyMapping > 0) state.policyMapping--;
788
+ if (state.inhibitAnyPolicy > 0) state.inhibitAnyPolicy--;
789
+ }
790
+
791
+ // (i),(j) policy/inhibit clamps.
792
+ var pc;
793
+ try { pc = decodeExt(cert, OID.policyConstraints); }
794
+ catch (e) { checks.push({ name: "policyConstraints", ok: false, code: "path/bad-policy" }); return { fatal: true, error: e }; }
795
+ var pcCritErr = requireCriticalExt(pc, "policyConstraints", checks);
796
+ if (pcCritErr) return { fatal: true, error: pcCritErr };
797
+ if (pc) {
798
+ if (pc.value.requireExplicitPolicy !== null && pc.value.requireExplicitPolicy < state.explicitPolicy) state.explicitPolicy = pc.value.requireExplicitPolicy;
799
+ if (pc.value.inhibitPolicyMapping !== null && pc.value.inhibitPolicyMapping < state.policyMapping) state.policyMapping = pc.value.inhibitPolicyMapping;
800
+ }
801
+ var iap;
802
+ try { iap = decodeExt(cert, OID.inhibitAnyPolicy); }
803
+ catch (e) { checks.push({ name: "inhibitAnyPolicy", ok: false, code: "path/bad-policy" }); return { fatal: true, error: e }; }
804
+ var iapCritErr = requireCriticalExt(iap, "inhibitAnyPolicy", checks);
805
+ if (iapCritErr) return { fatal: true, error: iapCritErr };
806
+ if (iap && iap.value < state.inhibitAnyPolicy) state.inhibitAnyPolicy = iap.value;
807
+
808
+ // (k) basicConstraints cA gate — the single authoritative CA check.
809
+ var bc;
810
+ try { bc = decodeExt(cert, OID.basicConstraints); }
811
+ catch (e) { checks.push({ name: "basicConstraints", ok: false, code: "path/bad-basic-constraints" }); return { fatal: true, error: e }; }
812
+ if (!bc || bc.value.cA !== true) {
813
+ checks.push({ name: "basicConstraints", ok: false, code: "path/not-a-ca" });
814
+ return { fatal: true, error: E("path/not-a-ca", "intermediate certificate is not a CA (basicConstraints cA is not TRUE, RFC 5280 6.1.4(k))") };
815
+ }
816
+ // 4.2.1.9: a CA certificate used to validate certificate signatures MUST mark
817
+ // basicConstraints critical. A non-critical cA:TRUE is non-conforming — a
818
+ // relying party that skips non-critical extensions would not see the CA bit.
819
+ var bcCritErr = requireCriticalExt(bc, "basicConstraints", checks);
820
+ if (bcCritErr) return { fatal: true, error: bcCritErr };
821
+ // (l),(m) path length.
822
+ if (!isSelfIssued) {
823
+ if (state.maxPathLength <= 0) { checks.push({ name: "pathLength", ok: false, code: "path/path-length-exceeded" }); return { fatal: true, error: E("path/path-length-exceeded", "certification path is longer than the CA path-length constraint allows") }; }
824
+ state.maxPathLength--;
825
+ }
826
+ if (bc.value.pathLenConstraint !== null && bc.value.pathLenConstraint < state.maxPathLength) state.maxPathLength = bc.value.pathLenConstraint;
827
+
828
+ // (n) keyUsage.keyCertSign.
829
+ var ku;
830
+ try { ku = decodeExt(cert, OID.keyUsage); }
831
+ catch (e) { checks.push({ name: "keyUsage", ok: false, code: "path/bad-key-usage" }); return { fatal: true, error: e }; }
832
+ if (ku && ku.value.keyCertSign !== true) {
833
+ checks.push({ name: "keyUsage", ok: false, code: "path/missing-key-cert-sign" });
834
+ return { fatal: true, error: E("path/missing-key-cert-sign", "CA certificate keyUsage does not assert keyCertSign (RFC 5280 6.1.4(n))") };
835
+ }
836
+ return { fatal: false };
837
+ }
838
+
839
+ function applyPolicyMappings(state, mappings, i) {
840
+ var depth = i;
841
+ if (state.policyMapping > 0) {
842
+ // 6.1.4(b)(1): for each depth-i node whose valid_policy is an ID-P that
843
+ // the extension maps, REPLACE its expected_policy_set with the SET of
844
+ // subjectDomainPolicy values mapped from that ID-P (not append — retaining
845
+ // the pre-mapping policy would let a later cert satisfy the chain by
846
+ // asserting the mapped-away policy).
847
+ var mappedFrom = {}; // issuerDomainPolicy -> [subjectDomainPolicy, ...]
848
+ mappings.forEach(function (m) { (mappedFrom[m.issuerDomainPolicy] = mappedFrom[m.issuerDomainPolicy] || []).push(m.subjectDomainPolicy); });
849
+ var depthI = leavesAt(state.validPolicyTree, depth);
850
+ var anyNodes = depthI.filter(function (nd) { return nd.validPolicy === OID.anyPolicy; });
851
+ Object.keys(mappedFrom).forEach(function (idp) {
852
+ var idpNodes = depthI.filter(function (nd) { return nd.validPolicy === idp; });
853
+ if (idpNodes.length) {
854
+ idpNodes.forEach(function (nd) { nd.expectedPolicySet = mappedFrom[idp].slice(); });
855
+ } else {
856
+ // 6.1.4(b)(1): no depth-i ID-P node, but a depth-i anyPolicy node — GENERATE
857
+ // the missing ID-P node under the anyPolicy node's parent with the mapped
858
+ // expected set (else an anyPolicy-only CA loses the mapping).
859
+ anyNodes.forEach(function (anyNode) {
860
+ if (anyNode.parent) addChild(state, anyNode.parent, idp, anyNode.qualifierSet[0] || null, mappedFrom[idp].slice(), []);
861
+ });
862
+ }
863
+ });
864
+ } else {
865
+ // 6.1.4(b)(2), policy_mapping == 0: delete every depth-i node whose
866
+ // valid_policy is a mapped ID-P, then prune. A prior mapping in the same
867
+ // extension may have already emptied the tree — stop if it is gone.
868
+ var mappedSet = {};
869
+ mappings.forEach(function (m) { mappedSet[m.issuerDomainPolicy] = true; });
870
+ if (!state.validPolicyTree) return;
871
+ leavesAt(state.validPolicyTree, depth).forEach(function (node) {
872
+ if (mappedSet[node.validPolicy] && node.parent) {
873
+ var idx = node.parent.children.indexOf(node);
874
+ if (idx !== -1) node.parent.children.splice(idx, 1);
875
+ }
876
+ });
877
+ // Prune from the PARENT depth (i-1): a pass starting at depth i would
878
+ // delete the surviving unmapped depth-i leaves (all leaves are childless),
879
+ // wrongly emptying the tree and false-rejecting a valid path.
880
+ pruneChildless(state.validPolicyTree, depth - 1);
881
+ if (treeIsEmpty(state.validPolicyTree)) state.validPolicyTree = null;
882
+ }
883
+ }
884
+
885
+ // policyMappings is semantically processed ONLY in the prepare-for-next step
886
+ // (§6.1.4(a),(b)), which does not run for the target certificate. It is also
887
+ // SHOULD-be-non-critical (§4.2.1.5), so a CRITICAL policyMappings on the target
888
+ // is both anomalous and unprocessed — it must fail closed (§6.1.5(f)) rather
889
+ // than let, e.g., a mapping to/from anyPolicy slip past the §6.1.4(a) rejection
890
+ // the intermediate path applies. (nameConstraints / inhibitAnyPolicy are also
891
+ // prepare-next-only but are MUST-be-critical CA extensions, so a critical one on
892
+ // a terminal CA cert is conforming and is NOT treated as unprocessed here.)
893
+ var TARGET_UNPROCESSED_IF_CRITICAL = {};
894
+ TARGET_UNPROCESSED_IF_CRITICAL[OID.policyMappings] = true;
895
+
896
+ function unrecognizedCriticalExtension(cert, isTarget) {
897
+ for (var i = 0; i < cert.extensions.length; i++) {
898
+ var ext = cert.extensions[i];
899
+ if (!ext.critical) continue;
900
+ if (!PROCESSED_EXTENSIONS[ext.oid]) return ext.oid;
901
+ if (isTarget && TARGET_UNPROCESSED_IF_CRITICAL[ext.oid]) return ext.oid;
902
+ }
903
+ return null;
904
+ }
905
+
906
+ // Decode every RECOGNIZED critical extension to enforce that its extnValue is
907
+ // structurally valid — even where the semantic gate is skipped (the leaf).
908
+ // Returns the failing typed code, or null.
909
+ function validateCriticalExtensionStructure(cert) {
910
+ for (var i = 0; i < cert.extensions.length; i++) {
911
+ var ext = cert.extensions[i];
912
+ if (!ext.critical) continue;
913
+ var dec = EXT_DECODERS[ext.oid];
914
+ if (!dec) continue; // unrecognized-critical handled separately
915
+ try { dec(ext.value); }
916
+ catch (e) { return pathCode(e, "path/bad-extension-value"); }
917
+ }
918
+ return null;
919
+ }
920
+
921
+ /**
922
+ * @primitive pki.path.validate
923
+ * @signature pki.path.validate(path, opts) -> Promise<result>
924
+ * @since 0.1.16
925
+ * @status experimental
926
+ * @spec RFC 5280
927
+ * @related pki.schema.x509.parse, pki.path.crlChecker
928
+ *
929
+ * Validate an ordered certification `path` (anchor→target) against a trust
930
+ * anchor per RFC 5280 6.1. `path` is an array of `pki.schema.x509.parse`
931
+ * objects (or DER/PEM the function parses); `opts` carries `time` (the
932
+ * always-on window check), `trustAnchor` ({ name, publicKey, algorithm,
933
+ * parameters? }), the optional policy inputs, and an optional
934
+ * `revocationChecker`. Returns `{ valid, path, results, workingPublicKey,
935
+ * workingPublicKeyAlgorithm, workingPublicKeyParameters, validPolicyTree }`
936
+ * where `results[i].checks` carries a per-check reason code (`path/*`) for
937
+ * every step. Pure and re-entrant — no input object is mutated. An empty path
938
+ * or a missing anchor throws a typed `PathError`.
939
+ *
940
+ * @example
941
+ * var cert = pki.schema.x509.parse(der);
942
+ * var res = await pki.path.validate([cert], {
943
+ * time: new Date("2020-01-01T00:00:00Z"),
944
+ * trustAnchor: { name: cert.issuer, publicKey: cert.subjectPublicKeyInfo.bytes, algorithm: cert.signatureAlgorithm.oid },
945
+ * });
946
+ * res.valid; // boolean; res.results[0].checks carries the per-check codes
947
+ */
948
+ async function validate(path, opts) {
949
+ opts = opts || {};
950
+ if (!Array.isArray(path)) throw E("path/bad-input", "validate: path must be an array of certificates");
951
+ // Bound the per-cert asymmetric-verify work BEFORE parsing an untrusted bundle
952
+ // (the policy-tree cap guards CVE-2023-0464-style blow-up; this guards linear
953
+ // crypto amplification from an oversized path). Entry-point tier: throw.
954
+ var maxCerts = opts.maxPathCerts !== undefined ? opts.maxPathCerts : constants.LIMITS.PATH_MAX_CERTS;
955
+ if (typeof maxCerts !== "number" || !isFinite(maxCerts) || maxCerts < 1) throw E("path/bad-input", "validate: opts.maxPathCerts must be a positive number");
956
+ if (path.length > maxCerts) throw E("path/bad-input", "validate: the certification path has " + path.length + " certificates, exceeding the maxPathCerts limit (" + maxCerts + ")");
957
+ var certs = path.map(function (c) { return (c && c.tbsBytes) ? c : x509.parse(c); });
958
+ var n = certs.length;
959
+ if (n < 1) throw E("path/empty-path", "validate: the certification path is empty");
960
+ if (!opts.trustAnchor) throw E("path/bad-input", "validate: a trustAnchor is required");
961
+ // The validity-window check is always on (6.1.3(a)(2)); a missing/invalid
962
+ // check date must fail closed, never silently disable it.
963
+ if (!(opts.time instanceof Date) || isNaN(opts.time.getTime())) {
964
+ throw E("path/bad-input", "validate: opts.time must be a Date (the always-on validity-window check date)");
965
+ }
966
+
967
+ var state = initialize(certs, opts);
968
+ state._n = n;
969
+ var verifier = opts.verifier || null;
970
+ var revocationChecker = opts.revocationChecker || null;
971
+ var softFail = opts.softFail === true;
972
+ // Revocation is a pluggable, opt-in step: by default a path with no checker is
973
+ // not revocation-checked. opts.requireRevocation makes the 6.1.3(a)(3)
974
+ // determination mandatory — an absent checker (or an undetermined result)
975
+ // then fails the path closed instead of silently skipping the step.
976
+ var requireRevocation = opts.requireRevocation === true;
977
+ var failed = false;
978
+
979
+ for (var idx = 0; idx < n; idx++) {
980
+ var i = idx + 1;
981
+ var cert = certs[idx];
982
+ var checks = [];
983
+
984
+ // 6.1.3(a)(1) signature.
985
+ var sigRes;
986
+ if (verifier) {
987
+ var vv;
988
+ try {
989
+ vv = await verifier.verify({
990
+ cert: cert,
991
+ workingPublicKey: state.workingPublicKey,
992
+ workingPublicKeyAlgorithm: state.workingPublicKeyAlgorithm,
993
+ workingPublicKeyParameters: state.workingPublicKeyParameters,
994
+ });
995
+ } catch (_e) { vv = false; }
996
+ sigRes = { ok: vv === true };
997
+ } else {
998
+ sigRes = await builtinVerify(state, cert);
999
+ }
1000
+ checks.push({ name: "signature", ok: sigRes.ok, code: sigRes.ok ? undefined : (sigRes.code || "path/bad-signature") });
1001
+ if (!sigRes.ok) failed = true;
1002
+
1003
+ // 6.1.3(a)(2) validity window.
1004
+ var t = opts.time;
1005
+ var vOk = true, vCode;
1006
+ if (t < cert.validity.notBefore) { vOk = false; vCode = "path/not-yet-valid"; }
1007
+ else if (t > cert.validity.notAfter) { vOk = false; vCode = "path/expired"; }
1008
+ checks.push({ name: "validity", ok: vOk, code: vCode });
1009
+ if (!vOk) failed = true;
1010
+
1011
+ // 6.1.3(a)(4) name chaining.
1012
+ var chainOk;
1013
+ try { chainOk = dnEqual(cert.issuer.rdns, state.workingIssuerName.rdns); }
1014
+ catch (_e) { chainOk = false; }
1015
+ checks.push({ name: "nameChaining", ok: chainOk === true, code: chainOk === true ? undefined : "path/name-chaining" });
1016
+ if (chainOk !== true) failed = true;
1017
+
1018
+ // 6.1.3(b,c) name constraints on this cert's own names (skip for a
1019
+ // self-issued non-terminal cert).
1020
+ if (!(selfIssued(cert) && i !== n)) {
1021
+ var ncRes;
1022
+ try { ncRes = checkNameConstraints(state, cert); }
1023
+ catch (e) { ncRes = { ok: false, code: pathCode(e, "path/bad-name-constraints") }; }
1024
+ checks.push({ name: "nameConstraints", ok: ncRes.ok, code: ncRes.ok ? undefined : ncRes.code });
1025
+ if (!ncRes.ok) failed = true;
1026
+ }
1027
+
1028
+ // 6.1.3(a)(3) revocation.
1029
+ if (revocationChecker) {
1030
+ var issuerCert = idx > 0 ? certs[idx - 1] : null; // the anchor issues cert[1]
1031
+ var rv;
1032
+ try { rv = await revocationChecker.check(cert, { workingIssuerName: state.workingIssuerName, workingPublicKey: state.workingPublicKey, workingPublicKeyAlgorithm: state.workingPublicKeyAlgorithm, issuerCert: issuerCert }, { time: opts.time, historicalMode: opts.historicalMode === true }); }
1033
+ catch (_e) { rv = { status: "unknown" }; }
1034
+ // ONLY an explicit "good" is a determined non-revocation; "revoked" fails;
1035
+ // every other value ("unknown", an OCSP tryLater/unauthorized, a typo, a
1036
+ // missing status) is undetermined and fails closed unless softFail.
1037
+ if (rv && rv.status === "good") { checks.push({ name: "revocation", ok: true }); }
1038
+ else if (rv && rv.status === "revoked") { checks.push({ name: "revocation", ok: false, code: "path/revoked" }); failed = true; }
1039
+ else if (softFail) { checks.push({ name: "revocation", ok: true }); }
1040
+ else { checks.push({ name: "revocation", ok: false, code: "path/revocation-undetermined" }); failed = true; }
1041
+ } else if (requireRevocation) {
1042
+ // No checker was supplied but the caller demands a revocation determination:
1043
+ // the step cannot be performed, so fail closed (never silently skip).
1044
+ checks.push({ name: "revocation", ok: false, code: "path/revocation-undetermined" }); failed = true;
1045
+ }
1046
+
1047
+ // 6.1.3(d-f) policies.
1048
+ var polRes = processPolicies(state, cert, i, checks);
1049
+ if (state._capHit) { checks.push({ name: "policyTree", ok: false, code: "path/policy-tree-cap" }); failed = true; }
1050
+ else if (polRes.fatal) failed = true;
1051
+
1052
+ // empty subject requires a critical SAN (4.1.2.6).
1053
+ if (cert.subject.rdns.length === 0) {
1054
+ var san = findExt(cert, OID.subjectAltName);
1055
+ if (!san || !san.critical) { checks.push({ name: "emptySubject", ok: false, code: "path/empty-subject-no-critical-san" }); failed = true; }
1056
+ }
1057
+
1058
+ // 6.1.4 / 6.1.5.
1059
+ if (i !== n) {
1060
+ if (!state._capHit) {
1061
+ var prep = prepareNext(state, cert, i, checks);
1062
+ if (prep.fatal) failed = true;
1063
+ }
1064
+ } else {
1065
+ // 6.1.5 wrap-up.
1066
+ if (state.explicitPolicy > 0) state.explicitPolicy--; // 6.1.5(a)
1067
+ var lpc;
1068
+ try { lpc = decodeExt(cert, OID.policyConstraints); }
1069
+ catch (_e) { lpc = null; checks.push({ name: "policyConstraints", ok: false, code: "path/bad-policy" }); failed = true; }
1070
+ // 4.2.1.11: policyConstraints MUST be critical — apply the same check the
1071
+ // intermediate path (prepareNext) uses, so a non-critical policyConstraints
1072
+ // on the TARGET cert fails closed consistently.
1073
+ if (requireCriticalExt(lpc, "policyConstraints", checks)) failed = true;
1074
+ if (lpc && lpc.value.requireExplicitPolicy === 0) state.explicitPolicy = 0; // 6.1.5(b)
1075
+ // 6.1.4(a) / 4.2.1.5: policyMappings must never map to/from anyPolicy. The
1076
+ // intermediate path enforces this in prepareNext; the target cert skips it,
1077
+ // so apply the structural rejection here too (covers a non-critical mapping
1078
+ // that the unrecognized-critical check above does not reach).
1079
+ var lpm;
1080
+ try { lpm = decodeExt(cert, OID.policyMappings); }
1081
+ catch (_e) { lpm = null; checks.push({ name: "policyMappings", ok: false, code: "path/bad-policy" }); failed = true; }
1082
+ if (lpm && lpm.value.some(function (m) { return m.issuerDomainPolicy === OID.anyPolicy || m.subjectDomainPolicy === OID.anyPolicy; })) {
1083
+ checks.push({ name: "policyMappings", ok: false, code: "path/bad-policy" }); failed = true;
1084
+ }
1085
+ updateWorkingKey(state, cert); // 6.1.5(c),(d) — key AND algorithm AND parameters
1086
+ }
1087
+
1088
+ // 6.1.4(o) / 6.1.5(e) unrecognized critical extension.
1089
+ var unk = unrecognizedCriticalExtension(cert, i === n);
1090
+ if (unk) { checks.push({ name: "criticalExtensions", ok: false, code: "path/unrecognized-critical-extension" }); failed = true; }
1091
+
1092
+ // A RECOGNIZED critical extension must still be structurally valid even
1093
+ // when its semantic gate does not run on this cert (the leaf is not subject
1094
+ // to 6.1.4, so its basicConstraints/keyUsage/policy* are never read in
1095
+ // prepareNext) — a malformed critical extnValue must fail closed, not slip
1096
+ // through as "recognized". Decode every recognized critical extension to
1097
+ // validate it (a no-op for one already decoded above; the decoders are pure).
1098
+ var crit = validateCriticalExtensionStructure(cert);
1099
+ if (crit) { checks.push({ name: "criticalExtensionValue", ok: false, code: crit }); failed = true; }
1100
+
1101
+ state.results.push({ index: idx, checks: checks });
1102
+ }
1103
+
1104
+ // 6.1.5(g) success condition. The tree is first INTERSECTED with the
1105
+ // user-initial-policy-set (userConstrainedPolicies); success requires
1106
+ // explicit_policy > 0 OR that pruned tree to be non-empty. Using the raw tree
1107
+ // would accept a path whose only surviving policies are outside the user set
1108
+ // when an explicit policy is required.
1109
+ var ucps = userConstrainedPolicies(state, n);
1110
+ var policyOk = state.explicitPolicy > 0 || ucps.length > 0;
1111
+ if (!policyOk) {
1112
+ var last = state.results[state.results.length - 1];
1113
+ if (!last.checks.some(function (c) { return c.code === "path/policy-required"; })) {
1114
+ last.checks.push({ name: "policy", ok: false, code: "path/policy-required" });
1115
+ }
1116
+ failed = true;
1117
+ }
1118
+
1119
+ return {
1120
+ valid: !failed,
1121
+ path: certs,
1122
+ results: state.results,
1123
+ workingPublicKey: state.workingPublicKey,
1124
+ workingPublicKeyAlgorithm: state.workingPublicKeyAlgorithm,
1125
+ workingPublicKeyParameters: state.workingPublicKeyParameters,
1126
+ validPolicyTree: treeWithoutParent(state.validPolicyTree),
1127
+ // 6.1.5(f): the authority-constrained policy set = the leaf-depth policies
1128
+ // in the valid-policy tree intersected with user-initial-policy-set.
1129
+ userConstrainedPolicySet: ucps,
1130
+ };
1131
+ }
1132
+
1133
+ function userConstrainedPolicies(state, n) {
1134
+ if (!state.validPolicyTree) return [];
1135
+ var uips = state.userInitialPolicySet;
1136
+ var anyUser = uips.indexOf(OID.anyPolicy) !== -1;
1137
+ var leaves = leavesAt(state.validPolicyTree, n);
1138
+ var explicit = {}, hasAnyLeaf = false;
1139
+ leaves.forEach(function (node) {
1140
+ if (node.validPolicy === OID.anyPolicy) hasAnyLeaf = true;
1141
+ else explicit[node.validPolicy] = true;
1142
+ });
1143
+ var set = {};
1144
+ Object.keys(explicit).forEach(function (p) { if (anyUser || uips.indexOf(p) !== -1) set[p] = true; });
1145
+ // 6.1.5(g) step 3: a depth-n anyPolicy node under a restrictive user set
1146
+ // expands to each user policy (the intersection of anyPolicy with the user
1147
+ // set is the user set itself).
1148
+ if (hasAnyLeaf) {
1149
+ if (anyUser) set[OID.anyPolicy] = true;
1150
+ else uips.forEach(function (p) { set[p] = true; });
1151
+ }
1152
+ return Object.keys(set);
1153
+ }
1154
+
1155
+ // ---- the CRL revocation checker (6.3) -------------------------------------
1156
+
1157
+ var OID_IDP = oid.byName("issuingDistributionPoint");
1158
+ var OID_DELTA_CRL = oid.byName("deltaCRLIndicator");
1159
+
1160
+ function decodeIdp(ext) {
1161
+ // IssuingDistributionPoint ::= SEQUENCE { distributionPoint [0]?,
1162
+ // onlyContainsUserCerts [1]?, onlyContainsCACerts [2]?, onlySomeReasons [3]?,
1163
+ // indirectCRL [4]?, onlyContainsAttributeCerts [5]? }. Surface the scope
1164
+ // flags the checker gates on.
1165
+ var out = { hasDistributionPoint: false, onlyUser: false, onlyCa: false, onlySomeReasons: null, indirect: false, onlyAttr: false, malformed: false };
1166
+ var n;
1167
+ try { n = asn1.decode(ext.value); }
1168
+ catch (_e) { out.malformed = true; return out; }
1169
+ // A present IDP whose value is not a SEQUENCE leaves the CRL's scope unknown
1170
+ // — treat the CRL as unusable rather than assuming an unrestricted scope.
1171
+ if (n.tagClass !== "universal" || n.tagNumber !== asn1.TAGS.SEQUENCE || !n.children) { out.malformed = true; return out; }
1172
+ n.children.forEach(function (f) {
1173
+ if (f.tagClass !== "context") { out.malformed = true; return; }
1174
+ if (f.tagNumber === 0) out.hasDistributionPoint = true; // scoped to a specific DP
1175
+ else if (f.tagNumber === 1) { if (idpBoolTrue(f, out)) out.onlyUser = true; }
1176
+ else if (f.tagNumber === 2) { if (idpBoolTrue(f, out)) out.onlyCa = true; }
1177
+ else if (f.tagNumber === 3) out.onlySomeReasons = true; // BIT STRING; presence gates out-of-scope
1178
+ else if (f.tagNumber === 4) { if (idpBoolTrue(f, out)) out.indirect = true; }
1179
+ else if (f.tagNumber === 5) { if (idpBoolTrue(f, out)) out.onlyAttr = true; }
1180
+ else out.malformed = true;
1181
+ });
1182
+ return out;
1183
+ }
1184
+
1185
+ // An IMPLICIT BOOLEAN scope flag is primitive with the single DER-TRUE octet
1186
+ // 0xFF (a present DEFAULT-FALSE field is TRUE); anything else — constructed,
1187
+ // wrong length, non-0xFF — is malformed, so mark the scope unknown.
1188
+ function idpBoolTrue(f, out) {
1189
+ if (f.children || !f.content || f.content.length !== 1 || f.content[0] !== 0xff) { out.malformed = true; return false; }
1190
+ return true;
1191
+ }
1192
+
1193
+ /**
1194
+ * @primitive pki.path.crlChecker
1195
+ * @signature pki.path.crlChecker(crls) -> RevocationChecker
1196
+ * @since 0.1.16
1197
+ * @status experimental
1198
+ * @spec RFC 5280
1199
+ * @related pki.path.validate, pki.schema.crl.parse
1200
+ *
1201
+ * Build a CRL-backed `RevocationChecker` for `pki.path.validate`'s
1202
+ * `revocationChecker` option from a set of CRLs (DER/PEM or already-parsed).
1203
+ * For each certificate it locates a CRL issued by the certificate's issuer,
1204
+ * verifies the CRL signature over its `tbsBytes`, honors the issuing
1205
+ * distribution point scope and reason coverage, checks currency
1206
+ * (`thisUpdate`/`nextUpdate`), and reports `{ status: "good"|"revoked"|
1207
+ * "unknown" }`. An out-of-scope, stale, unauthorized, or unverifiable CRL
1208
+ * yields `unknown`, which the validator fails closed unless `softFail` is set.
1209
+ *
1210
+ * @example
1211
+ * var checker = pki.path.crlChecker([]); // no CRLs -> every cert is "unknown"
1212
+ * typeof checker.check; // "function"
1213
+ */
1214
+ function crlChecker(crls) {
1215
+ var parsed = (crls || []).map(function (c) { return (c && c.tbsBytes) ? c : crl.parse(c); });
1216
+ return {
1217
+ check: async function (cert, issuer, ctx) {
1218
+ var time = ctx.time;
1219
+ var historical = ctx.historicalMode === true;
1220
+ var certIsCa = (function () { try { var bc = decodeExt(cert, OID.basicConstraints); return bc && bc.value.cA === true; } catch (_e) { return false; } })();
1221
+ // RFC 5280 §6.3.3(f): IF a keyUsage extension is present in the CRL issuer's
1222
+ // certificate, the cRLSign bit must be set. An issuer that OMITS keyUsage is
1223
+ // unconstrained (the same rule the §6.1.4(n) keyCertSign gate applies to
1224
+ // certificate signing), so its CRL is authoritative. The anchor is likewise
1225
+ // unconstrained (issuerCert is null for the cert it directly issued).
1226
+ var signerAuthorized = true;
1227
+ if (issuer && issuer.issuerCert) {
1228
+ var iku;
1229
+ try { iku = decodeExt(issuer.issuerCert, OID.keyUsage); } catch (_e) { iku = null; }
1230
+ if (iku && iku.value.cRLSign !== true) signerAuthorized = false;
1231
+ }
1232
+
1233
+ // Consult EVERY CRL issued by the cert's issuer — a clean CRL must not
1234
+ // shadow a revoking one (RFC 5280 6.3.3). A serial listed in ANY
1235
+ // authoritative, in-scope, current, verified CRL is revoked; the cert is
1236
+ // "good" only if at least one such CRL was consulted and none list it;
1237
+ // otherwise the status is undetermined.
1238
+ var sawAuthoritative = false;
1239
+ var sawDelta = false;
1240
+ var sawDeltaRemoval = false; // a delta released this serial from hold
1241
+ var revokedResult = null; // a base/full CRL revocation, decided at the end
1242
+ for (var k = 0; k < parsed.length; k++) {
1243
+ var theCrl = parsed[k];
1244
+ // dnEqual throws on a DN carrying an embedded NUL/control byte (CVE-2009-2408).
1245
+ // A single malformed CRL in the bundle must NOT abort the whole check (which
1246
+ // would mask a later authoritative CRL and pass under softFail) — treat it
1247
+ // as unusable and skip it, consulting the remaining CRLs.
1248
+ var issuerMatches;
1249
+ try { issuerMatches = dnEqual(theCrl.issuer.rdns, cert.issuer.rdns); }
1250
+ catch (_e) { continue; }
1251
+ if (!issuerMatches) continue;
1252
+ if (!signerAuthorized) continue;
1253
+
1254
+ // A CRL carrying deltaCRLIndicator is a DELTA CRL: it lists only the
1255
+ // CHANGES since a base CRL (RFC 5280 §5.2.4). deltaCRLIndicator is a
1256
+ // RECOGNIZED extension (so a critical one is not "unhandled"); the delta
1257
+ // is acted on only AFTER it passes the currency + signature checks below,
1258
+ // so a stale, malformed, or unverifiable delta cannot spuriously block a
1259
+ // good result. An AUTHORITATIVE delta blocks "good" (its base is not
1260
+ // merged here) and can still reveal a revocation for a serial it lists.
1261
+ var isDelta = false;
1262
+ for (var dz = 0; dz < theCrl.crlExtensions.length; dz++) {
1263
+ if (theCrl.crlExtensions[dz].oid === OID_DELTA_CRL) { isDelta = true; break; }
1264
+ }
1265
+
1266
+ // A validly-signed CRL carrying a CRITICAL extension this checker does
1267
+ // not understand (anything but issuingDistributionPoint / deltaCRLIndicator)
1268
+ // may change the CRL's scope or meaning — treat it as unusable (RFC 5280
1269
+ // 5.2 critical-extension semantics), never authoritative.
1270
+ var unhandledCritical = false;
1271
+ for (var x = 0; x < theCrl.crlExtensions.length; x++) {
1272
+ var xe = theCrl.crlExtensions[x];
1273
+ if (xe.critical && xe.oid !== OID_IDP && xe.oid !== OID_DELTA_CRL) { unhandledCritical = true; break; }
1274
+ }
1275
+ // RFC 5280 §5.3: a critical CRL-ENTRY extension the checker cannot
1276
+ // process (anything but reasonCode) makes the CRL unusable for ANY
1277
+ // certificate, not just the entry that carries it.
1278
+ for (var ry = 0; ry < theCrl.revokedCertificates.length && !unhandledCritical; ry++) {
1279
+ var ees = theCrl.revokedCertificates[ry].crlEntryExtensions || [];
1280
+ for (var ex = 0; ex < ees.length; ex++) {
1281
+ // Key on the stable OID only — a display name is registry-dependent
1282
+ // (a custom OID could be registered as "reasonCode"), so matching by
1283
+ // name would let an unhandled critical entry extension fail open.
1284
+ if (ees[ex].critical && ees[ex].oid !== OID_REASON_CODE) { unhandledCritical = true; break; }
1285
+ }
1286
+ }
1287
+ if (unhandledCritical) continue;
1288
+
1289
+ // A partition-scoped CRL (a specific distributionPoint, or reason-sharded
1290
+ // via onlySomeReasons) covers only part of the issuer's revocations, so it
1291
+ // cannot by itself establish "good" (full coverage is unconfirmed). But a
1292
+ // serial it LISTS is a genuine revocation of this certificate (serials are
1293
+ // unique per issuer), so such a CRL must still be consulted for revocation
1294
+ // — dropping it wholesale would let a revoked cert slip under softFail.
1295
+ var scopeRevocationOnly = false;
1296
+ var idpExt = null;
1297
+ for (var e = 0; e < theCrl.crlExtensions.length; e++) if (theCrl.crlExtensions[e].oid === OID_IDP) idpExt = theCrl.crlExtensions[e];
1298
+ if (idpExt) {
1299
+ var idp = decodeIdp(idpExt);
1300
+ if (idp.malformed) continue; // scope unknown -> unusable
1301
+ // An indirect CRL carries entries for other issuers keyed by the
1302
+ // per-entry certificateIssuer attribute (not tracked here) — matching
1303
+ // by serial alone could revoke the wrong cert or falsely cover it, so
1304
+ // treat an indirect CRL as unusable until certificateIssuer is honored.
1305
+ if (idp.indirect) continue;
1306
+ if (idp.onlyAttr) continue; // scoped to attribute certs, not this public-key cert
1307
+ if (idp.onlyCa && !certIsCa) continue; // out of scope for this cert
1308
+ if (idp.onlyUser && certIsCa) continue;
1309
+ if (idp.hasDistributionPoint || idp.onlySomeReasons) scopeRevocationOnly = true;
1310
+ }
1311
+ if (theCrl.thisUpdate > time) continue; // not yet valid
1312
+ // A CRL with no nextUpdate has no bounded validity — its currency
1313
+ // cannot be confirmed (RFC 5280 §5.1.2.5 requires nextUpdate), so a
1314
+ // replayed old CRL must not read "good". Treat it as unusable.
1315
+ if (!theCrl.nextUpdate || theCrl.nextUpdate < time) continue; // stale / no bound
1316
+
1317
+ var sigOk = await verifyCrlSignature(theCrl, issuer);
1318
+ if (!sigOk) continue; // unverifiable -> not authoritative
1319
+
1320
+ // The CRL is now authoritative + current + verified. An authoritative
1321
+ // delta blocks a "good" result (its base is not merged here) and can only
1322
+ // reveal a revocation — never establish "good" on its own.
1323
+ if (isDelta) { sawDelta = true; scopeRevocationOnly = true; }
1324
+
1325
+ for (var r = 0; r < theCrl.revokedCertificates.length; r++) {
1326
+ var entry = theCrl.revokedCertificates[r];
1327
+ if (entry.serialNumberHex !== cert.serialNumberHex) continue;
1328
+ // reasonCode removeFromCRL (8) means the entry was un-revoked. In a
1329
+ // DELTA this releases the serial from hold; because the base is not
1330
+ // merged here, a definitive "revoked" is no longer possible for it — a
1331
+ // base CRL that still lists it must not override the delta removal.
1332
+ if (crlEntryReason(entry) === 8) { if (isDelta) sawDeltaRemoval = true; continue; }
1333
+ // A revocation is effective as of its revocationDate (RFC 5280 §5.3).
1334
+ // In the DEFAULT present-time validation a listed serial is revoked
1335
+ // regardless of that date — a future revocationDate is post-dating or
1336
+ // clock skew and must NOT read good. Only under an EXPLICIT historical
1337
+ // validation (opts.historicalMode) — validating as of a past instant,
1338
+ // e.g. a timestamped signature — does an entry dated AFTER the
1339
+ // validation time not yet apply.
1340
+ if (historical && entry.revocationDate instanceof Date && entry.revocationDate.getTime() > time.getTime()) continue;
1341
+ // Record the revocation but keep scanning: a delta removeFromCRL for the
1342
+ // same serial (in another CRL) overrides it (base/delta not merged).
1343
+ revokedResult = { status: "revoked", reason: "serial listed in a CRL" };
1344
+ break;
1345
+ }
1346
+ // A partition-scoped CRL that did not list this serial does NOT prove the
1347
+ // cert is unrevoked (another shard/reason may revoke it) — only a
1348
+ // full-scope CRL can establish "good".
1349
+ if (!scopeRevocationOnly) sawAuthoritative = true; // covered this cert, not listed
1350
+ }
1351
+ // A delta released this serial from hold: without merging its base we cannot
1352
+ // return a definitive revoked (else a released cert stays rejected) — the
1353
+ // status is undetermined. This outranks a base CRL's revocation.
1354
+ if (sawDeltaRemoval) return { status: "unknown", reason: "a delta CRL released this serial from hold; without merging its base CRL the revocation status is undetermined" };
1355
+ if (revokedResult) return revokedResult;
1356
+ // A delta CRL for this issuer was seen but cannot be merged with its base,
1357
+ // so the current revocation picture is incomplete — never report "good".
1358
+ if (sawDelta) return { status: "unknown", reason: "a delta CRL cannot be evaluated without combining it with its base CRL, so the revocation status is undetermined" };
1359
+ if (sawAuthoritative) return { status: "good" };
1360
+ return { status: "unknown", reason: "no authoritative in-scope CRL covers this certificate" };
1361
+ },
1362
+ };
1363
+ }
1364
+
1365
+ // The decoded reasonCode of a revoked CRL entry (crl.parse surfaces it as a
1366
+ // number), or null when absent.
1367
+ var OID_REASON_CODE = oid.byName("reasonCode");
1368
+ function crlEntryReason(entry) {
1369
+ var exts = entry.crlEntryExtensions || [];
1370
+ for (var i = 0; i < exts.length; i++) {
1371
+ if (exts[i].oid === OID_REASON_CODE) return exts[i].value; // stable OID, not the display name
1372
+ }
1373
+ return null;
1374
+ }
1375
+
1376
+ function verifyCrlSignature(theCrl, issuer) {
1377
+ var d;
1378
+ try { d = resolveDescriptor(theCrl.signatureAlgorithm); }
1379
+ catch (_e) { return Promise.resolve(false); }
1380
+ if (theCrl.signatureValue.unusedBits !== 0) return Promise.resolve(false); // non-octet-aligned signature
1381
+ var key;
1382
+ return subtle.importKey("spki", issuer.workingPublicKey, d.imp, false, ["verify"]).then(function (k) {
1383
+ key = k;
1384
+ var sig = theCrl.signatureValue.bytes;
1385
+ if (d.ecdsa) sig = ecdsaDerToP1363(sig, key.algorithm.namedCurve);
1386
+ return subtle.verify(d.verify, key, sig, theCrl.tbsBytes);
1387
+ }).then(function (ok) { return ok === true; }, function () { return false; });
1388
+ }
1389
+
1390
+ module.exports = {
1391
+ validate: validate,
1392
+ crlChecker: crlChecker,
1393
+ };