@blamejs/pki 0.4.14 → 0.4.15

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,31 @@ 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.4.15 — 2026-08-11
8
+
9
+ A CA that partitions revocations by reason code, or publishes a delta CRL alongside its base, now gets a real answer instead of "undetermined".
10
+
11
+ ### Added
12
+
13
+ - Reason coverage ACCUMULATES. Each CRL that corresponds to one of the certificate's distribution points contributes its interim reason mask (RFC 5280 sec. 6.3.3(d)(1)-(4)), and the certificate reads good once the CRLs together cover all eight revocation reasons (sec. 6.3.3(l)). Previously only a CRL that covered every reason by itself could establish good, so a reason-partitioned CA could never be satisfied. Partial coverage still fails closed, and a shard that does not correspond to the certificate contributes nothing while still being consulted for revocation.
14
+ - Delta CRLs are MERGED onto a complete CRL they may be combined with (sec. 5.2.4(a)-(d), sec. 6.3.3(c)): same issuer, byte-identical issuing distribution point and authority key identifier, and a base number the complete CRL's own number covers. The delta is searched first, the complete CRL only if the delta left the certificate unrevoked, and a removeFromCRL entry then releases it -- so a certificate placed on hold and later released reaches good rather than staying rejected. A delta is merged only when the certificate or the complete CRL carries a freshestCRL locator (sec. 6.3.3(a)(2)).
15
+ - pki.path.crlChecker(crls, opts) takes opts.useDeltas (sec. 6.3.1(b)), default true. With it false a delta is never merged; it is still consulted for revocation.
16
+ - A revoked verdict carries reasonCode -- the CRLReason integer, 0 for unspecified when the entry has no reasonCode extension -- and a reason naming it, so an operator learns that a certificate was revoked for keyCompromise rather than only that it was revoked.
17
+
18
+ ### Changed
19
+
20
+ - Merging can only ever turn an undetermined verdict into good or revoked. A delta that combines with no complete CRL held locally is still consulted for the revocations it lists, and still withholds good, so an unmergeable delta -- including one naming a base the verifier does not have -- can never erase a revocation. Where several current deltas exist for one scope, which RFC 5280 sec. 5.2.4 permits, the one with the latest thisUpdate is selected rather than the set being treated as a fault.
21
+ - The reasons field of a certificate's cRLDistributionPoints is now rejected unless minimally encoded, matching the rule already applied to keyUsage (X.690 sec. 11.2.2 named bit lists). Two encodings of one reason set previously both parsed, which would leave the reason intersection computed over an encoding the rules forbid.
22
+ - A delta CRL indicator that is not marked critical, which RFC 5280 sec. 5.2.4 requires it to be, does not make the CRL mergeable. Such a CRL is still treated as a delta and still consulted for the revocations it lists, as before, but it cannot release a certificate its base revoked -- releasing rests on a conforming indicator.
23
+ - An issuing distribution point that is not marked critical cannot contribute reason coverage at all. Such an extension is one a relying party may ignore entirely, so building a good verdict on the scope it declares would rest on something another verifier would not see -- the same fail-closed reasoning already applied to distribution-point correspondence. It still restricts nothing and withholds good, exactly as before this release.
24
+ - A CRL number past the RFC 5280 sec. 5.2.3 twenty-octet ceiling does not make a CRL mergeable, matching the bound pki.crl.sign already enforces when emitting one. Such a CRL is still consulted for the revocations it lists; only the ability to release a certificate is withheld.
25
+
26
+ ### Fixed
27
+
28
+ - Holding a delta CRL alongside its base is no longer worse than holding the base alone. Any authoritative delta previously forced the whole verdict to undetermined.
29
+ - A CRL that covers no revocation reasons for the certificate -- a shard whose distribution point does not correspond to it -- is now checked for currency and signature before it is consulted at all. Such a CRL is still read for revocations, so without those checks an expired or forged one could have revoked a certificate it never legitimately covered, or, as a delta, released a certificate its base genuinely revoked.
30
+ - A delta CRL superseded by a more recent one for the same scope no longer affects the verdict in either direction. It could previously contribute a revocation that the selected, later delta had released -- resurrecting a revocation the CA withdrew -- while its own release was correctly ignored.
31
+
7
32
  ## v0.4.14 — 2026-08-10
8
33
 
9
34
  Every key-establishment secret this library allocates is now wiped when it stops being needed -- the classical ones too, not only the post-quantum ones.
