@blamejs/pki 0.1.31 → 0.1.32
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 +8 -0
- package/README.md +1 -1
- package/lib/guard-name.js +61 -3
- package/lib/path-validate.js +290 -42
- package/package.json +2 -2
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ All notable changes to `@blamejs/pki` are documented here. The format
|
|
|
4
4
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
|
|
5
5
|
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## v0.1.32 — 2026-07-11
|
|
8
|
+
|
|
9
|
+
OCSP-backed revocation checking joins certification-path validation.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- pki.path.ocspChecker(responses) -> RevocationChecker builds an OCSP-backed revocation checker for pki.path.validate's revocationChecker option from a set of pre-fetched OCSP responses (DER Buffer, PEM string, or already-parsed). It matches the full CertID triple against the certificate under whichever hash algorithm the CertID declares, authorizes the responder (issuing CA or a CA-issued id-kp-OCSPSigning delegate), verifies the response signature over tbsResponseData, and enforces thisUpdate/nextUpdate currency, reporting good, revoked (with revocationReason), or unknown. A wrong-issuer CertID, an unauthorized responder, a stale, not-yet-valid, or nextUpdate-less response, a non-successful responseStatus, or any verification failure yields unknown, which the validator fails closed unless softFail is set. RFC 6960.
|
|
14
|
+
|
|
7
15
|
## v0.1.31 — 2026-07-11
|
|
8
16
|
|
|
9
17
|
The DER format cohort and the JOSE surface graduate to stable.
|
package/README.md
CHANGED
|
@@ -220,7 +220,7 @@ is callable today; nothing below is a stub.
|
|
|
220
220
|
| `pki.acme` | RFC 8555 / 8737 / 8738 / 9773 ACME message layer over `pki.jose` — resource-object validators (closed status enums, conditional-required fields, unknown fields ignored), the three §7.1.6 state machines, request builders (newAccount + EAB, newOrder + `replaces`, finalize with CSR identifier-set match and account-key-reuse rejection, challenge responses, deactivation, revokeCert in both key modes, the keyChange nested JWS, POST-as-GET), the http-01 / dns-01 / tls-alpn-01 challenge computations, the dns/ip identifier validators, and the ARI certID (serial sign-padding preserved). A message layer, not an HTTP client — transport-injectable, fail-closed — `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
|
|
221
221
|
| `pki.schema.smime` | Decode S/MIME ESS signed-attribute values (RFC 5035 / RFC 8551) — `parseSigningCertificate` / `parseSigningCertificateV2` bind a signature to its signing certificate (cert hash, hash algorithm, issuer `GeneralNames` + serial), `parseSmimeCapabilities` decodes the ordered capability list, and `decodeAttribute` OID-dispatches a CMS attribute (enforcing the single-value rule, recognize-and-defer for unknown types). A companion decoder for CMS signed attributes, not an auto-routed format, fail-closed — `parseSigningCertificate`, `parseSigningCertificateV2`, `parseSmimeCapabilities`, `decodeAttribute` |
|
|
222
222
|
| `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk` / `encode` / `embeddedDer` plus the schema combinators |
|
|
223
|
-
| `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining, validity windows, name chaining, basic constraints and path length, key usage, name constraints, the certificate-policy tree) over an ordered path and a trust anchor, returning a structured verdict with per-check reason codes; `crlChecker` supplies CRL-based revocation. Pure and re-entrant, fail-closed — `validate`, `crlChecker` |
|
|
223
|
+
| `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining, validity windows, name chaining, basic constraints and path length, key usage, name constraints, the certificate-policy tree) over an ordered path and a trust anchor, returning a structured verdict with per-check reason codes; `crlChecker` supplies CRL-based revocation and `ocspChecker` supplies OCSP-based revocation (RFC 6960 — CertID binding, responder authorization, signature, currency) over the same pluggable hook. Pure and re-entrant, fail-closed — `validate`, `crlChecker`, `ocspChecker` |
|
|
224
224
|
| `pki.ct` | Parse RFC 6962 Certificate Transparency SCT lists — `parseSctList` decodes the `SignedCertificateTimestampList` a certificate or OCSP response carries in the SCT extension (a TLS-presentation-language payload inside the §3.3 double DER wrap) into per-SCT log id, exact `timestamp` (BigInt), named signature algorithm, and raw signature; `reconstructSignedData` rebuilds the exact `digitally-signed` preimage for external verification. Structure decoded, crypto surfaced raw, bounded decode, fail-closed — `parseSctList`, `reconstructSignedData` |
|
|
225
225
|
| `pki.merkle` | RFC 6962 / RFC 9162 Merkle-tree proof verification — `leafHash` / `nodeHash` / `emptyRootHash` build the domain-separated (0x00 leaf / 0x01 node) SHA-256 tree hashes; `verifyInclusion` folds an audit proof back to a root and `verifyConsistency` reconstructs both the old and new root (the append-only guarantee), each constant-time-compared to a trusted checkpoint root. Fail-closed on bad geometry, sync hashing, transport-free — `leafHash`, `nodeHash`, `emptyRootHash`, `verifyInclusion`, `verifyConsistency` |
|
|
226
226
|
| `pki.C` / `pki.constants` | Version-stable constants — functional scale helpers (`C.TIME.*`, `C.BYTES.*`), codec `LIMITS`, `version` |
|
package/lib/guard-name.js
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
// consumers whose name-string integrity composes this guard (pki.path.validate
|
|
7
7
|
// DN chaining, pki.schema.x509 GeneralName / SAN decode).
|
|
8
8
|
//
|
|
9
|
-
// guard-name -- fail-closed
|
|
10
|
-
// distinguished-name / SAN string
|
|
9
|
+
// guard-name -- fail-closed name integrity: reject an embedded control byte in a
|
|
10
|
+
// distinguished-name / SAN string, and compare two distinguished names by their
|
|
11
|
+
// RFC 5280 sec. 7.1 canonical identity rather than their raw bytes.
|
|
11
12
|
//
|
|
12
13
|
// Defends the name-truncation / display-confusion class (CVE-2009-2408): a NUL
|
|
13
14
|
// or control byte embedded in a decoded name lets an attacker make two different
|
|
@@ -15,6 +16,14 @@
|
|
|
15
16
|
// "good.example.com\0.evil.com" is treated as "good.example.com". CWE-158
|
|
16
17
|
// (improper neutralization of null byte) / CWE-20. The reject is at decode, so a
|
|
17
18
|
// truncation name never reaches a comparison or a display.
|
|
19
|
+
//
|
|
20
|
+
// Defends the DN identity-vs-bytes class (CWE-706): a distinguished name has many
|
|
21
|
+
// RFC 5280 sec. 7.1-equal DER encodings (case, whitespace, PrintableString vs
|
|
22
|
+
// UTF8String). Binding identity to raw bytes -- a byte compare, or hashing
|
|
23
|
+
// name.bytes as a lookup key -- silently treats two equal names as different, so
|
|
24
|
+
// certificate chaining breaks, a revocation issuer / OCSP responder fails to
|
|
25
|
+
// match, or (the mirror risk) a name constraint is escaped. Every DN identity
|
|
26
|
+
// decision routes through the one canonical comparison here.
|
|
18
27
|
|
|
19
28
|
// assertNoControlBytes(str, E, code, label) -> str | throws E(code, ...)
|
|
20
29
|
// DirectoryString policy (a DN attribute value): reject NUL and C0 control bytes
|
|
@@ -50,4 +59,53 @@ function assertPrintableIa5(buf, E, code, label) {
|
|
|
50
59
|
return buf;
|
|
51
60
|
}
|
|
52
61
|
|
|
53
|
-
|
|
62
|
+
// Canonical form of a single DN attribute value (RFC 5280 sec. 7.1): reject an
|
|
63
|
+
// embedded control byte (CVE-2009-2408) then case-fold and collapse internal
|
|
64
|
+
// whitespace. Every standard X.520 attribute uses caseIgnoreMatch, and this form
|
|
65
|
+
// matches OpenSSL's X509_NAME_cmp, so a chain OpenSSL accepts is not rejected. This
|
|
66
|
+
// canonicalization is the shape the guard-shape-reinlined detector keys on
|
|
67
|
+
// (declared on dnEqual): a boundary hand-rolling it is re-implementing DN identity.
|
|
68
|
+
function _canonAttrValue(v, E, code, label) {
|
|
69
|
+
if (typeof v !== "string") return v;
|
|
70
|
+
assertNoControlBytes(v, E, code, label);
|
|
71
|
+
return v.trim().replace(/\s+/g, " ").toLowerCase();
|
|
72
|
+
}
|
|
73
|
+
// rdnEqual(a, b, E, code, label) -> boolean. Canonical comparison of a single
|
|
74
|
+
// RelativeDistinguishedName (an unordered SET of type/value pairs, compared as a
|
|
75
|
+
// multiset). The RDN-level primitive a name-constraint directoryName prefix match
|
|
76
|
+
// composes; dnEqual composes it over the RDN sequence. Comparing an RDN by raw DER
|
|
77
|
+
// would treat two RFC 5280-equal names as different (or let a truncation name
|
|
78
|
+
// compare equal).
|
|
79
|
+
// @enforced-by guard-shape-reinlined (shares the canonicalization shape declared on dnEqual)
|
|
80
|
+
function rdnEqual(a, b, E, code, label) {
|
|
81
|
+
if (a.length !== b.length) return false;
|
|
82
|
+
var used = [];
|
|
83
|
+
for (var i = 0; i < a.length; i++) {
|
|
84
|
+
var found = false;
|
|
85
|
+
for (var j = 0; j < b.length; j++) {
|
|
86
|
+
if (used[j]) continue;
|
|
87
|
+
if (a[i].type === b[j].type && _canonAttrValue(a[i].value, E, code, label) === _canonAttrValue(b[j].value, E, code, label)) {
|
|
88
|
+
used[j] = true; found = true; break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!found) return false;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
// dnEqual(rdnsA, rdnsB, E, code, label) -> boolean. RFC 5280 sec. 7.1 canonical
|
|
96
|
+
// distinguished-name comparison over the RDN sequence -- the ONE place a DN identity
|
|
97
|
+
// is decided, so no caller binds identity to raw DER (a byte compare, or hashing
|
|
98
|
+
// name.bytes, would treat two RFC 5280-equal names as different, breaking a chain /
|
|
99
|
+
// a revocation match, or -- the mirror risk -- escaping a name constraint). The
|
|
100
|
+
// per-RDN canonicalization + control-byte reject is in rdnEqual / _canonAttrValue.
|
|
101
|
+
// @enforced-by guard-shape-reinlined
|
|
102
|
+
// @guard-shape replace\(/\\s\+/g,
|
|
103
|
+
function dnEqual(rdnsA, rdnsB, E, code, label) {
|
|
104
|
+
if (rdnsA.length !== rdnsB.length) return false;
|
|
105
|
+
for (var i = 0; i < rdnsA.length; i++) {
|
|
106
|
+
if (!rdnEqual(rdnsA[i], rdnsB[i], E, code, label)) return false;
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { assertNoControlBytes: assertNoControlBytes, assertPrintableIa5: assertPrintableIa5, dnEqual: dnEqual, rdnEqual: rdnEqual };
|
package/lib/path-validate.js
CHANGED
|
@@ -37,6 +37,7 @@ var asn1 = require("./asn1-der");
|
|
|
37
37
|
var schema = require("./schema-engine");
|
|
38
38
|
var x509 = require("./schema-x509");
|
|
39
39
|
var crl = require("./schema-crl");
|
|
40
|
+
var ocsp = require("./schema-ocsp");
|
|
40
41
|
var guard = require("./guard-all");
|
|
41
42
|
var constants = require("./constants");
|
|
42
43
|
|
|
@@ -336,42 +337,17 @@ function builtinVerify(state, cert) {
|
|
|
336
337
|
|
|
337
338
|
// ---- 7.1 name comparison ---------------------------------------------------
|
|
338
339
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
// cannot make two different names compare equal.
|
|
347
|
-
if (typeof v !== "string") return v;
|
|
348
|
-
guard.name.assertNoControlBytes(v, E, "path/name-chaining", "distinguished name");
|
|
349
|
-
return v.trim().replace(/\s+/g, " ").toLowerCase();
|
|
340
|
+
// RFC 5280 sec. 7.1 canonical DN / RDN comparison, via the shared name guard: the
|
|
341
|
+
// canonical form (case-fold + internal-whitespace collapse) and the embedded
|
|
342
|
+
// control-byte reject (CVE-2009-2408 -> path/name-chaining) live once in
|
|
343
|
+
// guard-name, so no path-validation caller can reintroduce a raw-byte DN
|
|
344
|
+
// comparison that treats two RFC 5280-equal names as different.
|
|
345
|
+
function dnEqual(rdnsA, rdnsB) {
|
|
346
|
+
return guard.name.dnEqual(rdnsA, rdnsB, E, "path/name-chaining", "distinguished name");
|
|
350
347
|
}
|
|
351
348
|
|
|
352
349
|
function rdnEqual(a, b) {
|
|
353
|
-
|
|
354
|
-
// An RDN is an unordered SET of type/value pairs; compare as multisets.
|
|
355
|
-
var used = [];
|
|
356
|
-
for (var i = 0; i < a.length; i++) {
|
|
357
|
-
var found = false;
|
|
358
|
-
for (var j = 0; j < b.length; j++) {
|
|
359
|
-
if (used[j]) continue;
|
|
360
|
-
if (a[i].type === b[j].type && normalizeAttrValue(a[i].value) === normalizeAttrValue(b[j].value)) {
|
|
361
|
-
used[j] = true; found = true; break;
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
if (!found) return false;
|
|
365
|
-
}
|
|
366
|
-
return true;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function dnEqual(rdnsA, rdnsB) {
|
|
370
|
-
if (rdnsA.length !== rdnsB.length) return false;
|
|
371
|
-
for (var i = 0; i < rdnsA.length; i++) {
|
|
372
|
-
if (!rdnEqual(rdnsA[i], rdnsB[i])) return false;
|
|
373
|
-
}
|
|
374
|
-
return true;
|
|
350
|
+
return guard.name.rdnEqual(a, b, E, "path/name-chaining", "distinguished name");
|
|
375
351
|
}
|
|
376
352
|
|
|
377
353
|
// ---- extension access ------------------------------------------------------
|
|
@@ -1583,23 +1559,295 @@ function crlEntryReason(entry) {
|
|
|
1583
1559
|
return null;
|
|
1584
1560
|
}
|
|
1585
1561
|
|
|
1586
|
-
|
|
1562
|
+
// Verify a raw signature over tbsBytes with an SPKI public key -- the shared core
|
|
1563
|
+
// of every certificate / CRL / OCSP signature check. Resolve the algorithm
|
|
1564
|
+
// descriptor, enforce the key-OID <-> sig-OID binding (the algorithm-confusion
|
|
1565
|
+
// guard, RFC 9814 sec. 4), import the SPKI, bridge an ECDSA DER signature to
|
|
1566
|
+
// P1363, and verify. Any fault -- an unresolvable/forbidden algorithm, a
|
|
1567
|
+
// key/sig mismatch, an import or verify failure -- resolves false: a signature
|
|
1568
|
+
// check never throws out of this path, it fails closed. `rawSig` is the raw
|
|
1569
|
+
// signature octets (the caller has already unwrapped any BIT STRING and rejected
|
|
1570
|
+
// a non-octet-aligned one).
|
|
1571
|
+
function _verifyWithSpki(sigAlg, rawSig, spkiBytes, tbsBytes) {
|
|
1587
1572
|
var d;
|
|
1588
1573
|
try {
|
|
1589
|
-
d = resolveDescriptor(
|
|
1590
|
-
assertKeyMatchesSigAlg(
|
|
1574
|
+
d = resolveDescriptor(sigAlg);
|
|
1575
|
+
assertKeyMatchesSigAlg(spkiBytes, sigAlg.oid, d);
|
|
1591
1576
|
} catch (_e) { return Promise.resolve(false); }
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
return subtle.importKey("spki", issuer.workingPublicKey, d.imp, false, ["verify"]).then(function (k) {
|
|
1595
|
-
key = k;
|
|
1596
|
-
var sig = theCrl.signatureValue.bytes;
|
|
1577
|
+
return subtle.importKey("spki", spkiBytes, d.imp, false, ["verify"]).then(function (key) {
|
|
1578
|
+
var sig = rawSig;
|
|
1597
1579
|
if (d.ecdsa) sig = ecdsaDerToP1363(sig, key.algorithm.namedCurve);
|
|
1598
|
-
return subtle.verify(d.verify, key, sig,
|
|
1580
|
+
return subtle.verify(d.verify, key, sig, tbsBytes);
|
|
1599
1581
|
}).then(function (ok) { return ok === true; }, function () { return false; });
|
|
1600
1582
|
}
|
|
1601
1583
|
|
|
1584
|
+
function verifyCrlSignature(theCrl, issuer) {
|
|
1585
|
+
if (theCrl.signatureValue.unusedBits !== 0) return Promise.resolve(false); // non-octet-aligned signature
|
|
1586
|
+
return _verifyWithSpki(theCrl.signatureAlgorithm, theCrl.signatureValue.bytes, issuer.workingPublicKey, theCrl.tbsBytes);
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// ---- the OCSP revocation checker (RFC 6960) -------------------------------
|
|
1590
|
+
|
|
1591
|
+
// CertID identity-hash algorithms (RFC 6960 sec. 4.1.1). Kept SEPARATE from the
|
|
1592
|
+
// signature SIG_ALGS/HASH_BY_OID set (which omits SHA-1 -- a SHA-1 *signature* is
|
|
1593
|
+
// refused, SHAttered): a CertID hash is an identity binding of an already-known
|
|
1594
|
+
// issuer, not a signature, so collision resistance is irrelevant to the lookup
|
|
1595
|
+
// and RFC 6960's default SHA-1 CertID MUST interoperate. A hash OID outside this
|
|
1596
|
+
// set cannot be reproduced -> no CertID match (fail closed), never an assumption.
|
|
1597
|
+
var OCSP_CERTID_HASHES = {};
|
|
1598
|
+
OCSP_CERTID_HASHES[oid.byName("sha1")] = "SHA-1";
|
|
1599
|
+
OCSP_CERTID_HASHES[oid.byName("sha256")] = "SHA-256";
|
|
1600
|
+
OCSP_CERTID_HASHES[oid.byName("sha384")] = "SHA-384";
|
|
1601
|
+
OCSP_CERTID_HASHES[oid.byName("sha512")] = "SHA-512";
|
|
1602
|
+
var OID_OCSP_SIGNING = oid.byName("ocspSigning");
|
|
1603
|
+
var OID_OCSP_NOCHECK = oid.byName("ocspNoCheck");
|
|
1604
|
+
|
|
1605
|
+
// The subjectPublicKey BIT STRING VALUE (excluding the unused-bits octet) of an
|
|
1606
|
+
// SPKI DER -- the exact bytes an OCSP CertID issuerKeyHash / byKey KeyHash hash
|
|
1607
|
+
// over (RFC 6960 sec. 4.1.1). Throws on a malformed SPKI; the caller fails closed.
|
|
1608
|
+
function ocspKeyValue(spkiDer) {
|
|
1609
|
+
return asn1.read.bitString(asn1.decode(spkiDer).children[1]).bytes;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// The delegate responder's importable SPKI. If its key omits algorithm parameters
|
|
1613
|
+
// and inherits them from the issuing CA (same key algorithm -- an EC public key
|
|
1614
|
+
// whose SPKI omits the namedCurve, RFC 5280 sec. 4.1.2.7), splice the issuer's
|
|
1615
|
+
// parameters in so importKey("spki", ...) has a complete key; otherwise a valid
|
|
1616
|
+
// response would verify as unknown. Mirrors updateWorkingKey's inheritance.
|
|
1617
|
+
function ocspResponderSpki(rc, issuer) {
|
|
1618
|
+
var keyAlg = rc.subjectPublicKeyInfo.algorithm;
|
|
1619
|
+
if (!isNullOrAbsentParams(keyAlg.parameters)) return rc.subjectPublicKeyInfo.bytes;
|
|
1620
|
+
var issuerOid, issuerParams;
|
|
1621
|
+
try {
|
|
1622
|
+
var alg = asn1.decode(issuer.workingPublicKey).children[0];
|
|
1623
|
+
issuerOid = asn1.read.oid(alg.children[0]);
|
|
1624
|
+
issuerParams = alg.children[1] ? alg.children[1].bytes : null;
|
|
1625
|
+
} catch (_e) { return rc.subjectPublicKeyInfo.bytes; }
|
|
1626
|
+
if (issuerOid === keyAlg.oid && !isNullOrAbsentParams(issuerParams)) {
|
|
1627
|
+
return spliceSpkiParameters(rc.subjectPublicKeyInfo, keyAlg.oid, issuerParams);
|
|
1628
|
+
}
|
|
1629
|
+
return rc.subjectPublicKeyInfo.bytes;
|
|
1630
|
+
}
|
|
1631
|
+
function ocspDigest(alg, buf) { return subtle.digest(alg, buf).then(function (h) { return Buffer.from(h); }); }
|
|
1632
|
+
|
|
1633
|
+
// The checker implements NO OCSP response-extension semantics (the nonce is the
|
|
1634
|
+
// live client's, RFC 6960 sec. 4.4.1; CRL References / Archive Cutoff / Service
|
|
1635
|
+
// Locator are not consulted), so a CRITICAL responseExtension / singleExtension
|
|
1636
|
+
// may change a response's meaning in a way this code cannot honor -- treat it as
|
|
1637
|
+
// unusable (RFC 6960 sec. 4.4 / the RFC 5280 critical-extension contract, the OCSP
|
|
1638
|
+
// analogue of the crlChecker unhandled-critical skip). Returns true if `extList`
|
|
1639
|
+
// carries any critical extension.
|
|
1640
|
+
function ocspHasCriticalExtension(extList) {
|
|
1641
|
+
if (!extList) return false;
|
|
1642
|
+
for (var i = 0; i < extList.length; i++) if (extList[i].critical) return true;
|
|
1643
|
+
return false;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
// A SingleResponse's CertID names the target cert IFF its serial equals the
|
|
1647
|
+
// target's AND issuerNameHash + issuerKeyHash, recomputed under the CertID's OWN
|
|
1648
|
+
// hashAlgorithm, match the issuer (RFC 6960 sec. 4.1.1). A serial-only match would
|
|
1649
|
+
// let a "good" for issuer A serial N answer a query for issuer B serial N
|
|
1650
|
+
// (cross-CA substitution), so BOTH issuer hashes must match under the responder's
|
|
1651
|
+
// chosen algorithm -- never an assumed SHA-1.
|
|
1652
|
+
//
|
|
1653
|
+
// `issuerNameCandidates` is every byte encoding of the (name-chaining-validated)
|
|
1654
|
+
// issuer DN to try: the checked cert's issuer field (RFC 6960 sec. 4.1.1) plus,
|
|
1655
|
+
// when available, the issuer certificate's own subject encoding. These are RFC
|
|
1656
|
+
// 5280 sec. 7.1-equal (they chained) but MAY differ in DER -- case, whitespace, a
|
|
1657
|
+
// PrintableString-vs-UTF8String DirectoryString choice -- and a responder MAY have
|
|
1658
|
+
// hashed any of them; matching the CertID against any is still the SAME validated
|
|
1659
|
+
// issuer, because the issuerKeyHash + serial + authorized signature carry the
|
|
1660
|
+
// binding. The key (raw octets, no encoding variance) is matched once.
|
|
1661
|
+
async function ocspCertIdMatches(certID, cert, issuerNameCandidates, issuerKeyBits) {
|
|
1662
|
+
if (certID.serialNumberHex !== cert.serialNumberHex) return false;
|
|
1663
|
+
var hashName = OCSP_CERTID_HASHES[certID.hashAlgorithm.oid];
|
|
1664
|
+
if (!hashName) return false; // an unreproducible hash -> the match cannot be confirmed
|
|
1665
|
+
var keyHash = await ocspDigest(hashName, issuerKeyBits);
|
|
1666
|
+
if (!certID.issuerKeyHash.equals(keyHash)) return false;
|
|
1667
|
+
for (var i = 0; i < issuerNameCandidates.length; i++) {
|
|
1668
|
+
if (certID.issuerNameHash.equals(await ocspDigest(hashName, issuerNameCandidates[i]))) return true;
|
|
1669
|
+
}
|
|
1670
|
+
return false;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// Resolve the signer of a BasicOCSPResponse to an AUTHORIZED responder's SPKI DER,
|
|
1674
|
+
// or null when none is authorized (RFC 6960 sec. 4.2.2.2). Two models: the issuing
|
|
1675
|
+
// CA signing directly (responderID identifies the issuer), or a CA-delegated
|
|
1676
|
+
// responder -- a certificate in `certs` issued by the SAME CA, valid at `time`,
|
|
1677
|
+
// bearing id-kp-OCSPSigning in its extendedKeyUsage. anyExtendedKeyUsage and an
|
|
1678
|
+
// ABSENT EKU do NOT authorize an OCSP delegate (the opposite of the path-validation
|
|
1679
|
+
// EKU default), so an ordinary leaf the CA issued cannot forge revocation status.
|
|
1680
|
+
// Fails closed at every branch (a malformed delegate, a control-byte DN, an
|
|
1681
|
+
// unreadable EKU -> skip that candidate).
|
|
1682
|
+
async function ocspAuthorizeResponder(basicResponse, cert, issuer, issuerKeyBits, time) {
|
|
1683
|
+
var rid = basicResponse.responderID;
|
|
1684
|
+
var matchesIssuer = false;
|
|
1685
|
+
try {
|
|
1686
|
+
if (rid.byName) matchesIssuer = dnEqual(rid.byName.rdns, cert.issuer.rdns);
|
|
1687
|
+
else if (rid.byKey) matchesIssuer = rid.byKey.equals(await ocspDigest("SHA-1", issuerKeyBits));
|
|
1688
|
+
} catch (_e) { matchesIssuer = false; }
|
|
1689
|
+
if (matchesIssuer) return issuer.workingPublicKey;
|
|
1690
|
+
|
|
1691
|
+
for (var i = 0; i < basicResponse.certs.length; i++) {
|
|
1692
|
+
var rc;
|
|
1693
|
+
try { rc = x509.parse(basicResponse.certs[i]); }
|
|
1694
|
+
catch (_e) { continue; }
|
|
1695
|
+
var identifies = false;
|
|
1696
|
+
try {
|
|
1697
|
+
if (rid.byName) identifies = dnEqual(rid.byName.rdns, rc.subject.rdns);
|
|
1698
|
+
else if (rid.byKey) identifies = rid.byKey.equals(await ocspDigest("SHA-1", ocspKeyValue(rc.subjectPublicKeyInfo.bytes)));
|
|
1699
|
+
} catch (_e) { identifies = false; }
|
|
1700
|
+
if (!identifies) continue;
|
|
1701
|
+
// The delegate MUST be issued directly by the CA that issued the target.
|
|
1702
|
+
var issuedByCa;
|
|
1703
|
+
try { issuedByCa = dnEqual(rc.issuer.rdns, cert.issuer.rdns); }
|
|
1704
|
+
catch (_e) { continue; }
|
|
1705
|
+
if (!issuedByCa) continue;
|
|
1706
|
+
if (rc.signatureValue.unusedBits !== 0) continue;
|
|
1707
|
+
if (!(await _verifyWithSpki(rc.signatureAlgorithm, rc.signatureValue.bytes, issuer.workingPublicKey, rc.tbsBytes))) continue;
|
|
1708
|
+
// The delegate certificate MUST itself be valid at the validation instant.
|
|
1709
|
+
if (time < rc.validity.notBefore || time > rc.validity.notAfter) continue;
|
|
1710
|
+
// The delegate MUST assert id-kp-OCSPSigning; anyEKU / an absent EKU do not.
|
|
1711
|
+
var eku;
|
|
1712
|
+
try { eku = decodeExt(rc, OID.extKeyUsage); }
|
|
1713
|
+
catch (_e) { continue; }
|
|
1714
|
+
if (!eku || eku.value.indexOf(OID_OCSP_SIGNING) === -1) continue;
|
|
1715
|
+
// A delegate asserting keyUsage MUST permit digitalSignature -- signing an OCSP
|
|
1716
|
+
// response is a digitalSignature operation (RFC 5280 sec. 4.2.1.3); an absent
|
|
1717
|
+
// keyUsage is unrestricted, an unreadable one is not authoritative.
|
|
1718
|
+
var ku;
|
|
1719
|
+
try { ku = decodeExt(rc, OID.keyUsage); }
|
|
1720
|
+
catch (_e) { continue; }
|
|
1721
|
+
if (ku && ku.value.digitalSignature !== true) continue;
|
|
1722
|
+
// A delegate carrying a critical extension this code does not process is
|
|
1723
|
+
// unusable, the same fail-closed rule the path validator applies to any cert
|
|
1724
|
+
// (RFC 5280 sec. 6.1.4(o)) -- an unknown critical constraint must not be ignored
|
|
1725
|
+
// while its key is trusted to authenticate revocation status. A RECOGNIZED
|
|
1726
|
+
// critical extension whose value is malformed is likewise rejected via the
|
|
1727
|
+
// shared structure validation, exactly as the path validator rejects it.
|
|
1728
|
+
if (unrecognizedCriticalExtension(rc, false)) continue;
|
|
1729
|
+
if (validateCriticalExtensionStructure(rc)) continue;
|
|
1730
|
+
// The delegate MUST carry id-pkix-ocsp-nocheck (RFC 6960 sec. 4.2.2.2.1): a
|
|
1731
|
+
// transport-free checker cannot otherwise confirm the responder certificate has
|
|
1732
|
+
// not itself been revoked, and a revoked OCSP-signing certificate would keep
|
|
1733
|
+
// signing "good" until its notAfter. A deployment that instead supplies the
|
|
1734
|
+
// responder's own status opts in through a future checker; absent nocheck, fail
|
|
1735
|
+
// closed (unknown) rather than trust an unvalidated responder.
|
|
1736
|
+
if (!findExt(rc, OID_OCSP_NOCHECK)) continue;
|
|
1737
|
+
return ocspResponderSpki(rc, issuer);
|
|
1738
|
+
}
|
|
1739
|
+
return null;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
/**
|
|
1743
|
+
* @primitive pki.path.ocspChecker
|
|
1744
|
+
* @signature pki.path.ocspChecker(responses) -> RevocationChecker
|
|
1745
|
+
* @since 0.1.32
|
|
1746
|
+
* @status experimental
|
|
1747
|
+
* @spec RFC 6960
|
|
1748
|
+
* @related pki.path.validate, pki.schema.ocsp.parseResponse, pki.path.crlChecker
|
|
1749
|
+
*
|
|
1750
|
+
* Build an OCSP-backed `RevocationChecker` for `pki.path.validate`'s
|
|
1751
|
+
* `revocationChecker` option from a set of pre-fetched OCSP responses (DER/PEM
|
|
1752
|
+
* or already-parsed). For each certificate it locates a SingleResponse whose
|
|
1753
|
+
* CertID binds this cert's serial to its issuer -- recomputing `issuerNameHash`
|
|
1754
|
+
* and `issuerKeyHash` under the CertID's own hashAlgorithm (SHA-1 or SHA-2), so
|
|
1755
|
+
* a response using either matches -- confirms the responder is authorized (the
|
|
1756
|
+
* issuing CA directly, or a valid CA-issued delegate bearing both id-kp-OCSPSigning
|
|
1757
|
+
* and id-pkix-ocsp-nocheck), verifies the response signature over
|
|
1758
|
+
* `tbsResponseDataBytes`, checks currency
|
|
1759
|
+
* (`thisUpdate`/`nextUpdate`), and reports `{ status: "good"|"revoked"|
|
|
1760
|
+
* "unknown" }`. A wrong-issuer CertID, an unauthorized responder, a stale,
|
|
1761
|
+
* not-yet-valid, nextUpdate-less, non-successful, or unverifiable response
|
|
1762
|
+
* yields `unknown`, which the validator fails closed unless `softFail` is set;
|
|
1763
|
+
* a `revoked` status surfaces its `revocationReason`. It is transport-free: the
|
|
1764
|
+
* caller supplies bytes it collected (an OCSP fetch or a stapled response), so
|
|
1765
|
+
* nonce anti-replay is the live client's responsibility and the residual replay
|
|
1766
|
+
* defense is the `thisUpdate`/`nextUpdate` currency window.
|
|
1767
|
+
*
|
|
1768
|
+
* @example
|
|
1769
|
+
* var checker = pki.path.ocspChecker([]); // no responses -> every cert is "unknown"
|
|
1770
|
+
* typeof checker.check; // "function"
|
|
1771
|
+
*/
|
|
1772
|
+
function ocspChecker(responses) {
|
|
1773
|
+
var parsed = (responses || []).map(function (r) { return (r && r.responseStatus) ? r : ocsp.parseResponse(r); });
|
|
1774
|
+
return {
|
|
1775
|
+
check: async function (cert, issuer, ctx) {
|
|
1776
|
+
var time = ctx.time;
|
|
1777
|
+
var historical = ctx.historicalMode === true;
|
|
1778
|
+
// Issuer DN candidates to match the CertID against (RFC 6960 sec. 4.1.1 names
|
|
1779
|
+
// the checked cert's issuer field; a response MAY instead carry the issuer
|
|
1780
|
+
// certificate's own subject encoding -- sec. 7.1-equal but not byte-identical).
|
|
1781
|
+
var issuerNameCandidates = [cert.issuer.bytes];
|
|
1782
|
+
function addNameCandidate(nm) {
|
|
1783
|
+
if (nm && nm.bytes && !issuerNameCandidates.some(function (e) { return e.equals(nm.bytes); })) issuerNameCandidates.push(nm.bytes);
|
|
1784
|
+
}
|
|
1785
|
+
if (issuer.issuerCert) addNameCandidate(issuer.issuerCert.subject);
|
|
1786
|
+
addNameCandidate(issuer.workingIssuerName);
|
|
1787
|
+
var issuerKeyBits;
|
|
1788
|
+
try { issuerKeyBits = ocspKeyValue(issuer.workingPublicKey); }
|
|
1789
|
+
catch (_e) { return { status: "unknown", reason: "the issuer public key could not be read to recompute the OCSP CertID" }; }
|
|
1790
|
+
|
|
1791
|
+
// A serial is revoked if ANY authoritative, verified, current response says
|
|
1792
|
+
// so -- a clean response must never shadow a revoking one (the crlChecker
|
|
1793
|
+
// fail-closed law). "good" needs at least one authoritative match; every
|
|
1794
|
+
// other outcome is undetermined.
|
|
1795
|
+
var revokedResult = null;
|
|
1796
|
+
var sawGood = false;
|
|
1797
|
+
var sawUnknownStatus = false;
|
|
1798
|
+
|
|
1799
|
+
for (var k = 0; k < parsed.length; k++) {
|
|
1800
|
+
var resp = parsed[k];
|
|
1801
|
+
if (resp.responseStatus.code !== 0) continue; // non-successful -> conveys no status
|
|
1802
|
+
var br = resp.basicResponse;
|
|
1803
|
+
if (!br) continue;
|
|
1804
|
+
var signerSpki = await ocspAuthorizeResponder(br, cert, issuer, issuerKeyBits, time);
|
|
1805
|
+
if (!signerSpki) continue; // unauthorized responder
|
|
1806
|
+
if (!(await _verifyWithSpki(br.signatureAlgorithm, br.signature, signerSpki, br.tbsResponseDataBytes))) continue;
|
|
1807
|
+
// A critical responseExtension changes the meaning of the WHOLE response
|
|
1808
|
+
// and this code processes none -> the response is unusable.
|
|
1809
|
+
if (ocspHasCriticalExtension(br.responseExtensions)) continue;
|
|
1810
|
+
for (var s = 0; s < br.responses.length; s++) {
|
|
1811
|
+
var sr = br.responses[s];
|
|
1812
|
+
if (!(await ocspCertIdMatches(sr.certID, cert, issuerNameCandidates, issuerKeyBits))) continue; // not about this cert
|
|
1813
|
+
if (ocspHasCriticalExtension(sr.singleExtensions)) continue; // a critical per-response extension this code cannot process -> skip
|
|
1814
|
+
if (sr.thisUpdate > time) continue; // not yet valid
|
|
1815
|
+
if (!sr.nextUpdate || sr.nextUpdate < time) continue; // no bounded validity / stale -> fail closed
|
|
1816
|
+
var st = sr.certStatus;
|
|
1817
|
+
if (st.type === "revoked") {
|
|
1818
|
+
// A revocation is effective as of its revocationTime. Present-time
|
|
1819
|
+
// validation revokes regardless (a future revocationTime is
|
|
1820
|
+
// post-dating/skew, never "good"); only an EXPLICIT historical
|
|
1821
|
+
// validation defers a strictly-future revocation.
|
|
1822
|
+
if (historical && st.revocationTime instanceof Date && st.revocationTime.getTime() > time.getTime()) { sawGood = true; }
|
|
1823
|
+
else {
|
|
1824
|
+
revokedResult = {
|
|
1825
|
+
status: "revoked",
|
|
1826
|
+
revocationReason: st.revocationReason || null,
|
|
1827
|
+
reason: "certificate reported revoked by an authorized OCSP responder" + (st.revocationReason ? " (" + st.revocationReason + ")" : ""),
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
} else if (st.type === "good") {
|
|
1831
|
+
sawGood = true;
|
|
1832
|
+
} else {
|
|
1833
|
+
sawUnknownStatus = true; // an explicit responder "unknown"
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
if (revokedResult) return revokedResult;
|
|
1838
|
+
if (sawGood) return { status: "good" };
|
|
1839
|
+
return {
|
|
1840
|
+
status: "unknown",
|
|
1841
|
+
reason: sawUnknownStatus
|
|
1842
|
+
? "the OCSP responder reported certStatus unknown for this certificate"
|
|
1843
|
+
: "no authoritative, current, in-scope OCSP response covers this certificate",
|
|
1844
|
+
};
|
|
1845
|
+
},
|
|
1846
|
+
};
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1602
1849
|
module.exports = {
|
|
1603
1850
|
validate: validate,
|
|
1604
1851
|
crlChecker: crlChecker,
|
|
1852
|
+
ocspChecker: ocspChecker,
|
|
1605
1853
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blamejs/pki",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.32",
|
|
4
4
|
"description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "blamejs contributors",
|
|
@@ -72,6 +72,6 @@
|
|
|
72
72
|
"devDependencies": {
|
|
73
73
|
"c8": "11.0.0",
|
|
74
74
|
"esbuild": "0.28.1",
|
|
75
|
-
"eslint": "10.
|
|
75
|
+
"eslint": "10.7.0"
|
|
76
76
|
}
|
|
77
77
|
}
|
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:87ae1f84-9b42-4b45-820b-1323915f3fbe",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-11T18:49:24.325Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/pki@0.1.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.1.32",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.1.
|
|
25
|
+
"version": "0.1.32",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/pki@0.1.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.1.32",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/pki@0.1.
|
|
57
|
+
"ref": "@blamejs/pki@0.1.32",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|