@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.
- package/CHANGELOG.md +12 -0
- package/README.md +2 -1
- package/index.js +4 -0
- package/lib/constants.js +4 -0
- package/lib/framework-error.js +8 -0
- package/lib/oid.js +8 -3
- package/lib/path-validate.js +1393 -0
- package/lib/schema-pkix.js +316 -11
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -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
|
+
};
|