package/README.md CHANGED
@@ -223,7 +223,7 @@ is callable today; nothing below is a stub.
223
223
  | `pki.acme` | RFC 8555 / 8737 / 8738 / 9773 ACME — `client(directoryUrl, opts)` is a stateful client that drives a live CA directory over `pki.transport` (inject your own, or the fail-closed default): `newAccount` / `newOrder` / `newAuthz` / `getOrder` / `getAuthorization` / `getChallenge` / `respondToChallenge` / `finalize` / `pollOrder` / `pollAuthorization` / `downloadCertificate` walk the issuance flow — `newAuthz` pre-authorizes a single identifier (§7.4.1) and `downloadCertificate` picks among alternate issuance chains (`Link rel="alternate"`, §7.4.2, via a `selectChain` predicate bounded by `maxAlternates`) — and `revokeCert` (account-key or certificate-key signed), `keyChange` (account key rotation), `deactivateAccount` / `deactivateAuthorization`, `renewalInfo` (ARI), and `renewalWindow` (the RFC 9773 §4.2/4.3 renewal decision) round out the lifecycle — https-only for every URL, an explicit trust anchor required, a fresh single-use nonce per request with a bounded badNonce retry, POST-as-GET reads, bounded polling that sleeps on a Retry-After via an injectable sleeper (capped by a poll count and a total-wait budget), and every response body size-capped. Over the message layer it composes: 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), fail-closed — `client`, `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
224
224
  | `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` |
225
225
  | `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk` / `encode` / `embeddedDer` plus the schema combinators |
226
- | `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining across RSA, ECDSA, EdDSA, ML-DSA, SLH-DSA and hybrid composite ML-DSA signatures — a composite is accepted only when **both** its post-quantum and traditional components verify; 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, and enforces a `pki.trust` anchor's per-purpose distrust-after dates and delegator purposes via `checkPurpose`; `crlChecker` supplies CRL-based revocation — including partitioned/sharded CRLs, whose §6.3.3 Distribution Point ↔ IDP correspondence lets a corresponding full-reason shard establish non-revocation — and `ocspChecker` supplies OCSP-based revocation (RFC 6960 — CertID binding, responder authorization, signature, currency) over the same pluggable hook. `build(leaf, opts)` is the discovering complement (RFC 4158): from a leaf, an untrusted pool of candidate CA certificates, and a trust store, it finds the ordered leaf→anchor path `validate` accepts — name chaining plus the RFC 4158 §3.5 sort hints (AKI/SKI match, anchor-adjacent issuer, CA + keyCertSign, validity), a depth-first search with backtracking so the first path `validate` accepts wins, and a bounded search (chain-length cap, candidate-expansion cap, identity-tuple visited-set) so a cross-certificate cycle or Bridge-CA fan-out terminates deterministically; every accept flows through `validate` and its verdict is cross-checked against `openssl verify`. **Opt-in AIA `caIssuers` fetching** (`opts.fetchAia: true`) discovers a *missing* intermediate from a certificate's Authority Information Access URL (RFC 5280 §4.2.2.1) over `pki.transport`, triggered only on a pool miss — SSRF/amplification-bounded (https-only, a total fetch budget that silently caps fetching rather than throwing, a per-cert URL cap, a build-wide URL dedupe, a response-size + certificate-count cap, no redirect following; every fault a silent skip), with the TLS trust (`opts.tls`) kept distinct from the PKI `trustAnchors` and every fetched certificate remaining untrusted pool material that still flows through `validate` (never a trust anchor). Off by default — the default build is byte-identical offline. Pure and re-entrant, fail-closed — `validate`, `build`, `crlChecker`, `ocspChecker` |
226
+ | `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining across RSA, ECDSA, EdDSA, ML-DSA, SLH-DSA and hybrid composite ML-DSA signatures — a composite is accepted only when **both** its post-quantum and traditional components verify; 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, and enforces a `pki.trust` anchor's per-purpose distrust-after dates and delegator purposes via `checkPurpose`; `crlChecker` supplies CRL-based revocation — including partitioned/sharded CRLs, whose §6.3.3 Distribution Point ↔ IDP correspondence lets corresponding shards ACCUMULATE reason coverage until all eight revocation reasons are covered, and delta CRLs, merged onto the complete CRL they may be combined with (sec. 5.2.4) so a held certificate its delta releases reads good, while a delta that merges with nothing still reports what it lists and still withholds good — and `ocspChecker` supplies OCSP-based revocation (RFC 6960 — CertID binding, responder authorization, signature, currency) over the same pluggable hook. `build(leaf, opts)` is the discovering complement (RFC 4158): from a leaf, an untrusted pool of candidate CA certificates, and a trust store, it finds the ordered leaf→anchor path `validate` accepts — name chaining plus the RFC 4158 §3.5 sort hints (AKI/SKI match, anchor-adjacent issuer, CA + keyCertSign, validity), a depth-first search with backtracking so the first path `validate` accepts wins, and a bounded search (chain-length cap, candidate-expansion cap, identity-tuple visited-set) so a cross-certificate cycle or Bridge-CA fan-out terminates deterministically; every accept flows through `validate` and its verdict is cross-checked against `openssl verify`. **Opt-in AIA `caIssuers` fetching** (`opts.fetchAia: true`) discovers a *missing* intermediate from a certificate's Authority Information Access URL (RFC 5280 §4.2.2.1) over `pki.transport`, triggered only on a pool miss — SSRF/amplification-bounded (https-only, a total fetch budget that silently caps fetching rather than throwing, a per-cert URL cap, a build-wide URL dedupe, a response-size + certificate-count cap, no redirect following; every fault a silent skip), with the TLS trust (`opts.tls`) kept distinct from the PKI `trustAnchors` and every fetched certificate remaining untrusted pool material that still flows through `validate` (never a trust anchor). Off by default — the default build is byte-identical offline. Pure and re-entrant, fail-closed — `validate`, `build`, `crlChecker`, `ocspChecker` |
227
227
  | `pki.x509` | X.509 certificate issuance (RFC 5280 §4) — `sign(spec, issuer, opts)` builds and signs a certificate: a `spec` of subject (a common-name string, an array of RDNs, or raw Name DER), the public key being certified, the validity window, an optional serial, and an optional `extensions` object; an `issuer` that is a key alone (self-signed — issuer equals subject, signed with that key) or a name + public key + key, or an issuing certificate + key (CA-signed). The signature algorithm is resolved from the signing key through the shared registry, so RSA (PKCS#1 v1.5 / PSS via `opts.pss`), ECDSA P-256/384/521, Ed25519, Ed448, ML-DSA-44/65/87, the twelve SLH-DSA sets, and the composite arms all issue without a per-algorithm branch. It encodes basic constraints, key usage, extended key usage, subject and authority key identifiers (the SKI auto-derived by SHA-1 of the subject key), subject alternative names, and certificate policies from the spec — any other extension supplied as pre-encoded DER — derives the version from the field set, and enforces the serial bounds, the UTCTime/GeneralizedTime cutover, the DER default omissions, and the CA cross-field rules; a violation throws a typed `CertificateError`. Returns DER, or a PEM `CERTIFICATE` with `opts.pem`; every arm is independently verified by OpenSSL. Parsing stays at `pki.schema.x509.parse` — `sign` |
228
228
  | `pki.csr` | PKCS#10 certification-request issuance (RFC 2986 / RFC 2985) — `sign(spec, key, opts)` builds and signs a `CertificationRequest`: a `spec` of subject (a common-name string, an array of RDNs, or raw Name DER; may be empty), the public key being certified, an optional `extensionRequest` (requested v3 extensions — subject alternative names, key usage, extended key usage, basic constraints, certificate policies, subject key identifier, or an array of pre-encoded Extension DER — that a CA copies into the issued certificate), and an optional `challengePassword`. `key` (or `{ key }`) is the subject's own private key: the request is self-signed to prove possession of the private half of `subjectPublicKey`, and that proof is verified before the request is returned (what `openssl req -verify` checks). The signature algorithm is resolved from the subject key, so RSA (PKCS#1 v1.5 / PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch. Returns DER, or a PEM `CERTIFICATE REQUEST` with `opts.pem`; malformed input throws a typed `CsrError`. Parsing stays at `pki.schema.csr.parse` — `sign` |
229
229
  | `pki.attrcert` | RFC 5755 attribute-certificate issuance — `sign(spec, issuer, opts)` builds and signs an `AttributeCertificate` as an Attribute Authority: a `spec` of `holder` (exactly one of an entity name, a `baseCertificateID` public-key-certificate reference, a `fromCertificate` binding derived from a certificate, or an object digest), the validity window (GeneralizedTime), an optional serial (positive, ≤ 20 octets; randomly generated when omitted), the `attributes` (the privilege syntaxes — role, clearance, group, chargingIdentity, accessIdentity, authenticationInfo — or pre-encoded Attribute DER), and optional `extensions` (auditIdentity, targetInformation, noRevAvail, aaControls, acProxying, authorityKeyIdentifier, or pre-encoded Extension DER, each with its RFC 5755 criticality). An attribute certificate is never self-signed — the `issuer` is the signing AA, supplied as `{ cert, key }` or `{ name, publicKey, key }`. The signature algorithm is resolved from the AA key, so RSA (PKCS#1 v1.5 / PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch, and the signature is verified under the AA public key before the certificate is returned. Returns DER, or a PEM `ATTRIBUTE CERTIFICATE` with `opts.pem`; malformed input throws a typed `AttrCertError`. Parsing stays at `pki.schema.attrcert.parse` — `sign` |
@@ -1469,6 +1469,9 @@ function userConstrainedPolicies(state, n) {
1469
1469
 
1470
1470
  var OID_IDP = oid.byName("issuingDistributionPoint");
1471
1471
  var OID_DELTA_CRL = oid.byName("deltaCRLIndicator");
1472
+ var OID_AUTHORITY_KEY_ID = oid.byName("authorityKeyIdentifier");
1473
+ var OID_CRL_NUMBER = oid.byName("cRLNumber");
1474
+ var OID_FRESHEST_CRL = oid.byName("freshestCRL");
1472
1475
 
1473
1476
  // IssuingDistributionPoint ::= SEQUENCE { distributionPoint [0] OPTIONAL,
1474
1477
  // onlyContainsUserCerts [1] DEFAULT FALSE, onlyContainsCACerts [2] DEFAULT FALSE,
@@ -1489,6 +1492,58 @@ var IDP_SCHEMA = schema.seq([
1489
1492
  ], { minTag: 0, maxTag: 5, unexpectedCode: "path/bad-idp", orderCode: "path/bad-idp" }),
1490
1493
  ], { assert: "sequence", code: "path/bad-idp", what: "IssuingDistributionPoint" });
1491
1494
 
1495
+ // RFC 5280 sec. 6.3.2(a): the legal members of reasons_mask are exactly the eight
1496
+ // named ReasonFlags bits, 1..8 (`unused` bit 0 is not a reason, and `unspecified`
1497
+ // has no ReasonFlags bit at all). 0x1FE is bits 1..8 set.
1498
+ //
1499
+ // NOTE the numbering divergence, which is the trap in this area: a ReasonFlags BIT
1500
+ // is not a CRLReason VALUE. Bits 1..6 coincide with codes 1..6, but bit 7 is
1501
+ // privilegeWithdrawn (code 9) and bit 8 is aACompromise (code 10). sec. 6.3.3 never
1502
+ // needs a mapping between them -- the mask comes only from IDP/DP ReasonFlags and
1503
+ // cert_status only from an entry's CRLReason -- so no bit<->code table exists here,
1504
+ // deliberately. constants.NAMES.REASON_FLAGS and NAMES.CRL_REASON stay separate.
1505
+ var ALL_REASONS = 0x1FE;
1506
+
1507
+ // A ReasonFlags BIT STRING { unusedBits, bytes } -> a bit mask, or null when the
1508
+ // encoding is not minimal DER (X.690 sec. 11.2.2 NamedBitList: trailing zero bits
1509
+ // MUST be dropped). A null tells the caller the CRL's scope is unknown, which is
1510
+ // the same fail-closed posture decodeIdp already takes for a malformed IDP.
1511
+ //
1512
+ // Bits at or above 9 are undefined by sec. 4.2.1.13. They are IGNORED rather than
1513
+ // rejected: an undefined bit can never help reach all-reasons, so ignoring it can
1514
+ // only withhold coverage, never grant it. Reading a bounded prefix also means a
1515
+ // hostile multi-kilobyte ReasonFlags costs nothing.
1516
+ function reasonMaskFromBitString(bs) {
1517
+ if (!bs || !bs.bytes) return null;
1518
+ try { schema.assertMinimalNamedBits(bs.unusedBits, bs.bytes, function (msg) { throw E("path/bad-idp", msg); }); }
1519
+ catch (_e) { return null; }
1520
+ var mask = 0;
1521
+ // From bit 1: sec. 6.3.2(a) defines the legal members as the eight NAMED reasons, and ReasonFlags
1522
+ // bit 0 is -- not a reason at all. Admitting it could not grant coverage (the completeness
1523
+ // test masks with ALL_REASONS) but would make a shard that asserts ONLY bit 0 look like partial
1524
+ // coverage rather than none, which is the wrong verdict to report.
1525
+ for (var bit = 1; bit <= 8; bit++) {
1526
+ var byteI = bit >> 3;
1527
+ if (byteI >= bs.bytes.length) break;
1528
+ if (bs.bytes[byteI] & (0x80 >> (bit & 7))) mask |= (1 << bit);
1529
+ }
1530
+ return mask;
1531
+ }
1532
+
1533
+ // RFC 5280 sec. 6.3.3(d)(1)-(4) -- the interim_reasons_mask, in ONE place.
1534
+ // (1) both present -> intersection
1535
+ // (2) IDP present, DP absent -> the IDP's reasons
1536
+ // (3) IDP absent, DP present -> the DP's reasons
1537
+ // (4) both absent -> all-reasons
1538
+ // A null mask means "present but unreadable"; the caller has already marked such a
1539
+ // CRL unusable, so treating it as 0 here is belt-and-braces, never a coverage grant.
1540
+ function interimReasonMask(idpMask, dpMask) {
1541
+ if (idpMask != null && dpMask != null) return idpMask & dpMask; // (d)(1)
1542
+ if (idpMask != null) return idpMask; // (d)(2)
1543
+ if (dpMask != null) return dpMask; // (d)(3)
1544
+ return ALL_REASONS; // (d)(4)
1545
+ }
1546
+
1492
1547
  function decodeIdp(ext) {
1493
1548
  // Surface the scope flags the checker gates on. ANY structural or value fault
1494
1549
  // -- non-SEQUENCE, unknown/duplicate/out-of-order field tag, a non-DER BOOLEAN,
@@ -1523,6 +1578,15 @@ function decodeIdp(ext) {
1523
1578
  out.onlyUser = flag(m.fields.onlyContainsUserCerts);
1524
1579
  out.onlyCa = flag(m.fields.onlyContainsCACerts);
1525
1580
  out.onlySomeReasons = m.fields.onlySomeReasons.present ? true : null;
1581
+ // The reason BITS, not just their presence: sec. 6.3.3(d)(1)/(d)(2) need the
1582
+ // value. A present-but-non-minimal ReasonFlags leaves the CRL's scope unknown,
1583
+ // which is the same unusable posture every other IDP fault takes -- otherwise
1584
+ // the (d)(1) intersection would be computed over an encoding DER forbids.
1585
+ out.onlySomeReasonsMask = null;
1586
+ if (m.fields.onlySomeReasons.present) {
1587
+ out.onlySomeReasonsMask = reasonMaskFromBitString(m.fields.onlySomeReasons.value);
1588
+ if (out.onlySomeReasonsMask === null) out.malformed = true;
1589
+ }
1526
1590
  out.indirect = flag(m.fields.indirectCRL);
1527
1591
  out.onlyAttr = flag(m.fields.onlyContainsAttributeCerts);
1528
1592
  return out;
@@ -1582,9 +1646,128 @@ function crlIssuerNamesIssuer(cRLIssuer, issuerRdns) {
1582
1646
  return false;
1583
1647
  }
1584
1648
 
1649
+ // The raw extnValue octets of a CRL extension, or null. sec. 6.3.3(c)(2)/(c)(3)
1650
+ // compare the IDP and authorityKeyIdentifier of a delta and its base for identity;
1651
+ // the comparison is over the exact encoded octets (the sec. 5.2.5 @3648
1652
+ // "identical encoding" discipline already used for distributionPoint matching),
1653
+ // never over a re-normalized decode.
1654
+ function crlExtValue(theCrl, wantOid) {
1655
+ for (var i = 0; i < theCrl.crlExtensions.length; i++) {
1656
+ if (theCrl.crlExtensions[i].oid === wantOid) return theCrl.crlExtensions[i].value;
1657
+ }
1658
+ return null;
1659
+ }
1660
+
1661
+ // One pass over the bundle: split complete CRLs from deltas and decode, per CRL,
1662
+ // the values the merge preconditions compare. A delta whose BaseCRLNumber will not
1663
+ // decode as a non-negative INTEGER is unusable AS A DELTA but stays consultable for
1664
+ // revocation -- dropping it wholesale would lose a revocation it lists (T1).
1665
+ // RFC 5280 sec. 5.2.3 @3404 bounds a CRL number at 20 octets, and pki.crl.sign enforces that on
1666
+ // emission. A number past it is non-conforming, so it does not earn the merge -- which can RELEASE
1667
+ // a certificate. The value still decodes and the CRL is still consulted for revocation; only the
1668
+ // new capability is withheld. Measured on the ENCODED length, matching the producer-side check
1669
+ // rather than a re-derived numeric bound.
1670
+ function crlNumberWithinBound(n) {
1671
+ if (typeof n !== "bigint" || n < 0n) return false;
1672
+ try { return asn1.decode(asn1.build.integer(n)).content.length <= 20; }
1673
+ catch (_e) { return false; }
1674
+ }
1675
+
1676
+ function classifyCrls(parsed) {
1677
+ var completes = [], deltas = [];
1678
+ for (var i = 0; i < parsed.length; i++) {
1679
+ var theCrl = parsed[i];
1680
+ var deltaRaw = crlExtValue(theCrl, OID_DELTA_CRL);
1681
+ var deltaCritical = false;
1682
+ for (var dz = 0; dz < theCrl.crlExtensions.length; dz++) {
1683
+ if (theCrl.crlExtensions[dz].oid === OID_DELTA_CRL) { deltaCritical = theCrl.crlExtensions[dz].critical === true; break; }
1684
+ }
1685
+ // schema-crl already decoded cRLNumber to a non-negative BigInt; the IDP,
1686
+ // authorityKeyIdentifier and deltaCRLIndicator values stay RAW octets there,
1687
+ // which is exactly what the byte-identical compares below need.
1688
+ var num = crlExtValue(theCrl, OID_CRL_NUMBER);
1689
+ var rec = {
1690
+ crl: theCrl,
1691
+ crlNumber: crlNumberWithinBound(num) ? num : null,
1692
+ idpRaw: crlExtValue(theCrl, OID_IDP),
1693
+ akiRaw: crlExtValue(theCrl, OID_AUTHORITY_KEY_ID),
1694
+ baseCrlNumber: null,
1695
+ mergeable: false,
1696
+ };
1697
+ if (deltaRaw === null) { completes.push(rec); continue; }
1698
+ try {
1699
+ var n = asn1.read.integer(asn1.decode(deltaRaw));
1700
+ // sec. 5.2.3 @3404: a CRL number may be up to 20 octets. It stays a BigInt
1701
+ // through every comparison below -- narrowing through Number would collapse
1702
+ // large values and make 5.2.4(c)/(d) compare the wrong things.
1703
+ // sec. 5.2.4 @3447 makes deltaCRLIndicator a MUST-be-critical extension. A non-critical one
1704
+ // is non-conforming, so it does not earn the NEW capability of being merged -- merging can
1705
+ // RELEASE a certificate, and that must rest on a conforming indicator. It is still classified
1706
+ // as a delta and still consulted for revocation, which is the shipped behaviour and the
1707
+ // conservative direction.
1708
+ if (crlNumberWithinBound(n) && deltaCritical) { rec.baseCrlNumber = n; rec.mergeable = true; }
1709
+ } catch (_e) {
1710
+ // Unusable AS A DELTA, but still scanned for revocation. That is the conservative direction:
1711
+ // the CRL is still the issuer's, signed and current (it must pass every gate before it is
1712
+ // consulted), so a serial it lists is a genuine revocation and dropping it wholesale could
1713
+ // lose one. Consulting it can only ever find MORE revocations, never grant coverage -- a
1714
+ // delta contributes no reason mask.
1715
+ }
1716
+ deltas.push(rec);
1717
+ }
1718
+ return { completes: completes, deltas: deltas };
1719
+ }
1720
+
1721
+ // RFC 5280 sec. 6.3.3(c)(1)-(3) + sec. 5.2.4(a)-(d), as one predicate. Every
1722
+ // comparison is BigInt or byte-exact: no narrowing, no canonicalization. An
1723
+ // extension ABSENT on both sides matches; present on one side only does not.
1724
+ function deltaMergesWith(delta, complete) {
1725
+ if (!delta.mergeable) return false;
1726
+ // Both numbers are required: 5.2.4(c)/(d) are ordering rules, and a missing
1727
+ // cRLNumber on either side leaves the ordering unknowable -- fail closed.
1728
+ if (complete.crlNumber === null || delta.crlNumber === null) return false;
1729
+ if (!dnEqualUsable(delta.crl.issuer.rdns, complete.crl.issuer.rdns)) return false; // (c)(1)
1730
+ if (!sameRawExt(delta.idpRaw, complete.idpRaw)) return false; // (c)(2) / 5.2.4(b)
1731
+ if (!sameRawExt(delta.akiRaw, complete.akiRaw)) return false; // (c)(3)
1732
+ if (!(complete.crlNumber >= delta.baseCrlNumber)) return false; // 5.2.4(c)
1733
+ if (!(complete.crlNumber < delta.crlNumber)) return false; // 5.2.4(d)
1734
+ return true;
1735
+ }
1736
+ // dnEqual THROWS on a DN carrying an embedded NUL/control byte (CVE-2009-2408).
1737
+ // For the merge preconditions a DN that cannot be compared is simply not a match:
1738
+ // the delta and the base are not shown to share an issuer, so they are not merged.
1739
+ // The throw is contained here rather than at the call site so the predicate itself
1740
+ // stays a straight-line sequence of comparisons.
1741
+ function dnEqualUsable(a, b) {
1742
+ try { return dnEqual(a, b); }
1743
+ catch (_e) { return false; }
1744
+ }
1745
+
1746
+ function sameRawExt(a, b) {
1747
+ if (a === null && b === null) return true;
1748
+ if (a === null || b === null) return false;
1749
+ return a.equals(b);
1750
+ }
1751
+
1752
+ // sec. 5.2.4 @3580 makes MULTIPLE current deltas for one scope legal and says the
1753
+ // application SHOULD take the one with the latest thisUpdate. Selection, never
1754
+ // rejection: treating two current deltas as a fault would refuse a conforming
1755
+ // publication. Ties break on the greater cRLNumber, then first-wins.
1756
+ function selectDelta(candidates) {
1757
+ var best = null;
1758
+ for (var i = 0; i < candidates.length; i++) {
1759
+ var c = candidates[i];
1760
+ if (best === null) { best = c; continue; }
1761
+ var t = c.crl.thisUpdate.getTime(), bt = best.crl.thisUpdate.getTime();
1762
+ if (t > bt) { best = c; continue; }
1763
+ if (t === bt && c.crlNumber !== null && best.crlNumber !== null && c.crlNumber > best.crlNumber) best = c;
1764
+ }
1765
+ return best;
1766
+ }
1767
+
1585
1768
  /**
1586
1769
  * @primitive pki.path.crlChecker
1587
- * @signature pki.path.crlChecker(crls) -> RevocationChecker
1770
+ * @signature pki.path.crlChecker(crls, opts?) -> RevocationChecker
1588
1771
  * @since 0.1.16
1589
1772
  * @status experimental
1590
1773
  * @spec RFC 5280
@@ -1599,17 +1782,38 @@ function crlIssuerNamesIssuer(cRLIssuer, issuerRdns) {
1599
1782
  * "unknown" }`. A partitioned/sharded CRL (a critical IDP naming a
1600
1783
  * distribution point) establishes "good" when it corresponds to one of the
