@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 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 rejection of an embedded control byte in a
10
- // distinguished-name / SAN string. Two policies over one concern.
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
- module.exports = { assertNoControlBytes: assertNoControlBytes, assertPrintableIa5: assertPrintableIa5 };
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 };
@@ -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
- function normalizeAttrValue(v) {
340
- // RFC 5280 7.1 canonical DN comparison: case-fold + collapse internal
341
- // whitespace. Every standard X.520 DN attribute (CN/O/OU/L/ST/C/serialNumber/
342
- // dnQualifier) uses caseIgnoreMatch, and this canonical form matches OpenSSL's
343
- // X509_NAME_cmp -- applying it uniformly is the interoperable behavior, not a
344
- // per-attribute caseExact rule that would reject a chain OpenSSL accepts.
345
- // Reject embedded NUL / control bytes so a truncation attack (CVE-2009-2408)
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
- if (a.length !== b.length) return false;
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
- function verifyCrlSignature(theCrl, issuer) {
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(theCrl.signatureAlgorithm);
1590
- assertKeyMatchesSigAlg(issuer.workingPublicKey, theCrl.signatureAlgorithm.oid, d);
1574
+ d = resolveDescriptor(sigAlg);
1575
+ assertKeyMatchesSigAlg(spkiBytes, sigAlg.oid, d);
1591
1576
  } catch (_e) { return Promise.resolve(false); }
1592
- if (theCrl.signatureValue.unusedBits !== 0) return Promise.resolve(false); // non-octet-aligned signature
1593
- var key;
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, theCrl.tbsBytes);
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.31",
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.3.0"
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:2ed2aa23-5194-4472-96f3-c4013f134834",
5
+ "serialNumber": "urn:uuid:87ae1f84-9b42-4b45-820b-1323915f3fbe",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-11T16:09:44.713Z",
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.31",
22
+ "bom-ref": "@blamejs/pki@0.1.32",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.1.31",
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.31",
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.31",
57
+ "ref": "@blamejs/pki@0.1.32",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]