@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 +25 -0
- package/README.md +1 -1
- package/lib/path-validate.js +411 -96
- package/lib/schema-pkix.js +6 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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
|
|
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` |
|
package/lib/path-validate.js
CHANGED
|
@@ -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)
|
|
1603
|
-
*
|
|
1604
|
-
*
|
|
1605
|
-
*
|
|
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
|
|
1666
|
-
//
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
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) {
|
|
1680
|
-
if (!issuerMatches)
|
|
1681
|
-
if (!signerAuthorized)
|
|
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)
|
|
1719
|
-
|
|
1720
|
-
//
|
|
1721
|
-
//
|
|
1722
|
-
//
|
|
1723
|
-
//
|
|
1724
|
-
//
|
|
1725
|
-
//
|
|
1726
|
-
|
|
1727
|
-
var
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
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)
|
|
1737
|
-
if (idp.onlyAttr)
|
|
1738
|
-
if (idp.onlyCa && certIsCa !== true)
|
|
1739
|
-
if (idp.onlyUser && certIsCa !== false)
|
|
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
|
|
1748
|
-
// (descriptive phrasing, not an imperative MUST
|
|
1749
|
-
//
|
|
1750
|
-
//
|
|
1751
|
-
// non-corresponding shard
|
|
1752
|
-
// consulted for revocation below: serials are
|
|
1753
|
-
// so a listed serial is a genuine revocation
|
|
1754
|
-
|
|
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)
|
|
1759
|
-
// sec. 6.3.3(d)(3): a matched DP carrying `reasons` bounds the
|
|
1760
|
-
// interim
|
|
1761
|
-
//
|
|
1762
|
-
|
|
1763
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
1777
|
-
|
|
1778
|
-
//
|
|
1779
|
-
//
|
|
1780
|
-
//
|
|
1781
|
-
|
|
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
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
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
|
-
//
|
|
1806
|
-
//
|
|
1807
|
-
//
|
|
1808
|
-
|
|
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
|
-
|
|
1811
|
-
//
|
|
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 (
|
|
1814
|
-
if (
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
if (
|
|
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
|
};
|
package/lib/schema-pkix.js
CHANGED
|
@@ -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
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:7595bce4-18d5-4ef0-a2a1-6be18d59782d",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-08-
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.4.15",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.4.
|
|
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.
|
|
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.
|
|
57
|
+
"ref": "@blamejs/pki@0.4.15",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|