1601
1784
  * certificate's own cRLDistributionPoints -- at least one identically-encoded
1602
- * name in common (RFC 5280 sec. 6.3.3) -- and neither side restricts reason
1603
- * codes; a non-corresponding or reason-restricted shard is consulted for
1604
- * revocation only. An out-of-scope, stale, unauthorized, or unverifiable CRL
1605
- * yields `unknown`, which the validator fails closed unless `softFail` is set.
1785
+ * name in common (RFC 5280 sec. 6.3.3). Reason-sharded CRLs ACCUMULATE: each
1786
+ * corresponding CRL contributes its interim reason mask (sec. 6.3.3(d)) and the
1787
+ * certificate is "good" once the shards together cover all eight revocation
1788
+ * reasons, so a CA that partitions by reason code is served. A DELTA CRL is
1789
+ * merged onto a complete CRL it may be combined with (sec. 5.2.4 / 6.3.3(c)):
1790
+ * the delta is searched first, the complete CRL only if the delta left the
1791
+ * status unrevoked, and `removeFromCRL` then releases the certificate -- so a
1792
+ * base+delta pair reports a real verdict where the base alone could not. A
1793
+ * delta that merges with nothing is still consulted for revocation and still
1794
+ * blocks "good": merging may turn undetermined into good or revoked, never a
1795
+ * revoked into a good. A non-corresponding shard is consulted for revocation
1796
+ * only. An out-of-scope, stale, unauthorized, or unverifiable CRL yields
1797
+ * `unknown`, which the validator fails closed unless `softFail` is set.
1798
+ *
1799
+ * A `revoked` verdict carries `reasonCode` (the CRLReason integer, 0 for
1800
+ * `unspecified`) and a `reason` naming it.
1801
+ *
1802
+ * @opts
1803
+ * useDeltas boolean merge delta CRLs onto their base (RFC 5280 sec. 6.3.1(b)).
1804
+ * Default true. When false a delta is never merged; it is
1805
+ * still consulted for revocation.
1606
1806
  *
1607
1807
  * @example
1608
1808
  * var checker = pki.path.crlChecker([]); // no CRLs -> every cert is "unknown"
1609
1809
  * typeof checker.check; // "function"
1610
1810
  */
1611
- function crlChecker(crls) {
1811
+ function crlChecker(crls, opts) {
1612
1812
  var parsed = (crls || []).map(function (c) { return (c && c.tbsBytes) ? c : crl.parse(c); });
1813
+ // RFC 5280 sec. 6.3.1(b): use-deltas is an INPUT to the algorithm. Default ON --
1814
+ // a caller holding a delta wants it used -- and turning it off never makes a
1815
+ // verdict weaker, only less determined.
1816
+ var useDeltas = !(opts && opts.useDeltas === false);
1613
1817
  return {
1614
1818
  check: async function (cert, issuer, ctx) {
1615
1819
  var time = ctx.time;
@@ -1660,42 +1864,58 @@ function crlChecker(crls) {
1660
1864
  }
1661
1865
 
1662
1866
  // Consult EVERY CRL issued by the cert's issuer -- a clean CRL must not
1663
- // shadow a revoking one (RFC 5280 6.3.3). A serial listed in ANY
1867
+ // shadow a revoking one (RFC 5280 sec. 6.3.3). A serial listed in ANY
1664
1868
  // authoritative, in-scope, current, verified CRL is revoked; the cert is
1665
- // "good" only if at least one such CRL was consulted and none list it;
1666
- // otherwise the status is undetermined.
1667
- var sawAuthoritative = false;
1668
- var sawDelta = false;
1669
- var sawDeltaRemoval = false; // a delta released this serial from hold
1670
- var revokedResult = null; // a base/full CRL revocation, decided at the end
1671
- for (var k = 0; k < parsed.length; k++) {
1672
- var theCrl = parsed[k];
1869
+ // "good" only when the CRLs consulted together cover ALL EIGHT revocation
1870
+ // reasons (sec. 6.3.3(l) + the termination rule at @5291) and none list it.
1871
+ //
1872
+ // Note there is deliberately no early exit once the mask is complete: the
1873
+ // shipped checker scans every CRL so a clean one cannot shadow a revoking
1874
+ // one, and stopping at full coverage would reintroduce exactly that.
1875
+ var certStatus = null; // sec. 6.3.2(b) cert_status; null = UNREVOKED
1876
+ var reasonsMask = 0; // sec. 6.3.2(a) reasons_mask; the empty set
1877
+ var releasedByUnmergedDelta = false;
1878
+ // A CURRENT, authoritative delta that merged with nothing means the local
1879
+ // revocation picture is incomplete: the base it names may be newer than any
1880
+ // complete CRL held here, so a clean base proves less than it appears to.
1881
+ // The shipped checker blocks on exactly this, and the merge must only
1882
+ // ever turn undetermined INTO good -- never make an unmerged delta weaker
1883
+ // than it was before this feature existed.
1884
+ var sawUnmergedDelta = false;
1885
+ var classified = classifyCrls(parsed);
1886
+ var consumedDeltas = [];
1887
+
1888
+ // Every gate a CRL must pass before it may speak for this certificate, in
1889
+ // the shipped order. Returns null when the CRL is unusable, otherwise the
1890
+ // interim_reasons_mask it may contribute -- 0 meaning "consulted for
1891
+ // revocation only", which is how a non-corresponding shard is honestly
1892
+ // encoded (it can reveal a revocation but can never establish coverage).
1893
+ async function gateCrl(rec) {
1894
+ // Memoized per RECORD, and the records are rebuilt by classifyCrls on every check(), so the
1895
+ // cache is per-call and the checker stays re-entrant. Without it the base-by-delta pairing
1896
+ // below would repeat an asynchronous public-key signature verification for every pair --
1897
+ // O(completes x deltas) verifications where the shipped loop did O(crls).
1898
+ if (rec._gated) return rec._gate;
1899
+ rec._gate = await gateCrlUncached(rec);
1900
+ rec._gated = true;
1901
+ return rec._gate;
1902
+ }
1903
+ async function gateCrlUncached(rec) {
1904
+ var theCrl = rec.crl;
1673
1905
  // dnEqual throws on a DN carrying an embedded NUL/control byte (CVE-2009-2408).
1674
1906
  // A single malformed CRL in the bundle must NOT abort the whole check (which
1675
1907
  // would mask a later authoritative CRL and pass under softFail) -- treat it
1676
1908
  // as unusable and skip it, consulting the remaining CRLs.
1677
1909
  var issuerMatches;
1678
1910
  try { issuerMatches = dnEqual(theCrl.issuer.rdns, cert.issuer.rdns); }
1679
- catch (_e) { continue; }
1680
- if (!issuerMatches) continue;
1681
- if (!signerAuthorized) continue;
1682
-
1683
- // A CRL carrying deltaCRLIndicator is a DELTA CRL: it lists only the
1684
- // CHANGES since a base CRL (RFC 5280 sec. 5.2.4). deltaCRLIndicator is a
1685
- // RECOGNIZED extension (so a critical one is not "unhandled"); the delta
1686
- // is acted on only AFTER it passes the currency + signature checks below,
1687
- // so a stale, malformed, or unverifiable delta cannot spuriously block a
1688
- // good result. An AUTHORITATIVE delta blocks "good" (its base is not
1689
- // merged here) and can still reveal a revocation for a serial it lists.
1690
- var isDelta = false;
1691
- for (var dz = 0; dz < theCrl.crlExtensions.length; dz++) {
1692
- if (theCrl.crlExtensions[dz].oid === OID_DELTA_CRL) { isDelta = true; break; }
1693
- }
1911
+ catch (_e) { return null; }
1912
+ if (!issuerMatches) return null;
1913
+ if (!signerAuthorized) return null;
1694
1914
 
1695
1915
  // A validly-signed CRL carrying a CRITICAL extension this checker does
1696
1916
  // not understand (anything but issuingDistributionPoint / deltaCRLIndicator)
1697
1917
  // may change the CRL's scope or meaning -- treat it as unusable (RFC 5280
1698
- // 5.2 critical-extension semantics), never authoritative.
1918
+ // sec. 5.2 critical-extension semantics), never authoritative.
1699
1919
  var unhandledCritical = false;
1700
1920
  for (var x = 0; x < theCrl.crlExtensions.length; x++) {
1701
1921
  var xe = theCrl.crlExtensions[x];
@@ -1715,28 +1935,38 @@ function crlChecker(crls) {
1715
1935
  if (ees[ex].critical && ees[ex].oid !== OID_REASON_CODE) { unhandledCritical = true; break; }
1716
1936
  }
1717
1937
  }
1718
- if (unhandledCritical) continue;
1719
-
1720
- // A partition-scoped CRL (a specific distributionPoint, or reason-sharded
1721
- // via onlySomeReasons) covers only part of the issuer's revocations, so it
1722
- // cannot by itself establish "good" (full coverage is unconfirmed). But a
1723
- // serial it LISTS is a genuine revocation of this certificate (serials are
1724
- // unique per issuer), so such a CRL must still be consulted for revocation
1725
- // -- dropping it wholesale would let a revoked cert slip under softFail.
1726
- var scopeRevocationOnly = false;
1727
- var idpExt = null;
1728
- for (var e = 0; e < theCrl.crlExtensions.length; e++) if (theCrl.crlExtensions[e].oid === OID_IDP) idpExt = theCrl.crlExtensions[e];
1729
- if (idpExt) {
1730
- var idp = decodeIdp(idpExt);
1731
- if (idp.malformed) continue; // scope unknown -> unusable
1938
+ if (unhandledCritical) return null;
1939
+
1940
+ // sec. 6.3.3(d): the interim reason mask this CRL contributes. Its two
1941
+ // inputs are the IDP's onlySomeReasons and the CORRESPONDING certificate
1942
+ // DistributionPoint's reasons -- never an entry's reasonCode (sec. 5.2.5
1943
+ // @3628 explicitly permits a shard entry to omit one).
1944
+ // Set when the shard covers no reasons for this certificate. It is recorded rather than
1945
+ // RETURNED here: the currency and signature gates below still have to run, or an expired,
1946
+ // not-yet-valid or FORGED shard could be scanned for revocations and falsely revoke.
1947
+ var noCoverage = false;
1948
+ var idpMask = null, dpMask = null, sawIdp = false;
1949
+ var idpExtension = null;
1950
+ for (var e = 0; e < theCrl.crlExtensions.length; e++) if (theCrl.crlExtensions[e].oid === OID_IDP) idpExtension = theCrl.crlExtensions[e];
1951
+ if (idpExtension) {
1952
+ sawIdp = true;
1953
+ var idp = decodeIdp(idpExtension);
1954
+ if (idp.malformed) return null; // scope unknown -> unusable
1732
1955
  // An indirect CRL carries entries for other issuers keyed by the
1733
1956
  // per-entry certificateIssuer attribute (not tracked here) -- matching
1734
1957
  // by serial alone could revoke the wrong cert or falsely cover it, so
1735
1958
  // treat an indirect CRL as unusable until certificateIssuer is honored.
1736
- if (idp.indirect) continue;
1737
- if (idp.onlyAttr) continue; // scoped to attribute certs, not this public-key cert
1738
- if (idp.onlyCa && certIsCa !== true) continue; // out of scope (or CA-ness undeterminable)
1739
- if (idp.onlyUser && certIsCa !== false) continue;
1959
+ if (idp.indirect) return null;
1960
+ if (idp.onlyAttr) return null; // scoped to attribute certs, not this public-key cert
1961
+ if (idp.onlyCa && certIsCa !== true) return null; // out of scope (or CA-ness undeterminable)
1962
+ if (idp.onlyUser && certIsCa !== false) return null;
1963
+ // The same fail-closed decision the distributionPoint correspondence rests on (sec. 5.2.5
1964
+ // @3601 lets a relying party not support the IDP at all): a scope a non-supporting
1965
+ // verifier would IGNORE is not a scope to build coverage on. So a non-critical IDP's
1966
+ // onlySomeReasons contributes nothing -- which also preserves the shipped property that
1967
+ // an onlySomeReasons shard could only ever WITHHOLD good, never establish it.
1968
+ if (idpExtension.critical === true) idpMask = idp.onlySomeReasonsMask;
1969
+ else if (idp.onlySomeReasons) noCoverage = true;
1740
1970
  if (idp.hasDistributionPoint) {
1741
1971
  // RFC 5280 sec. 6.3.3(b)(2)(i): a partition shard speaks for this
1742
1972
  // certificate only when the IDP's distribution point shares at
@@ -1744,50 +1974,50 @@ function crlChecker(crls) {
1744
1974
  // own DistributionPoints (sec. 5.2.5: "The identical encoding MUST
1745
1975
  // be used in the distributionPoint fields of the certificate and
1746
1976
  // the CRL"). The IDP must also be CRITICAL to be relied on for
1747
- // scope: sec. 5.2.5 defines the IDP as "a critical CRL extension"
1748
- // (descriptive phrasing, not an imperative MUST), and a partition
1749
- // scope a non-supporting relying party would ignore is not a scope
1750
- // to build "good" on -- a deliberate fail-closed decision. A
1751
- // non-corresponding shard cannot establish "good" but is still
1752
- // consulted for revocation below: serials are unique per issuer,
1753
- // so a listed serial is a genuine revocation (fail closed toward
1754
- // revoked).
1755
- var matchedDp = idpExt.critical === true
1977
+ // scope: sec. 5.2.5 describes the IDP as "a critical CRL extension"
1978
+ // (descriptive phrasing, not an imperative MUST -- and @3601 lets a
1979
+ // relying party not support it at all), so building coverage on a
1980
+ // scope a non-supporting verifier would ignore is a deliberate
1981
+ // fail-closed decision. A non-corresponding shard contributes NO
1982
+ // coverage but is still consulted for revocation below: serials are
1983
+ // unique per issuer, so a listed serial is a genuine revocation.
1984
+ var matchedDp = idpExtension.critical === true
1756
1985
  ? correspondingCertDp(idp.distributionPoint, certDPs, cert.issuer.rdns)
1757
1986
  : null;
1758
- if (!matchedDp) scopeRevocationOnly = true;
1759
- // sec. 6.3.3(d)(3): a matched DP carrying `reasons` bounds the
1760
- // interim reason mask below all-reasons -- under the coarse rule
1761
- // ("good" only at the (d)(4) all-reasons case) that shard is
1762
- // revocation-only.
1763
- else if (matchedDp.reasons) scopeRevocationOnly = true;
1987
+ if (!matchedDp) noCoverage = true;
1988
+ // sec. 6.3.3(d)(1)/(d)(3): a matched DP carrying `reasons` bounds the
1989
+ // interim mask. A present-but-unreadable value cannot bound anything
1990
+ // safely, so it contributes nothing rather than defaulting open.
1991
+ else if (matchedDp.reasons) {
1992
+ dpMask = reasonMaskFromBitString(matchedDp.reasons);
1993
+ if (dpMask === null) noCoverage = true;
1994
+ }
1764
1995
  }
1765
- // sec. 6.3.3(d)(1)/(d)(2): any onlySomeReasons restriction keeps the
1766
- // interim reason mask below all-reasons -- revocation-only (coarse).
1767
- if (idp.onlySomeReasons) scopeRevocationOnly = true;
1768
1996
  }
1769
- if (theCrl.thisUpdate > time) continue; // not yet valid
1997
+ if (theCrl.thisUpdate > time) return null; // not yet valid
1770
1998
  // A CRL with no nextUpdate has no bounded validity -- its currency
1771
1999
  // cannot be confirmed (RFC 5280 sec. 5.1.2.5 requires nextUpdate), so a
1772
2000
  // replayed old CRL must not read "good". Treat it as unusable.
1773
- if (!theCrl.nextUpdate || theCrl.nextUpdate < time) continue; // stale / no bound
2001
+ if (!theCrl.nextUpdate || theCrl.nextUpdate < time) return null; // stale / no bound
1774
2002
 
1775
2003
  var sigOk = await crlVerify.verifyCrlSignature(theCrl, issuer.workingPublicKey);
1776
- if (!sigOk) continue; // unverifiable -> not authoritative
1777
-
1778
- // The CRL is now authoritative + current + verified. An authoritative
1779
- // delta blocks a "good" result (its base is not merged here) and can only
1780
- // reveal a revocation -- never establish "good" on its own.
1781
- if (isDelta) { sawDelta = true; scopeRevocationOnly = true; }
2004
+ if (!sigOk) return null; // unverifiable -> not authoritative
2005
+
2006
+ // sec. 6.3.3 @5295: a CRL not named by any distribution point is processed
2007
+ // as though under a DP whose `reasons` and `cRLIssuer` are absent -- i.e.
2008
+ // all-reasons. That is why a full-scope CRL still covers a certificate
2009
+ // that happens to carry a cRLDistributionPoints extension.
2010
+ void sawIdp;
2011
+ return { interim: noCoverage ? 0 : interimReasonMask(idpMask, dpMask) };
2012
+ }
1782
2013
 
2014
+ // sec. 6.3.3(i)/(j): the certificate's entry on one CRL, as a CRLReason
2015
+ // value, or null for UNREVOKED. sec. 6.3.3(i)(2): an entry with no
2016
+ // reasonCode extension is `unspecified` (0), which is a revocation.
2017
+ function scanCrl(theCrl) {
1783
2018
  for (var r = 0; r < theCrl.revokedCertificates.length; r++) {
1784
2019
  var entry = theCrl.revokedCertificates[r];
1785
2020
  if (entry.serialNumberHex !== cert.serialNumberHex) continue;
1786
- // reasonCode removeFromCRL (8) means the entry was un-revoked. In a
1787
- // DELTA this releases the serial from hold; because the base is not
1788
- // merged here, a definitive "revoked" is no longer possible for it -- a
1789
- // base CRL that still lists it must not override the delta removal.
1790
- if (crlEntryReason(entry) === 8) { if (isDelta) sawDeltaRemoval = true; continue; }
1791
2021
  // A revocation is effective as of its revocationDate (RFC 5280 sec. 5.3).
1792
2022
  // In the DEFAULT present-time validation a listed serial is revoked
1793
2023
  // regardless of that date -- a future revocationDate is post-dating or
@@ -1797,28 +2027,113 @@ function crlChecker(crls) {
1797
2027
  // validation time not yet apply.
1798
2028
  // allow:nan-date-comparison-unguarded -- revocationDate is codec-parsed (NaN-rejected); a NaN check time makes this FAIL CLOSED (the skip is not taken -> the entry is treated as revoked), and `time` is validated at the path.validate / crlChecker entry points.
1799
2029
  if (historical && entry.revocationDate instanceof Date && entry.revocationDate.getTime() > time.getTime()) continue;
1800
- // Record the revocation but keep scanning: a delta removeFromCRL for the
1801
- // same serial (in another CRL) overrides it (base/delta not merged).
1802
- revokedResult = { status: "revoked", reason: "serial listed in a CRL" };
1803
- break;
2030
+ var rc = crlEntryReason(entry);
2031
+ return rc === null ? 0 : rc;
2032
+ }
2033
+ return null;
2034
+ }
2035
+
2036
+ // sec. 6.3.3(a)(2): a delta is obtained only when use-deltas is set AND a
2037
+ // locator exists -- freshestCRL on the certificate or on the complete CRL.
2038
+ // sec. 5.2.6 @3719: the locator's CONTENTS are only ever used to find a
2039
+ // delta, never to validate one, so its presence is the whole gate.
2040
+ // PRESENCE only, deliberately. sec. 5.2.6 @3719 says the freshestCRL contents are used to
2041
+ // LOCATE a delta and never to validate one, so decoding them would be using a value the RFC
2042
+ // says not to use. It is also not a security control: enabling the merge grants nothing on its
2043
+ // own, because a delta still has to pass every gate -- issuer, authorization, criticality,
2044
+ // scope, currency and SIGNATURE -- plus the sec. 5.2.4 merge preconditions. An attacker who
2045
+ // could satisfy those already controls the issuing key.
2046
+ var certHasFreshest = !!findExt(cert, OID_FRESHEST_CRL);
2047
+ function deltaLocatorPresent(completeRec) {
2048
+ return certHasFreshest || crlExtValue(completeRec.crl, OID_FRESHEST_CRL) !== null;
2049
+ }
2050
+
2051
+ for (var ci = 0; ci < classified.completes.length; ci++) {
2052
+ var rec = classified.completes[ci];
2053
+ var gate = await gateCrl(rec);
2054
+ if (!gate) continue;
2055
+
2056
+ // sec. 6.3.3(c): pair this complete CRL with a delta it may be combined
2057
+ // with. The delta passes its OWN gates first -- sec. 6.3.3(h) requires
2058
+ // its signature verified and (f) its issuer authorized, exactly as for a
2059
+ // complete CRL -- so a stale, unverifiable or out-of-scope delta is never
2060
+ // merged.
2061
+ var chosenDelta = null;
2062
+ if (useDeltas && deltaLocatorPresent(rec)) {
2063
+ var candidates = [];
2064
+ for (var di = 0; di < classified.deltas.length; di++) {
2065
+ var cand = classified.deltas[di];
2066
+ if (!deltaMergesWith(cand, rec)) continue;
2067
+ if (!(await gateCrl(cand))) continue;
2068
+ // Mergeable and usable: this delta's scope IS covered by a merge, even
2069
+ // if sec. 5.2.4 @3580 selection prefers a sibling with a later
2070
+ // thisUpdate. Only a delta that pairs with NO complete CRL leaves the
2071
+ // picture incomplete -- a losing candidate must not block a good result,
2072
+ // or publishing two current deltas (which the RFC permits) would be
2073
+ // worse than publishing one.
2074
+ cand.accounted = true;
2075
+ candidates.push(cand);
2076
+ }
2077
+ chosenDelta = selectDelta(candidates);
2078
+ }
2079
+
2080
+ var status;
2081
+ if (chosenDelta) {
2082
+ consumedDeltas.push(chosenDelta);
2083
+ status = scanCrl(chosenDelta.crl); // (i) search the delta FIRST
2084
+ if (status === null) status = scanCrl(rec.crl); // (j) the complete CRL only if still UNREVOKED
2085
+ } else {
2086
+ status = scanCrl(rec.crl);
1804
2087
  }
1805
- // A partition-scoped CRL that did not list this serial does NOT prove the
1806
- // cert is unrevoked (another shard/reason may revoke it) -- only a
1807
- // full-scope CRL can establish "good".
1808
- if (!scopeRevocationOnly) sawAuthoritative = true; // covered this cert, not listed
2088
+ // (k): removeFromCRL means the certificate is no longer revoked. It is
2089
+ // normalized wherever it appears -- sec. 5.3.1's "only in delta CRLs" binds
2090
+ // the CA that emits it, not this consumer, and rejecting a complete CRL
2091
+ // that carries one would make an unusual-but-harmless CRL unusable.
2092
+ if (status === 8) status = null;
2093
+
2094
+ if (status !== null && certStatus === null) certStatus = status;
2095
+ else if (status === null) reasonsMask |= gate.interim; // (l): only a clean scope covers
1809
2096
  }
1810
- // A delta released this serial from hold: without merging its base we cannot
1811
- // return a definitive revoked (else a released cert stays rejected) -- the
2097
+
2098
+ // An unmerged delta still speaks, under the shipped fail-closed posture: a
2099
+ // serial it lists is a genuine revocation (serials are unique per issuer),
2100
+ // and a removeFromCRL it carries blocks a definitive revoked without being
2101
+ // able to establish good. This is what keeps the merge MONOTONIC -- it may
2102
+ // turn undetermined into good or revoked, but a delta that merges with
2103
+ // nothing can never erase a revocation the checker would otherwise report.
2104
+ for (var dj = 0; dj < classified.deltas.length; dj++) {
2105
+ var dRec = classified.deltas[dj];
2106
+ if (consumedDeltas.indexOf(dRec) !== -1) continue;
2107
+ // A delta that was MERGEABLE with some complete CRL but lost the sec. 5.2.4 @3580 selection
2108
+ // does not speak for its scope at all -- the selected delta does, and it was evaluated
2109
+ // against the base. Letting a superseded delta contribute its revocation while ignoring its
2110
+ // release would be incoherent: an older delta revoking a certificate that the newer one
2111
+ // releases would resurrect the revocation the CA withdrew.
2112
+ if (dRec.accounted) continue;
2113
+ if (!(await gateCrl(dRec))) continue;
2114
+ sawUnmergedDelta = true;
2115
+ var dStatus = scanCrl(dRec.crl);
2116
+ if (dStatus === null) continue;
2117
+ if (dStatus === 8) { releasedByUnmergedDelta = true; continue; }
2118
+ if (certStatus === null) certStatus = dStatus;
2119
+ }
2120
+
2121
+ // A delta released this serial from hold but its base was not merged: a
2122
+ // definitive revoked would leave a released certificate rejected, so the
1812
2123
  // status is undetermined. This outranks a base CRL's revocation.
1813
- if (sawDeltaRemoval) return { status: "unknown", reason: "a delta CRL released this serial from hold; without merging its base CRL the revocation status is undetermined" };
1814
- if (revokedResult) return revokedResult;
1815
- // A delta CRL for this issuer was seen but cannot be merged with its base,
1816
- // so the current revocation picture is incomplete -- never report "good".
1817
- 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" };
1818
- if (sawAuthoritative) return { status: "good" };
2124
+ if (releasedByUnmergedDelta) return { status: "unknown", reason: "a delta CRL released this serial from hold; without merging its base CRL the revocation status is undetermined" };
2125
+ if (certStatus !== null) {
2126
+ var reasonName = constants.NAMES.CRL_REASON[String(certStatus)] || "unspecified";
2127
+ return { status: "revoked", reasonCode: certStatus, reason: "serial listed in a CRL (" + reasonName + ")" };
2128
+ }
2129
+ if (sawUnmergedDelta) return { status: "unknown", reason: "a delta CRL cannot be combined with any complete CRL held here, so the revocation picture is incomplete" };
2130
+ if ((reasonsMask & ALL_REASONS) === ALL_REASONS) return { status: "good" };
1819
2131
  if (certScopeFault) {
1820
2132
  return { status: "unknown", reason: "no authoritative in-scope CRL covers this certificate; its basicConstraints extension is unreadable (" + certScopeFault + "), so scope-limited CRLs were skipped" };
1821
2133
  }
2134
+ if (reasonsMask !== 0) {
2135
+ return { status: "unknown", reason: "the CRLs available cover only some revocation reasons for this certificate; no combination covers all of them" };
2136
+ }
1822
2137
  return { status: "unknown", reason: "no authoritative in-scope CRL covers this certificate" };
1823
2138
  },
1824
2139
  };
@@ -1141,6 +1141,12 @@ function certExtensionDecoders(ns) {
1141
1141
  var bs;
1142
1142
  try { bs = asn1.read.bitStringImplicit(f, 1); }
1143
1143
  catch (e) { throw ns.E(C, "DistributionPoint reasons [1] must be an IMPLICIT ReasonFlags BIT STRING", e); }
1144
+ // ReasonFlags is a NamedBitList, so DER requires trailing zero bits be
1145
+ // dropped (X.690 sec. 11.2.2) -- the same rule the keyUsage decoder
1146
+ // enforces above. Without it two different encodings of one reason set
1147
+ // both parse, and RFC 5280 sec. 6.3.3(d)(1) would intersect the CRL's
1148
+ // onlySomeReasons against a value the encoding rules forbid.
1149
+ schema.assertMinimalNamedBits(bs.unusedBits, bs.bytes, function (msg) { throw ns.E(C, "DistributionPoint reasons: " + msg); });
1144
1150
  out.reasons = { unusedBits: bs.unusedBits, bytes: bs.bytes };
1145
1151
  } else if (f.tagNumber === 2) {
1146
1152
  out.cRLIssuer = schema.walk(generalNames(ns, { implicitTag: 2, decodeValue: true, code: C, what: "DistributionPoint cRLIssuer" }), f, ns).result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.4.14",
3
+ "version": "0.4.15",
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",
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:a5caa56c-cfae-4869-93f2-4e8984c7c32f",
5
+ "serialNumber": "urn:uuid:7595bce4-18d5-4ef0-a2a1-6be18d59782d",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-10T23:24:00.079Z",
8
+ "timestamp": "2026-08-11T01:35:46.496Z",
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.4.14",
22
+ "bom-ref": "@blamejs/pki@0.4.15",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.4.14",
25
+ "version": "0.4.15",
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.4.14",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.4.15",
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.4.14",
57
+ "ref": "@blamejs/pki@0.4.15",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]