@blamejs/pki 0.7.8 → 0.7.9

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,22 @@ 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.7.9 — 2026-09-10
8
+
9
+ Certificate revocation lists can now be linted against the RFC 5280 profile.
10
+
11
+ ### Added
12
+
13
+ - pki.lint.crl(pem | der | parsed, opts) lints a certificate revocation list against RFC 5280 section 5, under the new profile name rfc5280-crl. It returns the same LintReport as pki.lint.certificate, and hostile bytes produce a single fatal lint/unparseable finding carrying the strict parser's code rather than raising.
14
+ - On the CRL itself: nextUpdate presence (section 5.1.2.5) and thisUpdate not following it; cRLNumber presence, and the 20-octet ceiling section 5.2.3 places on a CRL number, measured on both the cRLNumber and the baseCRLNumber a delta CRL carries, which section 5.2.4 defines as a CRLNumber; both halves of the authorityKeyIdentifier requirement, that it is present and that it uses the key identifier method (section 5.2.1); the criticality every profiled extension must carry (section 5.2), with issuerAltName reported separately at warn because section 5.2.2 states its criticality as a recommendation; freshestCRL in a delta CRL, and one carrying the reasons or cRLIssuer fields section 5.2.6 requires a CRL to omit (section 5.2.6); a delta whose cRLNumber does not exceed its baseCRLNumber (section 5.2.4); an issuingDistributionPoint stating a scope the profile does not permit (section 5.2.5), which includes a reason scope naming no reason at all, as an empty mask or one setting only the unused bit does; a distribution point named relative to the CRL issuer, in either the issuingDistributionPoint or the freshestCRL, at warn, since section 4.2.1.13 states that as a recommendation; an authorityInfoAccess that names no caIssuers access method, or that carries one other than caIssuers (section 5.2.7), with a warning where no location it gives is an HTTP or LDAP URI; and an unrecognized critical extension.
15
+ - On each revoked entry: the entry-extension criticality section 5.3 requires; a certificate serial that is not positive or runs past 20 octets, the same section 4.1.2.2 profile a certificate's own serial carries; a removeFromCRL reason code outside a delta CRL (section 5.3.1); a certificateIssuer on a CRL that declares itself direct, or one that omits the issuer distinguished name the clause requires (section 5.3.3); a reasonCode encoding unspecified(0), which section 5.3.1 says should be absent instead, at warn, and as an error where the CRL is scoped to some reason codes, since section 5.2.5 makes a meaningful reason a requirement there; a reason the CRL's own issuingDistributionPoint does not declare, where that extension names at least one reason (section 5.2.5); a serial listed twice on a direct CRL, where a reader cannot tell which entry governs (section 5.1.2.6); and an unrecognized critical entry extension.
16
+ - An extension the section 5 profile names, whose value does not read as the structure that identifier names, is reported in either scope. Only extensions the CRL profile names are read this way: a certificate-only extension carried on a CRL is an unrecognized one, which the profile lets a consumer ignore when it is non-critical and which the unknown-critical row reports when it is not. Recognizing an extension by its identifier says only that the profile names it, so without this a critical issuingDistributionPoint carrying a NULL satisfied the criticality rule, was not an unrecognized extension, and reported clean although no consumer can process it. The issuingDistributionPoint is read through the same decoder certification-path validation uses, so the two verbs cannot disagree about what a well-formed one is. Where that extension does not read, the CRL states neither a scope nor a direction, so the rows that depend on it stand aside and the extension is reported once rather than every entry that would have followed from it.
17
+ - pki.lint.rules("rfc5280-crl") enumerates the CRL registry, and pki.lint.rules() with no argument now returns the certificate and CRL rules together. The two verbs do not accept each other's profile names: passing a CRL profile to pki.lint.certificate, or a certificate profile to pki.lint.crl, raises lint/unknown-profile naming the verb to use instead.
18
+
19
+ ### Changed
20
+
21
+ - Six section 5 rules have no row, because the strict parser refuses them before a rule could run: a CRL whose version does not match the extensions it carries (section 5.1.2.1), a signatureAlgorithm that does not match the signature field inside tbsCertList (section 5.1.1.2), an empty issuer name (section 5.1.2.3), a present-but-empty revokedCertificates (section 5.1.2.6), a date carried in the wrong time type, which is UTCTime through 2049 and GeneralizedTime from 2050 (sections 5.1.2.4, 5.1.2.6 and 5.3.2), and an extension repeated on the CRL or on an entry (section 4.2). Each reaches a caller as the fatal lint/unparseable finding carrying the parser's own code.
22
+
7
23
  ## v0.7.8 — 2026-09-10
8
24
 
9
25
  A pre-encoded CRL extension whose value does not decode is refused instead of signed.
package/README.md CHANGED
@@ -252,7 +252,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
252
252
  | `pki.sigstore` | Offline verifier for a Sigstore bundle, the artifact `npm publish --provenance` produces and the registry serves. `verifyBundle` composes five fail-closed legs against caller-pinned trust (Fulcio CA roots and Rekor log keys, never trusted from the bundle): the DSSE signature over its PAE preimage under the Fulcio leaf key, the ephemeral Fulcio certificate chain validated as of the Rekor log time, the RFC 9162 inclusion proof folded to a Rekor-signed tree root, the log entry bound to this exact signature, and the in-toto SLSA subject digest the caller confirms against the published artifact. It reuses the X.509 parser, the RFC 5280 path validator, and the Merkle verifier; the net-new codecs are the DSSE PAE byte-builder and a fail-closed JSON reader. `verified: true` says the artifact was signed and logged, not that a party you trust signed it — Fulcio issues to anyone who completes an OIDC flow, so who signed is decided only by `opts.identity`, and `identityChecked` reports which of its fields were compared. An identity policy naming no field, or a field name that is not one of the three, is refused rather than satisfied — either would accept every signer while reading as a policy in force. A sixth leg is opt-in: pinning the certificate-transparency logs in `opts.ctLogs` checks the receipt Fulcio embeds in the certificate it issues (RFC 6962 §3.2), verified over the certificate as it stood before the receipt was added and under the key of the log that issued it, so the verdict says the signing certificate was public when it was issued rather than handed out quietly. At least one receipt must verify against a pinned log, the verdict reports `sctChecked` and `validScts`, and an empty log set is refused rather than read as a policy that checks nothing. Both content arms are read: a DSSE-wrapped in-toto attestation, and a message signature over an artifact's own bytes, which `cosign sign-blob` produces. For that arm `opts.artifact` supplies the bytes, and they are held to the hash the Rekor `hashedrekord` entry records before the signature is checked over them; the `messageDigest` the bundle carries is covered by no signature, so it is compared against the computed digest and never used in its place. `contentType` names the arm and the verdict has one shape for both — `pae`, `parseBundle`, `verifyBundle` |
253
253
  | `pki.inspect` | Human-readable inspection, the pure-JS equivalent of `openssl x509/crl/req/cms -text`. `certificate(pem \| der \| parsed)` renders an OpenSSL-style report: version, serial, signature algorithm, issuer and subject distinguished names, validity, public-key details (curve or modulus size plus the raw point or modulus), every decoded extension with its critical flag, and the signature. `crl`, `csr`, and `cms` render the other formats the same way — a CRL like `openssl crl -text`, a CSR like `openssl req -text`, and a CMS message like `openssl cms -cmsout -print`, with a stable summary for a non-SignedData ContentInfo — and `any(input)` detects the format and routes to the right report. Built over the strict parsers and the two-way OID registry with one set of field renderers, it names extension and algorithm OIDs an OpenSSL build shows only as raw bytes. No OpenSSL dependency, and the format is stable and OpenSSL-familiar rather than pinned to one OpenSSL version. A certificate policy's user notice renders as text, both its explicit text and a notice reference with the notice numbers that identify it, rather than hex, and a malformed part falls back to a hex dump rather than throwing — `certificate`, `crl`, `csr`, `cms`, `any` |
254
254
  | `pki.webauthn` | WebAuthn and passkey verification, both halves: offline trust evaluation of a W3C WebAuthn (Level 3) registration, and signature verification of the assertion every login returns. `parseAttestationObject(bytes)` decodes the CBOR attestation object, authenticatorData, and COSE credential key over the strict `pki.cbor` codec; `parseAuthenticatorData(bytes)` reads the bare form an assertion carries through the same parser; `parseClientData(bytes, opts)` decodes the `clientDataJSON` no signature check looks inside, through the shared JSON guard since these are attacker-chosen bytes, returning the challenge decoded so a caller compares bytes rather than spellings, and checking the ceremony type, challenge, and origin when the relying party supplies what it issued. `verifyAssertion(input)` verifies an assertion signature over `authenticatorData \|\| SHA-256(clientDataJSON)` — raw bytes, no COSE_Sign1, an ES256 signature in ASN.1 DER — and applies the §7.2 step 21 counter rule when a stored `previousSignCount` is given, so a counter that fails to advance is refused as a cloned authenticator. `verify(attestationObject, clientDataHash, opts)` checks the attestation-statement signature and each format's structural bindings for packed, tpm, android-key, apple, fido-u2f, and none: the x5c leaf key, the apple nonce, the tpm `certInfo` Name and `extraData` over the `pubArea`, the android `KeyDescription`, and the fido-u2f `verificationData`. It binds the credential public key to each attestation, through the signed authenticatorData for packed and fido-u2f or a cert or `pubArea`-key equality check for android-key, apple, and tpm, and enforces each leaf's certificate requirements. The credential-key check covers the full WebAuthn COSE algorithm set — ES256/384/512, RS256/384/512, PS256/384/512, EdDSA (Ed25519), and the RFC 9864 fully-specified identifiers ESP256/384/512, Ed25519, and Ed448 — validating the public-key point on its curve, rejecting the compressed EC point form, and enforcing a minimally encoded DER ECDSA signature. The verdict field is `attestationVerified`, and `signatureVerified` for an assertion, rather than a bare `verified`, because a sound statement is a different claim from an acceptable ceremony: an attestation naming another origin's RP ID with user presence clear is perfectly sound and must not be registered. Pass `expectedRpId`, `requireUserPresence`, `requireUserVerification`, or `allowedAlgorithms` and those are checked, with `bindingChecked` reporting which ran, so a check that passed can be told from one that never happened. Pass `opts.clientDataJSON` instead of the digest and the client data is read here too: the ceremony type is checked unconditionally, since a login response replayed into a registration is what that rule stops, and the challenge, origin, and top-level origin are checked against what the relying party issued, with `clientData.checked` reporting which comparisons ran. A registration verdict also carries the `credentialId`, `credentialPublicKey`, and initial `signCount` a later login needs. A credential key declaring COSE algorithm `-65535` (RSASSA-PKCS1-v1_5 with SHA-1) is refused unless `allowedAlgorithms` names it, since every signature that credential ever makes would use SHA-1. Anchoring the trust path has two routes: `opts.metadata` resolves the roots the authenticator's own model registered, and `opts.rootCertificates` pins roots directly, which is what anchors the formats FIDO MDS does not cover, Apple's authenticators and the Google hardware-attestation roots among them. `metadata` governs when both are given, and `anchoredTo` names every route that anchored the path, joined with `+` when more than one did: `"metadata"`, `"rootCertificates"`, and `"safetyNetRoots"` for the android-safetynet chain, which anchors through the roots that format requires whether or not either other route was asked for. It is `null` only when nothing anchored the path. `verifyMetadataBlob(blob, opts)` reads a FIDO Metadata Service (MDS v3) BLOB, the signed catalogue of registered authenticator models, verifying its JWS and chaining its signer to an operator-supplied FIDO root before the payload is parsed, with sequence-number rollback and `nextUpdate` freshness checks. Passing the result as `opts.metadata` to `verify` resolves the authenticator's registered attestation roots from its identifier and requires the trust path to fully validate to one of them, refusing an unlisted or revoked model. `metadataAnchors(entry, opts)` applies the same status gate for a caller anchoring the path themselves: a model the catalogue has disqualified registers no anchors. Both of the catalogue's key spaces are covered: an aaguid, and the attestation-certificate key identifiers a U2F authenticator is listed under instead. No FIDO root is bundled, there is no trust-on-first-use, and retrieving the BLOB is out of scope. Fail-closed with typed `webauthn/*` errors — `parseAttestationObject`, `verify`, `verifyMetadataBlob`, `metadataFor`, `metadataAnchors` |
255
- | `pki.lint` | Certificate linting, the zlint or pkilint of JavaScript. `certificate(pem \| der \| parsed, opts)` walks a parsed certificate and emits graded advisory findings, each with a stable id, a severity (`fatal`, `error`, `warn`, `notice`), a source, a spec-clause citation, and a message, against the RFC 5280 profile, the post-quantum certificate profiles, and a representative CA/Browser Forum TLS BR subset: serial sign and size, validity ordering and the SC081v3 reducing validity schedule, keyCertSign coherence, extension criticality (basicConstraints, nameConstraints, policyConstraints and inhibitAnyPolicy must be critical, and keyUsage should be), nameConstraints CA-scope, unknown critical extensions, empty-subject SAN, SKI and AKI presence including the end-entity subjectKeyIdentifier, SAN required and CN-in-SAN, dNSName syntax, serverAuth EKU, weak keys, the post-quantum key-usage rules binding a certificate's keyUsage bits to its subject key type (ML-KEM under RFC 9935 §5, ML-DSA under RFC 9881 §5, and SLH-DSA under RFC 9909 §6 across both its pure and prehash identifiers) together with the ML-KEM encapsulation-key encoding and size, and the §4.2.1.4 certificate-policy user-notice rules (a VisibleString or BMPString explicitText, a notice past 200 characters, an empty notice, control characters, and a non-NFC UTF8String notice, each at the strength the clause states). Alone among these entries the data path never throws: hostile bytes return a `fatal` `lint/unparseable` finding carrying the strict parser's code, so a whole directory lints without a try/catch, and only config-time misuse throws a typed `LintError` — `certificate`, `rules`, `profiles` |
255
+ | `pki.lint` | Certificate linting, the zlint or pkilint of JavaScript. `certificate(pem \| der \| parsed, opts)` walks a parsed certificate and emits graded advisory findings, each with a stable id, a severity (`fatal`, `error`, `warn`, `notice`), a source, a spec-clause citation, and a message, against the RFC 5280 profile, the post-quantum certificate profiles, and a representative CA/Browser Forum TLS BR subset: serial sign and size, validity ordering and the SC081v3 reducing validity schedule, keyCertSign coherence, extension criticality (basicConstraints, nameConstraints, policyConstraints and inhibitAnyPolicy must be critical, and keyUsage should be), nameConstraints CA-scope, unknown critical extensions, empty-subject SAN, SKI and AKI presence including the end-entity subjectKeyIdentifier, SAN required and CN-in-SAN, dNSName syntax, serverAuth EKU, weak keys, the post-quantum key-usage rules binding a certificate's keyUsage bits to its subject key type (ML-KEM under RFC 9935 §5, ML-DSA under RFC 9881 §5, and SLH-DSA under RFC 9909 §6 across both its pure and prehash identifiers) together with the ML-KEM encapsulation-key encoding and size, and the §4.2.1.4 certificate-policy user-notice rules (a VisibleString or BMPString explicitText, a notice past 200 characters, an empty notice, control characters, and a non-NFC UTF8String notice, each at the strength the clause states). Alone among these entries the data path never throws: hostile bytes return a `fatal` `lint/unparseable` finding carrying the strict parser's code, so a whole directory lints without a try/catch, and only config-time misuse throws a typed `LintError` — `certificate`, `crl` (the RFC 5280 §5 revocation-list profile: nextUpdate presence, update ordering, cRLNumber presence and its 20-octet ceiling, authorityKeyIdentifier presence, the criticality each profiled CRL and entry extension must carry, freshestCRL in a delta CRL, and unrecognized critical extensions), `rules`, `profiles` |
256
256
  | `pki.C` / `pki.constants` | Version-stable constants: the functional scale helpers `C.TIME.*` and `C.BYTES.*`, the codec `LIMITS`, and `version` |
257
257
  | `pki.errors` | The `PkiError` taxonomy — `defineClass` plus `ConstantsError`, `Asn1Error`, `OidError`, `PemError`, `CertificateError`, `CrlError`, `CsrError`, `Pkcs8Error`, `CmsError`, `OcspError`, `TspError`, `AttrCertError`, `CrmfError`, `Pkcs12Error`, `CmpError`, `PathError`, `CtError`, `JoseError`, `AcmeError`, `WebauthnError`, and `LintError`, each carrying a stable `code` in `domain/reason` form |
258
258
  | `pki` CLI | `pki version`, `pki oid <dotted\|name>`, `pki parse <cert>`, `pki inspect <cert>`, `pki lint <cert>`, `pki convert <file> --to der\|pem`, `pki verify <cert>... --anchor <cert>`, `pki sign <file> --cert <c> --key <k>` |
package/lib/lint.js CHANGED
@@ -5,14 +5,16 @@
5
5
  * @module pki.lint
6
6
  * @nav Tooling
7
7
  * @title Lint
8
- * @fullname Certificate linting against CA/Browser Forum requirements
9
- * @intro The certificate LINTING engine, the zlint / pkilint of JavaScript. It walks an
10
- * ALREADY-PARSED certificate (from `pki.schema.x509.parse`, whose extension values it
11
- * decodes with the shared RFC 5280 decoders) and emits graded, advisory FINDINGS: each
12
- * with a stable id, a severity (`fatal` > `error` > `warn` > `notice` > `pass`), a source,
13
- * a spec-clause citation, and a human message. It ships the RFC 5280 certificate profile,
14
- * the post-quantum certificate profiles for ML-KEM, ML-DSA and SLH-DSA, and a
15
- * representative CA/Browser Forum TLS Baseline Requirements subset.
8
+ * @fullname Certificate and revocation-list linting against RFC 5280 and CA/Browser Forum
9
+ * requirements
10
+ * @intro The LINTING engine, the zlint / pkilint of JavaScript. It walks an ALREADY-PARSED
11
+ * certificate or certificate revocation list (from `pki.schema.x509.parse` or
12
+ * `pki.schema.crl.parse`, whose extension values it decodes with the shared RFC 5280
13
+ * decoders) and emits graded, advisory FINDINGS: each with a stable id, a severity
14
+ * (`fatal` > `error` > `warn` > `notice` > `pass`), a source, a spec-clause citation, and
15
+ * a human message. It ships the RFC 5280 certificate profile, the RFC 5280 section 5
16
+ * revocation-list profile, the post-quantum certificate profiles for ML-KEM, ML-DSA and
17
+ * SLH-DSA, and a representative CA/Browser Forum TLS Baseline Requirements subset.
16
18
  *
17
19
  * Unlike every other toolkit entry, the lint data path never throws. A linter surveys a
18
20
  * corpus that includes malformed members, so `pki.lint.certificate(hostileBytes)` returns
@@ -22,7 +24,7 @@
22
24
  * raises a typed `LintError`. This deliberate inversion of the toolkit's fail-closed-throw
23
25
  * posture is what lets an operator lint a whole directory without a try/catch per file.
24
26
  * @spec RFC 5280, RFC 9881, RFC 9909, RFC 9935, CA/Browser Forum TLS Baseline Requirements
25
- * @card Lint a certificate against RFC 5280 + CABF TLS BR, in pure JS.
27
+ * @card Lint a certificate or CRL against RFC 5280 + CABF TLS BR, in pure JS.
26
28
  */
27
29
 
28
30
  var frameworkError = require("./framework-error");
@@ -30,7 +32,9 @@ var asn1 = require("./asn1-der");
30
32
  var guard = require("./guard-all");
31
33
  var oid = require("./oid");
32
34
  var x509 = require("./schema-x509");
35
+ var crlSchema = require("./schema-crl");
33
36
  var pkix = require("./schema-pkix");
37
+ var schema = require("./schema-engine");
34
38
  var path = require("./path-validate");
35
39
  var C = require("./constants");
36
40
  var ipUtils = require("./ip-utils");
@@ -149,12 +153,27 @@ function _runLints(rules, cert, ctx) {
149
153
  }
150
154
 
151
155
 
156
+ /** @internal The octet length of a big-endian magnitude. DER prefixes a 0x00 to a positive integer
157
+ * whose top bit is set, so that octet is encoding overhead rather than part of the number. Every
158
+ * rule that measures a number against a ceiling reads it here, so no two of them can answer
159
+ * differently for one value.
160
+ *
161
+ * The ceiling is measured against the VALUE, not its encoding. Sections 4.1.2.2 and 5.2.3 bound
162
+ * "serialNumber values" and "CRLNumber values", and each requires that users be able to handle
163
+ * values up to 20 octets. Every value from 2^159 through 2^160-1 is a 20-octet value whose DER
164
+ * content needs a 21st octet for the sign, so reading the ceiling against the encoding would make
165
+ * conforming values unrepresentable. Measuring the encoding is a real reading elsewhere in the
166
+ * ecosystem, which is why some CAs clear the top bit of a 20-octet serial; this is a deliberate
167
+ * choice between the two, not an oversight. */
168
+ function _magnitudeOctets(buf) {
169
+ if (buf.length > 1 && buf[0] === 0x00 && (buf[1] & 0x80)) return buf.length - 1;
170
+ return buf.length;
171
+ }
172
+
152
173
  function _serialOctets(cert) {
153
174
  var hex = cert.serialNumberHex || "";
154
175
  if (hex.length % 2) hex = "0" + hex;
155
- var buf = Buffer.from(hex, "hex");
156
- if (buf.length > 1 && buf[0] === 0x00 && (buf[1] & 0x80)) buf = buf.subarray(1);
157
- return buf.length;
176
+ return _magnitudeOctets(Buffer.from(hex, "hex"));
158
177
  }
159
178
 
160
179
  function _dnsNameProblem(s) { return pkix.dnsNameProblem(s); }
@@ -559,6 +578,709 @@ var RFC9909_RULES = [
559
578
  _pqcSigKeyUsageRule("lint/rfc9909/slhdsa-key-usage", "rfc9909", "RFC 9909 6", "SLH-DSA", _isSlhDsa),
560
579
  ];
561
580
 
581
+ /** @internal RFC 5280 sec. 5 states the criticality of each CRL extension it profiles, and sec. 5.3
582
+ * does the same for the entry extensions. certificateIssuer is critical (sec. 5.3.3) because a
583
+ * consumer that ignores it attributes an entry to the wrong issuer. */
584
+ /** @internal The extensions section 5.2 profiles for a CRL. This is the RECOGNIZED set, which is
585
+ * wider than the table below: issuerAltName is profiled here but its criticality is a SHOULD, so it
586
+ * is recognized without being held to a required value. */
587
+ var CRL_KNOWN_EXTENSIONS = Object.create(null);
588
+ ["authorityKeyIdentifier", "issuerAltName", "cRLNumber", "deltaCRLIndicator",
589
+ "issuingDistributionPoint", "freshestCRL", "authorityInfoAccess"]
590
+ .forEach(function (n) { CRL_KNOWN_EXTENSIONS[oid.byName(n)] = true; });
591
+
592
+ /** @internal Only the extensions whose criticality section 5.2 states as a MUST. issuerAltName is
593
+ * deliberately absent: section 5.2.2 says a conforming issuer SHOULD mark it non-critical, and
594
+ * grading a SHOULD as an error would promote the requirement level the clause states. */
595
+ var CRL_CRITICALITY = Object.create(null);
596
+ [["cRLNumber", false], ["authorityKeyIdentifier", false], ["freshestCRL", false],
597
+ ["authorityInfoAccess", false],
598
+ ["issuingDistributionPoint", true], ["deltaCRLIndicator", true]]
599
+ .forEach(function (r) { CRL_CRITICALITY[oid.byName(r[0])] = r[1]; });
600
+ var CRL_ENTRY_CRITICALITY = Object.create(null);
601
+ [["reasonCode", false], ["invalidityDate", false], ["certificateIssuer", true]]
602
+ .forEach(function (r) { CRL_ENTRY_CRITICALITY[oid.byName(r[0])] = r[1]; });
603
+
604
+ function _crlExtList(crl) { return crl.crlExtensions || []; }
605
+ function _crlExtByName(crl, name) {
606
+ var dotted = oid.byName(name), list = _crlExtList(crl);
607
+ for (var i = 0; i < list.length; i++) { if (list[i].oid === dotted) return list[i]; }
608
+ return null;
609
+ }
610
+ /** @internal An AuthorityKeyIdentifier value read through the shared extension decoder, which holds
611
+ * it to the structure RFC 5280 sec. 4.2.1.1 states. Scanning for a [0] child by hand would admit a
612
+ * SET carrying one, and a SEQUENCE whose [0] is constructed, neither of which is an
613
+ * AuthorityKeyIdentifier. Bytes the decoder refuses come back as null and the caller reports that;
614
+ * nothing here returns a verdict. */
615
+ function _akiValue(bytes) {
616
+ var dec = EXT_DECODERS[oid.byName("authorityKeyIdentifier")];
617
+ if (!dec) return null;
618
+ // allow:swallow-unverified a value the shared decoder refuses is not an AuthorityKeyIdentifier, which the caller reports
619
+ try { return dec(bytes) || null; }
620
+ catch (_e) { return null; }
621
+ }
622
+
623
+ /** @internal The decoded names of a GeneralNames value, or null when the bytes are not one. The
624
+ * value-syntax row reports that separately, so nothing here returns a verdict. */
625
+ function _generalNamesOf(bytes) {
626
+ // allow:swallow-unverified bytes that are not GeneralNames carry no names for the caller to read
627
+ try {
628
+ var m = schema.walk(pkix.generalNames(NS, { decodeValue: true, code: _LINT_VALUE_CODE }), asn1.decode(bytes), NS);
629
+ return (m.result && m.result.names) || null;
630
+ } catch (_e) { return null; }
631
+ }
632
+
633
+ /** @internal A CRLReason value mapped onto its ReasonFlags bit. The two enumerations do NOT line
634
+ * up: RFC 5280 sec. 5.3.1 leaves CRLReason 7 unused and numbers privilegeWithdrawn 9 and
635
+ * aACompromise 10, while sec. 4.2.1.13 gives them ReasonFlags bits 7 and 8. unspecified(0) and
636
+ * removeFromCRL(8) have no bit at all, so neither can sit outside a reason scope and both are
637
+ * absent here; their own rows answer for them. Assuming identity would report conforming CRLs. */
638
+ var REASON_TO_FLAG_BIT = Object.create(null);
639
+ [[1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [6, 6], [9, 7], [10, 8]]
640
+ .forEach(function (p) { REASON_TO_FLAG_BIT[p[0]] = p[1]; });
641
+
642
+ /** @internal Whether a ReasonFlags BIT STRING has the given bit set. */
643
+ function _reasonBitSet(bs, bit) {
644
+ if (!bs || !bs.bytes) return false;
645
+ var byteI = bit >> 3;
646
+ if (byteI >= bs.bytes.length) return false;
647
+ return (bs.bytes[byteI] & (0x80 >> (bit & 7))) !== 0;
648
+ }
649
+
650
+ /** @internal Whether a ReasonFlags mask names at least one reason code. Bit 0 is named `unused`,
651
+ * so a mask setting only it, or setting nothing at all, names none and partitions nothing.
652
+ *
653
+ * Bit 0 set ALONGSIDE a real reason is deliberately not reported. Section 5.2.5 requires the reason
654
+ * codes associated with a distribution point to be specified here, and such a mask specifies one;
655
+ * RFC 5280 states nothing about bit 0 being clear, so reporting it would name a violation the
656
+ * profile does not state. */
657
+ function _scopeNamesAnyReason(bs) {
658
+ for (var bit = 1; bit <= 8; bit++) { if (_reasonBitSet(bs, bit)) return true; }
659
+ return false;
660
+ }
661
+
662
+ /** @internal The ReasonFlags of a CRL's issuingDistributionPoint, or null when it states no reason
663
+ * scope. Read through the shared decoder, so a malformed one scopes nothing. A mask naming no
664
+ * reason scopes nothing either: it gives an entry no partition to belong to, so the extension is
665
+ * what is at fault and the profile row reports it rather than every entry in turn. */
666
+ function _crlReasonScope(crl) {
667
+ var e = _crlExtByName(crl, "issuingDistributionPoint");
668
+ if (!e || !Buffer.isBuffer(e.value)) return null;
669
+ var idp = pkix.decodeIssuingDistributionPointValue(NS, e.value, _LINT_VALUE_CODE);
670
+ if (idp.malformed || !idp.onlySomeReasonsPresent) return null;
671
+ if (!_scopeNamesAnyReason(idp.onlySomeReasonsValue)) return null;
672
+ return idp.onlySomeReasonsValue || null;
673
+ }
674
+
675
+ /** @internal Whether the CRL is scoped to some reason codes, which is what makes a revocation
676
+ * reason load-bearing: the reason decides which partition an entry belongs in. Reads the scope
677
+ * itself so the two cannot answer differently about the same CRL. */
678
+ function _crlHasReasonScope(crl) {
679
+ return _crlReasonScope(crl) !== null;
680
+ }
681
+
682
+ /** @internal Whether the CRL affirmatively declares itself DIRECT, read from the same
683
+ * IssuingDistributionPoint decoder certification-path validation uses. Three cases, because a
684
+ * malformed extension is not the same as an absent one: a CRL carrying no issuingDistributionPoint
685
+ * is direct by omission, a well-formed one answers with its indirectCRL flag, and one that does not
686
+ * decode declares neither direction. The rows that only mean something on a direct CRL stand aside
687
+ * for that last case, so an unreadable declaration is reported against the extension by the
688
+ * value-syntax row rather than against each entry in turn. That row is an error, so no CRL reaches
689
+ * a clean report with those rows unevaluated. */
690
+ function _crlDeclaresDirect(crl) {
691
+ var e = _crlExtByName(crl, "issuingDistributionPoint");
692
+ if (!e) return true;
693
+ if (!Buffer.isBuffer(e.value)) return false;
694
+ var idp = pkix.decodeIssuingDistributionPointValue(NS, e.value, _LINT_VALUE_CODE);
695
+ if (idp.malformed) return false;
696
+ return idp.indirect !== true;
697
+ }
698
+
699
+ var CA_ISSUERS_OID = oid.byName("caIssuers");
700
+
701
+ /** @internal A recognized CRL extension's value, read through the same decoder every other consumer
702
+ * of that extension uses. null when the extension is absent or its value does not read, and the
703
+ * value-syntax row reports the latter on its own. */
704
+ function _crlDecodedExtension(crl, name) {
705
+ var e = _crlExtByName(crl, name);
706
+ if (!e || !Buffer.isBuffer(e.value)) return null;
707
+ var dec = EXT_DECODERS[oid.byName(name)];
708
+ if (!dec) return null;
709
+ try {
710
+ return dec(e.value);
711
+ } catch (_e) {
712
+ return null;
713
+ }
714
+ }
715
+
716
+ /** @internal Whether a string opens with the given URI scheme followed by a colon, compared
717
+ * without regard to case by scanning character codes. */
718
+ function _uriSchemeIs(s, scheme) {
719
+ if (typeof s !== "string" || s.length <= scheme.length) return false;
720
+ for (var i = 0; i < scheme.length; i++) {
721
+ var c = s.charCodeAt(i);
722
+ if (c >= 65 && c <= 90) c += 32;
723
+ if (c !== scheme.charCodeAt(i)) return false;
724
+ }
725
+ return s.charCodeAt(scheme.length) === 58;
726
+ }
727
+
728
+ /** @internal The schemes that satisfy the section 5.2.7 recommendation. The secured forms count:
729
+ * warning about an https or ldaps location would report the more protected spelling of the two the
730
+ * clause names. */
731
+ var AIA_RECOMMENDED_SCHEMES = ["http", "https", "ldap", "ldaps"];
732
+
733
+ /** @internal The DER content length an integer occupies, which is what the 20-octet ceilings in
734
+ * RFC 5280 bound. Measured by re-encoding, since the parser hands back the decoded value; a
735
+ * non-minimal encoding cannot reach here because strict DER refuses one at parse. */
736
+ function _integerOctets(v) {
737
+ if (typeof v !== "bigint") return null;
738
+ // allow:swallow-unverified a value the codec cannot re-encode is not one this can size
739
+ try { return _magnitudeOctets(asn1.decode(asn1.build.integer(v)).content); }
740
+ catch (_e) { return null; }
741
+ }
742
+
743
+ /** @internal The extensions that carry a CRLNumber. RFC 5280 sec. 5.2.4 defines BaseCRLNumber as a
744
+ * CRLNumber, so the sec. 5.2.3 ceiling bounds the delta indicator's value as well as the CRL's own
745
+ * number. The parser decodes cRLNumber to a bigint and leaves deltaCRLIndicator opaque, so each
746
+ * carrier names how its value is read. */
747
+ var CRL_NUMBER_CARRIERS = [
748
+ { name: "cRLNumber", read: function (e) { return e.value; } },
749
+ { name: "deltaCRLIndicator", read: function (e) { return _crlIntegerValue(e.value); } },
750
+ ];
751
+
752
+ /** @internal An extension value the CRL parser leaves opaque, read as the INTEGER it should be.
753
+ * Bytes that are not one come back as null and the caller decides what that means. */
754
+ function _crlIntegerValue(bytes) {
755
+ if (!Buffer.isBuffer(bytes)) return null;
756
+ // allow:swallow-unverified bytes that are not an INTEGER are reported by the value-syntax row, not decided here
757
+ try {
758
+ var n = asn1.decode(bytes);
759
+ if (n.tagClass !== "universal" || n.tagNumber !== asn1.TAGS.INTEGER) return null;
760
+ return asn1.read.integer(n);
761
+ } catch (_e) { return null; }
762
+ }
763
+
764
+ /** @internal How each recognized extension's value is read, for the ones the CRL parser leaves as
765
+ * opaque bytes. Each walks the shared schema for that structure rather than testing an outer tag:
766
+ * a SEQUENCE carrying a NULL is not an IssuingDistributionPoint, and matching the outer type would
767
+ * admit it. Where a shared decoder exists it answers instead, and where the parser already decoded
768
+ * the value to a JavaScript type there is nothing left to check. */
769
+ var _LINT_VALUE_CODE = "lint/bad-extension-value";
770
+ var CRL_VALUE_READER = Object.create(null);
771
+ CRL_VALUE_READER[oid.byName("deltaCRLIndicator")] = function (bytes) {
772
+ var n = asn1.decode(bytes);
773
+ if (n.tagClass !== "universal" || n.tagNumber !== asn1.TAGS.INTEGER) throw _cfg(_LINT_VALUE_CODE, "not an INTEGER");
774
+ /** @internal BaseCRLNumber is a CRLNumber, which RFC 5280 sec. 5.2.3 constrains to a
775
+ * non-negative integer, so a negative one is not a BaseCRLNumber at all. */
776
+ if (asn1.read.integer(n) < 0n) throw _cfg(_LINT_VALUE_CODE, "a negative BaseCRLNumber");
777
+ };
778
+ /** @internal Read through the SAME decoder certification-path validation uses, rather than a walk
779
+ * assembled here. The shared IssuingDistributionPoint schema leaves distributionPoint as `any`, so
780
+ * validating only that far accepts a wrapper carrying something that is not a DistributionPointName
781
+ * at all, and the linter would call well formed what the path validator rejects. */
782
+ CRL_VALUE_READER[oid.byName("issuingDistributionPoint")] = function (bytes) {
783
+ if (pkix.decodeIssuingDistributionPointValue(NS, bytes, _LINT_VALUE_CODE).malformed) {
784
+ throw _cfg(_LINT_VALUE_CODE, "malformed IssuingDistributionPoint");
785
+ }
786
+ };
787
+ /** @internal Only certificateIssuer. The parser decodes reasonCode and invalidityDate itself and
788
+ * refuses a value that is not the type they name, so an entry for either would be dead. */
789
+ var CRL_ENTRY_VALUE_READER = Object.create(null);
790
+ CRL_ENTRY_VALUE_READER[oid.byName("certificateIssuer")] = function (bytes) {
791
+ schema.walk(pkix.generalNames(NS, { code: _LINT_VALUE_CODE }), asn1.decode(bytes), NS);
792
+ };
793
+
794
+ /** @internal Whether a recognized extension's value reads as the structure its identifier names.
795
+ * Returns null when it does, and a reason when it does not. An extension whose value the parser
796
+ * already turned into a JavaScript value needs no check: it could not have been decoded otherwise. */
797
+ function _valueSyntaxFault(e, knownTable, readerTable) {
798
+ /** @internal Only an extension section 5 profiles FOR A CRL has a syntax to enforce here. A
799
+ * certificate-only extension carried on a CRL is an unrecognized one, which section 5.2 lets a
800
+ * consumer ignore when it is non-critical and the unknown-critical row reports when it is not;
801
+ * decoding its value against the CERTIFICATE profile would report a fault no CRL rule states. */
802
+ if (knownTable[e.oid] === undefined) return null;
803
+ var dec = EXT_DECODERS[e.oid];
804
+ if (dec && Buffer.isBuffer(e.value)) {
805
+ // allow:swallow-unverified the decoder's refusal IS the fault this returns
806
+ try { dec(e.value); return null; }
807
+ catch (_e) { return "does not decode as the structure its extension identifier names"; }
808
+ }
809
+ var read = readerTable[e.oid];
810
+ if (read === undefined || !Buffer.isBuffer(e.value)) return null;
811
+ // allow:swallow-unverified the reader's refusal IS the fault this returns
812
+ try { read(e.value); return null; }
813
+ catch (_e) { return "does not decode as the structure its extension identifier names"; }
814
+ }
815
+
816
+ function _criticalityRows(list, table, what) {
817
+ var out = [];
818
+ (list || []).forEach(function (e) {
819
+ /** @internal The table has a null prototype and boolean values, so an absent OID is undefined. */
820
+ var want = table[e.oid];
821
+ if (want === undefined) return;
822
+ if ((e.critical === true) !== want) {
823
+ out.push({ context: { extension: e.name || e.oid, required: want ? "critical" : "non-critical", was: e.critical === true ? "critical" : "non-critical", scope: what } });
824
+ }
825
+ });
826
+ return out;
827
+ }
828
+
829
+ /** @internal Three of the section 5 rules have no row here, because the strict parser refuses them
830
+ * before a rule could run and a rule for them would be dead: the version against the extensions
831
+ * present (sec. 5.1.2.1, `crl/bad-version`), an empty issuer name (sec. 5.1.2.3, `crl/bad-issuer`),
832
+ * and a present-but-empty revokedCertificates (sec. 5.1.2.6, `crl/bad-revoked-certificates`). Each
833
+ * reaches a caller as the engine's fatal lint/unparseable carrying that code. */
834
+ var RFC5280_CRL_RULES = [
835
+ {
836
+ id: "lint/rfc5280-crl/next-update-missing", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.1.2.5",
837
+ message: "a conforming CRL issuer includes the nextUpdate field in every CRL",
838
+ check: function (crl) { return crl.nextUpdate == null ? true : null; },
839
+ },
840
+ {
841
+ id: "lint/rfc5280-crl/update-times-inverted", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.1.2.4 / 5.1.2.5",
842
+ message: "a CRL's thisUpdate must not follow its nextUpdate",
843
+ check: function (crl) {
844
+ // allow:nan-date-comparison-unguarded -- both are codec-parsed CRL times (asn1 readTime rejects a NaN instant).
845
+ if (!guard.time.isDate(crl.thisUpdate) || !guard.time.isDate(crl.nextUpdate)) return null;
846
+ return guard.time.instantOf(crl.thisUpdate) > guard.time.instantOf(crl.nextUpdate) ? true : null;
847
+ },
848
+ },
849
+ {
850
+ id: "lint/rfc5280-crl/crl-number-missing", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.2.3",
851
+ message: "a conforming CRL issuer includes the cRLNumber extension in every CRL",
852
+ check: function (crl) { return _crlExtByName(crl, "cRLNumber") ? null : true; },
853
+ },
854
+ {
855
+ id: "lint/rfc5280-crl/crl-number-too-long", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.2.3 / 5.2.4",
856
+ /** @internal One requirement over two carriers, so one row measures both and the context names
857
+ * which one it measured. A value that is not an INTEGER sizes as null here and is reported by
858
+ * the value-syntax row instead. */
859
+ message: "a CRL number must not be longer than 20 octets",
860
+ check: function (crl) {
861
+ var out = [];
862
+ CRL_NUMBER_CARRIERS.forEach(function (carrier) {
863
+ var e = _crlExtByName(crl, carrier.name);
864
+ if (!e) return;
865
+ var octets = _integerOctets(carrier.read(e));
866
+ if (octets !== null && octets > 20) out.push({ context: { extension: carrier.name, octets: octets } });
867
+ });
868
+ return out.length ? out : null;
869
+ },
870
+ },
871
+ {
872
+ id: "lint/rfc5280-crl/aki-missing", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.2.1",
873
+ /** @internal Section 5.2.1 states this in the same terms section 5.2.3 states cRLNumber:
874
+ * conforming CRL issuers MUST include the extension in all CRLs. Two clauses of the same
875
+ * strength are graded the same, so a caller filtering at `error` sees both or neither. The
876
+ * certificate profile grades its own authorityKeyIdentifier row differently, and that is
877
+ * shipped surface left as it is rather than re-graded from here. */
878
+ message: "a conforming CRL issuer includes an authorityKeyIdentifier in every CRL",
879
+ check: function (crl) { return _crlExtByName(crl, "authorityKeyIdentifier") ? null : true; },
880
+ },
881
+ {
882
+ id: "lint/rfc5280-crl/aki-without-key-identifier", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.2.1",
883
+ message: "a CRL's authorityKeyIdentifier must be well formed and use the key identifier method",
884
+ /** @internal Section 5.2.1 states two requirements: include the extension, and use the key
885
+ * identifier method. The row above answers the first. This answers the second, and it also
886
+ * answers for the value being an AuthorityKeyIdentifier at all, since the CRL parser leaves
887
+ * this extension as opaque bytes and nothing else reads it. */
888
+ appliesTo: function (crl) { return !!_crlExtByName(crl, "authorityKeyIdentifier"); },
889
+ check: function (crl) {
890
+ var e = _crlExtByName(crl, "authorityKeyIdentifier");
891
+ if (!Buffer.isBuffer(e.value)) return null;
892
+ var v = _akiValue(e.value);
893
+ if (v === null) return { context: { undecodable: true } };
894
+ return v.keyIdentifier ? null : true;
895
+ },
896
+ },
897
+ {
898
+ id: "lint/rfc5280-crl/extension-criticality", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.2",
899
+ message: "a CRL extension is marked with a criticality the profile does not allow",
900
+ check: function (crl) {
901
+ var rows = _criticalityRows(_crlExtList(crl), CRL_CRITICALITY, "crlExtensions");
902
+ return rows.length ? rows : null;
903
+ },
904
+ },
905
+ {
906
+ id: "lint/rfc5280-crl/entry-extension-criticality", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.3",
907
+ message: "a CRL entry extension is marked with a criticality the profile does not allow",
908
+ check: function (crl) {
909
+ /** @internal Appended rather than concatenated: a CRL may list very many entries, and this
910
+ * verb exists to survey ones that arrived from elsewhere, so rebuilding the accumulator per
911
+ * entry would make the rule quadratic in the length of a list an attacker chooses. */
912
+ var rows = [];
913
+ (crl.revokedCertificates || []).forEach(function (r, i) {
914
+ _criticalityRows(r.crlEntryExtensions, CRL_ENTRY_CRITICALITY, "crlEntryExtensions")
915
+ .forEach(function (row) { row.context.entry = i; rows.push(row); });
916
+ });
917
+ return rows.length ? rows : null;
918
+ },
919
+ },
920
+ {
921
+ id: "lint/rfc5280-crl/issuer-alt-name-critical", severity: "warn", source: "rfc5280-crl", citation: "RFC 5280 5.2.2",
922
+ message: "a conforming CRL issuer should mark the issuerAltName extension as non-critical",
923
+ check: function (crl) {
924
+ var e = _crlExtByName(crl, "issuerAltName");
925
+ return (e && e.critical === true) ? true : null;
926
+ },
927
+ },
928
+ {
929
+ id: "lint/rfc5280-crl/duplicate-entry-serial", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.1.2.6",
930
+ /** @internal On a direct CRL a serial identifies one certificate, so listing it twice states
931
+ * two revocations of the same certificate and a reader cannot tell which entry governs. An
932
+ * INDIRECT CRL is excluded: there a serial is unique per issuer rather than per list, and the
933
+ * effective issuer is carried forward by certificateIssuer, which is a different computation
934
+ * from the one here. */
935
+ appliesTo: function (crl) { return _crlDeclaresDirect(crl); },
936
+ message: "a direct CRL lists the same certificate serial number more than once",
937
+ check: function (crl) {
938
+ var seen = Object.create(null), out = [];
939
+ (crl.revokedCertificates || []).forEach(function (row, i) {
940
+ if (typeof row.serialNumber !== "bigint") return;
941
+ var key = row.serialNumber.toString(16);
942
+ if (seen[key] !== undefined) out.push({ context: { entry: i, firstSeenAt: seen[key], serialNumber: String(row.serialNumber) } });
943
+ else seen[key] = i;
944
+ });
945
+ return out.length ? out : null;
946
+ },
947
+ },
948
+ {
949
+ id: "lint/rfc5280-crl/certificate-issuer-on-direct-crl", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.3.3",
950
+ /** @internal certificateIssuer names the issuer an entry belongs to, which only means something
951
+ * on a CRL that declares itself indirect through the issuingDistributionPoint indirectCRL flag.
952
+ * On a direct CRL the entry and the list disagree about whose certificates are covered, and no
953
+ * revocation status follows from it. */
954
+ appliesTo: function (crl) { return _crlDeclaresDirect(crl); },
955
+ message: "a revoked entry carries certificateIssuer on a CRL that does not declare itself indirect",
956
+ check: function (crl) {
957
+ var out = [];
958
+ (crl.revokedCertificates || []).forEach(function (row, i) {
959
+ (row.crlEntryExtensions || []).forEach(function (e) {
960
+ if (e.oid === oid.byName("certificateIssuer")) out.push({ context: { entry: i } });
961
+ });
962
+ });
963
+ return out.length ? out : null;
964
+ },
965
+ },
966
+ {
967
+ id: "lint/rfc5280-crl/certificate-issuer-without-dn", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.3.3",
968
+ message: "a certificateIssuer must carry the issuer distinguished name of the certificate its entry names",
969
+ /** @internal Section 5.3.3: a conforming issuer MUST include in this extension the distinguished
970
+ * name from the issuer field of the certificate the entry names. In a GeneralNames that is the
971
+ * directoryName [4] arm, so a certificateIssuer carrying only some other name form does not
972
+ * attribute the entry to an issuer at all. Separate from whether the value decodes. */
973
+ check: function (crl) {
974
+ var out = [];
975
+ (crl.revokedCertificates || []).forEach(function (row, i) {
976
+ (row.crlEntryExtensions || []).forEach(function (e) {
977
+ if (e.oid !== oid.byName("certificateIssuer") || !Buffer.isBuffer(e.value)) return;
978
+ var names = _generalNamesOf(e.value);
979
+ if (names === null) return;
980
+ /** @internal A directoryName wrapping an empty Name is a name container holding no name.
981
+ * RFC 5280 sec. 4.1.2.4 gives a certificate's issuer a non-empty distinguished name, so
982
+ * an empty one cannot be the DN sec. 5.3.3 requires, and matching only the tag would
983
+ * accept it. */
984
+ var hasDn = false;
985
+ names.forEach(function (n) {
986
+ if (n.tagNumber === 4 && n.value && (n.value.rdns || []).length > 0) hasDn = true;
987
+ });
988
+ if (!hasDn) out.push({ context: { entry: i } });
989
+ });
990
+ });
991
+ return out.length ? out : null;
992
+ },
993
+ },
994
+ {
995
+ id: "lint/rfc5280-crl/unspecified-reason-in-reason-scoped-crl", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.2.5",
996
+ /** @internal Section 5.2.5: where the issuingDistributionPoint carries onlySomeReasons, every
997
+ * revoked certificate in scope MUST be assigned a reason other than unspecified, because the
998
+ * reason is what decides which partition the entry belongs in. That is a MUST here, where the
999
+ * same encoding is only a SHOULD under section 5.3.1, so it is reported as an error and the
1000
+ * SHOULD row stands aside. An entry carrying NO reasonCode is untouched: the clause says the
1001
+ * extension need not be present. */
1002
+ appliesTo: function (crl) { return _crlHasReasonScope(crl); },
1003
+ message: "a CRL scoped to some reason codes lists an entry whose reasonCode is unspecified(0)",
1004
+ check: function (crl) {
1005
+ var out = [];
1006
+ (crl.revokedCertificates || []).forEach(function (row, i) {
1007
+ (row.crlEntryExtensions || []).forEach(function (e) {
1008
+ if (e.oid === oid.byName("reasonCode") && Number(e.value) === 0) out.push({ context: { entry: i } });
1009
+ });
1010
+ });
1011
+ return out.length ? out : null;
1012
+ },
1013
+ },
1014
+ {
1015
+ id: "lint/rfc5280-crl/reason-outside-crl-scope", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.2.5",
1016
+ /** @internal Section 5.2.5: the reason codes associated with a distribution point MUST be
1017
+ * specified in onlySomeReasons, and the section's own partitioning example shows a CRL carrying
1018
+ * only the reasons it declares. An entry whose reason is not among them is a revocation this
1019
+ * CRL says it does not carry, so a reader partitioning by reason would miss it. */
1020
+ appliesTo: function (crl) { return _crlReasonScope(crl) !== null; },
1021
+ message: "a revoked entry states a reason the CRL's issuingDistributionPoint does not declare",
1022
+ check: function (crl) {
1023
+ var scope = _crlReasonScope(crl), out = [];
1024
+ (crl.revokedCertificates || []).forEach(function (row, i) {
1025
+ (row.crlEntryExtensions || []).forEach(function (e) {
1026
+ if (e.oid !== oid.byName("reasonCode")) return;
1027
+ var bit = REASON_TO_FLAG_BIT[Number(e.value)];
1028
+ if (bit === undefined) return;
1029
+ if (!_reasonBitSet(scope, bit)) out.push({ context: { entry: i, reasonCode: Number(e.value) } });
1030
+ });
1031
+ });
1032
+ return out.length ? out : null;
1033
+ },
1034
+ },
1035
+ {
1036
+ id: "lint/rfc5280-crl/reason-code-unspecified", severity: "warn", source: "rfc5280-crl", citation: "RFC 5280 5.3.1",
1037
+ /** @internal Section 5.3.1 states this as a SHOULD, so it is a warning rather than an error:
1038
+ * the reason code extension should be absent instead of encoding unspecified(0), which says
1039
+ * nothing a reader can act on while costing an extension slot. Where the CRL is scoped to some
1040
+ * reason codes the row above answers instead, at the strength section 5.2.5 states. */
1041
+ message: "a reasonCode extension encoding unspecified(0) should be absent instead",
1042
+ appliesTo: function (crl) { return !_crlHasReasonScope(crl); },
1043
+ check: function (crl) {
1044
+ var out = [];
1045
+ (crl.revokedCertificates || []).forEach(function (row, i) {
1046
+ (row.crlEntryExtensions || []).forEach(function (e) {
1047
+ if (e.oid === oid.byName("reasonCode") && Number(e.value) === 0) out.push({ context: { entry: i } });
1048
+ });
1049
+ });
1050
+ return out.length ? out : null;
1051
+ },
1052
+ },
1053
+ {
1054
+ id: "lint/rfc5280-crl/entry-serial-not-positive", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 4.1.2.2",
1055
+ message: "a revoked entry's certificate serial number must be a positive integer",
1056
+ /** @internal userCertificate is a CertificateSerialNumber, so it carries the sec. 4.1.2.2
1057
+ * profile the certificate rows already apply to a certificate's own serial. */
1058
+ check: function (crl) {
1059
+ var out = [];
1060
+ (crl.revokedCertificates || []).forEach(function (row, i) {
1061
+ if (typeof row.serialNumber === "bigint" && row.serialNumber <= 0n) {
1062
+ out.push({ context: { entry: i, serialNumber: String(row.serialNumber) } });
1063
+ }
1064
+ });
1065
+ return out.length ? out : null;
1066
+ },
1067
+ },
1068
+ {
1069
+ id: "lint/rfc5280-crl/entry-serial-too-long", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 4.1.2.2",
1070
+ message: "a revoked entry's certificate serial number must not be longer than 20 octets",
1071
+ check: function (crl) {
1072
+ var out = [];
1073
+ (crl.revokedCertificates || []).forEach(function (row, i) {
1074
+ var octets = _integerOctets(row.serialNumber);
1075
+ if (octets !== null && octets > 20) out.push({ context: { entry: i, octets: octets } });
1076
+ });
1077
+ return out.length ? out : null;
1078
+ },
1079
+ },
1080
+ {
1081
+ id: "lint/rfc5280-crl/idp-profile", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.2.5",
1082
+ message: "an issuingDistributionPoint states a scope the profile does not permit",
1083
+ /** @internal A separate decision from whether the value DECODES, which the value-syntax row
1084
+ * answers. These are the constraints section 5.2.5 places on a well-formed one, read from the
1085
+ * same decoder certification-path validation uses. */
1086
+ appliesTo: function (crl) { return !!_crlExtByName(crl, "issuingDistributionPoint"); },
1087
+ check: function (crl) {
1088
+ var idp = pkix.decodeIssuingDistributionPointValue(NS, _crlExtByName(crl, "issuingDistributionPoint").value, _LINT_VALUE_CODE);
1089
+ if (idp.malformed) return null;
1090
+ var out = [];
1091
+ if (!idp.hasDistributionPoint && !idp.onlyUser && !idp.onlyCa && !idp.onlySomeReasonsPresent && !idp.indirect && !idp.onlyAttr) {
1092
+ out.push({ context: { reason: "states no scope at all" } });
1093
+ }
1094
+ if (idp.onlyUser && idp.onlyCa) out.push({ context: { reason: "sets both onlyContainsUserCerts and onlyContainsCACerts" } });
1095
+ if (idp.onlyAttr) out.push({ context: { reason: "sets onlyContainsAttributeCerts" } });
1096
+ if (idp.onlySomeReasonsPresent && !_scopeNamesAnyReason(idp.onlySomeReasonsValue)) {
1097
+ out.push({ context: { reason: "sets onlySomeReasons to a mask naming no reason code" } });
1098
+ }
1099
+ return out.length ? out : null;
1100
+ },
1101
+ },
1102
+ {
1103
+ id: "lint/rfc5280-crl/freshest-crl-forbidden-field", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.2.6",
1104
+ /** @internal Section 5.2.6 reuses the cRLDistributionPoints syntax but states that only the
1105
+ * distribution point field is meaningful on a CRL, and that reasons and cRLIssuer are omitted.
1106
+ * The shared decoder accepts both because the certificate extension permits them, so this row
1107
+ * carries the restriction the certificate profile does not have. */
1108
+ message: "a CRL's freshestCRL must omit the reasons and cRLIssuer fields",
1109
+ appliesTo: function (crl) { return !!_crlExtByName(crl, "freshestCRL"); },
1110
+ check: function (crl) {
1111
+ var dps = _crlDecodedExtension(crl, "freshestCRL");
1112
+ if (dps === null) return null;
1113
+ var out = [];
1114
+ dps.forEach(function (dp, i) {
1115
+ if (dp.reasons) out.push({ context: { index: i, field: "reasons" } });
1116
+ if (dp.cRLIssuer) out.push({ context: { index: i, field: "cRLIssuer" } });
1117
+ });
1118
+ return out.length ? out : null;
1119
+ },
1120
+ },
1121
+ {
1122
+ id: "lint/rfc5280-crl/aia-without-ca-issuers", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.2.7",
1123
+ /** @internal caIssuers is what this extension is for on a CRL: it names where to fetch the
1124
+ * certificates a reader verifies the CRL's signature with. Present without one, it gives a
1125
+ * reader nothing to fetch. */
1126
+ message: "a CRL's authorityInfoAccess must include an AccessDescription whose method is caIssuers",
1127
+ appliesTo: function (crl) { return !!_crlExtByName(crl, "authorityInfoAccess"); },
1128
+ check: function (crl) {
1129
+ var descs = _crlDecodedExtension(crl, "authorityInfoAccess");
1130
+ if (descs === null) return null;
1131
+ var found = descs.some(function (d) { return d.accessMethod === CA_ISSUERS_OID; });
1132
+ if (found) return null;
1133
+ return { context: { methods: descs.map(function (d) { return oid.name(d.accessMethod) || d.accessMethod; }) } };
1134
+ },
1135
+ },
1136
+ {
1137
+ id: "lint/rfc5280-crl/aia-forbidden-access-method", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.2.7",
1138
+ message: "a CRL's authorityInfoAccess must not carry an access method other than caIssuers",
1139
+ appliesTo: function (crl) { return !!_crlExtByName(crl, "authorityInfoAccess"); },
1140
+ check: function (crl) {
1141
+ var descs = _crlDecodedExtension(crl, "authorityInfoAccess");
1142
+ if (descs === null) return null;
1143
+ var out = [];
1144
+ descs.forEach(function (d, i) {
1145
+ if (d.accessMethod === CA_ISSUERS_OID) return;
1146
+ out.push({ context: { index: i, method: oid.name(d.accessMethod) || d.accessMethod } });
1147
+ });
1148
+ return out.length ? out : null;
1149
+ },
1150
+ },
1151
+ {
1152
+ id: "lint/rfc5280-crl/aia-location-not-http-or-ldap", severity: "warn", source: "rfc5280-crl", citation: "RFC 5280 5.2.7",
1153
+ /** @internal Section 5.2.7 states this one as a SHOULD, so it is a warning where the two rules
1154
+ * above are errors. */
1155
+ message: "at least one authorityInfoAccess location should be an HTTP or LDAP URI",
1156
+ appliesTo: function (crl) { return !!_crlExtByName(crl, "authorityInfoAccess"); },
1157
+ check: function (crl) {
1158
+ var descs = _crlDecodedExtension(crl, "authorityInfoAccess");
1159
+ if (descs === null || descs.length === 0) return null;
1160
+ var reachable = descs.some(function (d) {
1161
+ var loc = d.accessLocation;
1162
+ if (!loc || loc.tag !== 6) return false;
1163
+ return AIA_RECOMMENDED_SCHEMES.some(function (s) { return _uriSchemeIs(loc.value, s); });
1164
+ });
1165
+ return reachable ? null : true;
1166
+ },
1167
+ },
1168
+ {
1169
+ id: "lint/rfc5280-crl/distribution-point-name-relative", severity: "warn", source: "rfc5280-crl", citation: "RFC 5280 5.2.5 / 5.2.6 / 4.2.1.13",
1170
+ /** @internal Sections 5.2.5 and 5.2.6 both give their distribution point the syntax and
1171
+ * encoding conventions section 4.2.1.13 states, and there naming a distribution point relative
1172
+ * to the CRL issuer is a SHOULD NOT, so this is a warning rather than an error. One requirement
1173
+ * over two carriers, so one row reads both and the context names which it read. The MUST NOT
1174
+ * stated beside it is conditioned on cRLIssuer carrying more than one name; an
1175
+ * IssuingDistributionPoint has no cRLIssuer field, and section 5.2.6 requires freshestCRL to
1176
+ * omit its own, so that sentence cannot reach a CRL either way. */
1177
+ message: "a distribution point name should not be given relative to the CRL issuer",
1178
+ appliesTo: function (crl) {
1179
+ return !!_crlExtByName(crl, "issuingDistributionPoint") || !!_crlExtByName(crl, "freshestCRL");
1180
+ },
1181
+ check: function (crl) {
1182
+ var out = [];
1183
+ var e = _crlExtByName(crl, "issuingDistributionPoint");
1184
+ if (e && Buffer.isBuffer(e.value)) {
1185
+ var idp = pkix.decodeIssuingDistributionPointValue(NS, e.value, _LINT_VALUE_CODE);
1186
+ if (!idp.malformed && idp.distributionPoint && idp.distributionPoint.kind === "rdn") {
1187
+ out.push({ context: { extension: "issuingDistributionPoint", form: "nameRelativeToCRLIssuer" } });
1188
+ }
1189
+ }
1190
+ var dps = _crlDecodedExtension(crl, "freshestCRL");
1191
+ if (dps) {
1192
+ dps.forEach(function (dp, i) {
1193
+ if (!dp.distributionPoint || dp.distributionPoint.kind !== "rdn") return;
1194
+ out.push({ context: { extension: "freshestCRL", index: i, form: "nameRelativeToCRLIssuer" } });
1195
+ });
1196
+ }
1197
+ return out.length ? out : null;
1198
+ },
1199
+ },
1200
+ {
1201
+ id: "lint/rfc5280-crl/delta-number-not-advancing", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.2.4",
1202
+ message: "a delta CRL's cRLNumber must be greater than its baseCRLNumber",
1203
+ appliesTo: function (crl) { return !!_crlExtByName(crl, "deltaCRLIndicator"); },
1204
+ check: function (crl) {
1205
+ var num = _crlExtByName(crl, "cRLNumber");
1206
+ var base = _crlIntegerValue(_crlExtByName(crl, "deltaCRLIndicator").value);
1207
+ if (base === null || !num || typeof num.value !== "bigint") return null;
1208
+ return num.value > base ? null : { context: { crlNumber: String(num.value), baseCRLNumber: String(base) } };
1209
+ },
1210
+ },
1211
+ {
1212
+ id: "lint/rfc5280-crl/remove-from-crl-outside-delta", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.3.1",
1213
+ message: "the removeFromCRL reason code may appear only in a delta CRL",
1214
+ appliesTo: function (crl) { return !_crlExtByName(crl, "deltaCRLIndicator"); },
1215
+ check: function (crl) {
1216
+ var out = [];
1217
+ (crl.revokedCertificates || []).forEach(function (row, i) {
1218
+ (row.crlEntryExtensions || []).forEach(function (e) {
1219
+ if (e.oid === oid.byName("reasonCode") && Number(e.value) === 8) out.push({ context: { entry: i } });
1220
+ });
1221
+ });
1222
+ return out.length ? out : null;
1223
+ },
1224
+ },
1225
+ {
1226
+ id: "lint/rfc5280-crl/freshest-in-delta", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.2.6",
1227
+ message: "the freshestCRL extension must not appear in a delta CRL",
1228
+ appliesTo: function (crl) { return !!_crlExtByName(crl, "deltaCRLIndicator"); },
1229
+ check: function (crl) { return _crlExtByName(crl, "freshestCRL") ? true : null; },
1230
+ },
1231
+ {
1232
+ id: "lint/rfc5280-crl/extension-value-syntax", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.2 / 5.3",
1233
+ message: "a recognized extension's value does not read as the structure its identifier names",
1234
+ /** @internal Recognizing an extension by its identifier says only that the profile names it.
1235
+ * Without this, a critical extension the profile recognizes but whose value no consumer can
1236
+ * process reports clean: the criticality row is satisfied and the unknown-critical row does not
1237
+ * fire, because the identifier IS known. Both scopes are walked. */
1238
+ check: function (crl) {
1239
+ var out = [];
1240
+ _crlExtList(crl).forEach(function (e) {
1241
+ var why = _valueSyntaxFault(e, CRL_KNOWN_EXTENSIONS, CRL_VALUE_READER);
1242
+ if (why) out.push({ context: { extension: e.name || e.oid, scope: "crlExtensions", reason: why } });
1243
+ });
1244
+ (crl.revokedCertificates || []).forEach(function (row, i) {
1245
+ (row.crlEntryExtensions || []).forEach(function (e) {
1246
+ var why = _valueSyntaxFault(e, CRL_ENTRY_CRITICALITY, CRL_ENTRY_VALUE_READER);
1247
+ if (why) out.push({ context: { extension: e.name || e.oid, scope: "crlEntryExtensions", entry: i, reason: why } });
1248
+ });
1249
+ });
1250
+ return out.length ? out : null;
1251
+ },
1252
+ },
1253
+ {
1254
+ id: "lint/rfc5280-crl/unknown-critical-extension", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.2 / 5.3",
1255
+ message: "a critical extension is not recognized by the profile -- a conforming consumer must not use the CRL",
1256
+ /** @internal Sections 5.2 and 5.3 state the same obligation, once for the CRL's extensions and
1257
+ * once for a revoked entry's, so both lists are walked. The recognized set for each scope is
1258
+ * the extensions that section profiles, which is exactly the table its criticality rule keys
1259
+ * on. The certificate registry cannot answer here: it does not recognize deltaCRLIndicator or
1260
+ * issuingDistributionPoint, which section 5.2 REQUIRES to be critical, and it does recognize
1261
+ * certificate-only extensions that have no meaning on a CRL. */
1262
+ check: function (crl) {
1263
+ var out = [];
1264
+ _crlExtList(crl).forEach(function (e) {
1265
+ if (e.critical === true && CRL_KNOWN_EXTENSIONS[e.oid] === undefined) {
1266
+ out.push({ context: { oid: e.oid, name: e.name || null, scope: "crlExtensions" } });
1267
+ }
1268
+ });
1269
+ (crl.revokedCertificates || []).forEach(function (row, i) {
1270
+ (row.crlEntryExtensions || []).forEach(function (e) {
1271
+ if (e.critical === true && CRL_ENTRY_CRITICALITY[e.oid] === undefined) {
1272
+ out.push({ context: { oid: e.oid, name: e.name || null, scope: "crlEntryExtensions", entry: i } });
1273
+ }
1274
+ });
1275
+ });
1276
+ return out.length ? out : null;
1277
+ },
1278
+ },
1279
+ ];
1280
+
1281
+ var CRL_PROFILES = Object.assign(Object.create(null), { "rfc5280-crl": RFC5280_CRL_RULES });
1282
+ var ALL_CRL_RULES = RFC5280_CRL_RULES;
1283
+
562
1284
  var PROFILES = Object.assign(Object.create(null), {
563
1285
  "rfc5280": RFC5280_RULES,
564
1286
  "rfc9881": RFC9881_RULES,
@@ -570,7 +1292,10 @@ var ALL_RULES = RFC5280_RULES.concat(RFC9881_RULES).concat(RFC9909_RULES).concat
570
1292
 
571
1293
  function _selectRules(profile) {
572
1294
  if (profile == null || profile === "all" || profile === "default") return ALL_RULES;
573
- if (!PROFILES[profile]) throw _cfg("lint/unknown-profile", "unknown lint profile \"" + profile + "\" (known: " + Object.keys(PROFILES).join(", ") + ")");
1295
+ if (!PROFILES[profile]) {
1296
+ if (CRL_PROFILES[profile]) throw _cfg("lint/unknown-profile", "\"" + profile + "\" is a CRL profile; lint a CRL with pki.lint.crl");
1297
+ throw _cfg("lint/unknown-profile", "unknown lint profile \"" + profile + "\" (known: " + Object.keys(PROFILES).join(", ") + ")");
1298
+ }
574
1299
  return PROFILES[profile];
575
1300
  }
576
1301
 
@@ -636,6 +1361,107 @@ function certificate(input, opts) {
636
1361
  return report;
637
1362
  }
638
1363
 
1364
+ var _CRL_OPTS = Object.assign(Object.create(null), { profile: 1, severity: 1 });
1365
+
1366
+ /** @internal The same door the certificate path uses. A parsed CRL is admitted only when the shared
1367
+ * guard says it is one, so an object that merely carries a `crlExtensions` property is refused with
1368
+ * the typed config error rather than reaching a rule and faulting there. */
1369
+ function _derivedCrl(o) {
1370
+ if (!o || typeof o !== "object" || guard.bytes.isByteSource(o)) return null;
1371
+ // allow:swallow-unverified an object the guard refuses is not a parsed CRL, and the byte paths below answer for it
1372
+ try {
1373
+ var p = guard.parsed.acceptDerived(o, "crl", crlSchema.parse, _cfg, "lint/bad-input", "the CRL");
1374
+ return guard.parsed.isCrl(p) ? p : null;
1375
+ } catch (_e) {
1376
+ return null;
1377
+ }
1378
+ }
1379
+
1380
+ function _ingestCrl(input) {
1381
+ var derived = _derivedCrl(input);
1382
+ if (derived) return { crl: derived };
1383
+ var der;
1384
+ if (Buffer.isBuffer(input)) der = input;
1385
+ else if (typeof input === "string") {
1386
+ try { der = crlSchema.pemDecode(input); }
1387
+ catch (e) { return { fatal: { id: "lint/unparseable", severity: "fatal", source: "engine", citation: "pki.lint", message: "input is not a decodable PEM/DER CRL", context: { code: e.code } } }; }
1388
+ } else {
1389
+ throw _cfg("lint/bad-input", "pki.lint.crl input must be a parsed CRL, a DER Buffer, or a PEM string");
1390
+ }
1391
+ try { return { crl: crlSchema.parse(der) }; }
1392
+ catch (e) { return { fatal: { id: "lint/unparseable", severity: "fatal", source: "engine", citation: "RFC 5280", message: "input is not a well-formed X.509 CRL", context: { code: e.code } } }; }
1393
+ }
1394
+
1395
+ /**
1396
+ * @primitive pki.lint.crl
1397
+ * @signature pki.lint.crl(pem | der | parsed, opts?) -> LintReport
1398
+ * @since 0.7.9
1399
+ * @status stable
1400
+ * @spec RFC 5280 sec. 5
1401
+ * @related pki.lint.certificate, pki.schema.crl.parse, pki.crl.sign
1402
+ *
1403
+ * Lint a certificate revocation list against the RFC 5280 section 5 profile. `input` is a PEM
1404
+ * string, a DER `Buffer`, or an already-parsed `pki.schema.crl` object. It returns the same
1405
+ * `LintReport` shape `pki.lint.certificate` returns, and it never throws on the data path:
1406
+ * bytes that are not a well-formed CRL produce a single `fatal` finding `lint/unparseable`
1407
+ * carrying the strict parser's own code.
1408
+ *
1409
+ * The rows cover the CRL's update times (nextUpdate presence and ordering), its extensions
1410
+ * (cRLNumber presence and length, authorityKeyIdentifier presence and the key identifier method,
1411
+ * the criticality every profiled extension is required to carry, the scope an
1412
+ * issuingDistributionPoint states, freshestCRL in a delta CRL, and an unrecognized critical
1413
+ * extension), and each revoked entry (the serial profile, a reason code against the scope the CRL
1414
+ * declares, certificateIssuer, and entry-extension criticality).
1415
+ *
1416
+ * Six section 5 rules never appear here because the strict parser refuses them first, and a rule
1417
+ * for them could not fire. A CRL carrying extensions without the version that admits them
1418
+ * (section 5.1.2.1), a `signatureAlgorithm` differing from the `signature` field inside
1419
+ * `tbsCertList` (section 5.1.1.2), an empty issuer name (section 5.1.2.3), a present-but-empty
1420
+ * `revokedCertificates` (section 5.1.2.6), a date carried in the wrong time type, which is UTCTime
1421
+ * through 2049 and GeneralizedTime from 2050 (sections 5.1.2.4, 5.1.2.6 and 5.3.2), and an
1422
+ * extension repeated on the CRL or on an entry (section 4.2) each arrive as `lint/unparseable`
1423
+ * carrying the parser's own code.
1424
+ *
1425
+ * @opts profile `"rfc5280-crl"`, the only CRL profile (default runs every CRL rule). A
1426
+ * certificate profile name is refused rather than run against a CRL.
1427
+ * @opts severity Suppress findings below this floor (default `"notice"`). `counts` and `worst`
1428
+ * always reflect the complete, unfiltered result.
1429
+ * @example
1430
+ * var pair = await pki.key.generate("Ed25519");
1431
+ * var crlDer = await pki.crl.sign({ thisUpdate: new Date("2026-01-01T00:00:00Z"),
1432
+ * nextUpdate: new Date("2026-02-01T00:00:00Z"), crlNumber: 1n },
1433
+ * { name: "Example CA", publicKey: await pki.key.export(pair.publicKey), key: pair.privateKey });
1434
+ * var report = pki.lint.crl(crlDer);
1435
+ * report.worst; // -> "error", for the missing AKI
1436
+ * report.findings.map(function (f) { return f.id; }); // -> ["lint/rfc5280-crl/aki-missing"]
1437
+ */
1438
+ function crl(input, opts) {
1439
+ opts = guard.identifier.optionsObject(opts, _cfg, "lint/bad-input", "pki.lint options");
1440
+ guard.identifier.assertKnownKeys(opts, _CRL_OPTS, _cfg, "lint/bad-input",
1441
+ "pki.lint.crl has an unknown option: ");
1442
+ if (opts.severity != null && VALID_SEVERITY.indexOf(opts.severity) === -1) {
1443
+ throw _cfg("lint/bad-severity", "unknown severity threshold \"" + opts.severity + "\" (known: " + VALID_SEVERITY.join(", ") + ")");
1444
+ }
1445
+ var profile = opts.profile;
1446
+ var rules_;
1447
+ if (profile == null || profile === "all" || profile === "default") rules_ = ALL_CRL_RULES;
1448
+ else if (CRL_PROFILES[profile]) rules_ = CRL_PROFILES[profile];
1449
+ else if (PROFILES[profile]) throw _cfg("lint/unknown-profile", "\"" + profile + "\" is a certificate profile; lint a certificate with pki.lint.certificate");
1450
+ else throw _cfg("lint/unknown-profile", "unknown CRL lint profile \"" + profile + "\" (known: " + Object.keys(CRL_PROFILES).join(", ") + ")");
1451
+
1452
+ var ingested = _ingestCrl(input);
1453
+ var report;
1454
+ if (ingested.fatal) {
1455
+ report = { findings: [ingested.fatal], ran: [],
1456
+ counts: Object.assign(Object.create(null), { fatal: 1, error: 0, warn: 0, notice: 0, pass: 0, na: 0, ne: 0 }) };
1457
+ } else {
1458
+ report = _runLints(rules_, ingested.crl, { profile: profile, decode: function () { return null; }, raw: function () { return null; } });
1459
+ }
1460
+ report.worst = _worst(report.findings);
1461
+ report.findings = _applyThreshold(report, opts.severity);
1462
+ return report;
1463
+ }
1464
+
639
1465
  /**
640
1466
  * @primitive pki.lint.rules
641
1467
  * @signature pki.lint.rules(profile?) -> [{id, severity, source, citation}]
@@ -650,7 +1476,10 @@ function certificate(input, opts) {
650
1476
  * pki.lint.rules("rfc5280").length; // -> a positive count
651
1477
  */
652
1478
  function rules(profile) {
653
- var set = profile == null ? ALL_RULES : (PROFILES[profile] || (function () { throw _cfg("lint/unknown-profile", "unknown lint profile \"" + profile + "\""); })());
1479
+ var set = profile == null
1480
+ ? ALL_RULES.concat(ALL_CRL_RULES)
1481
+ : (PROFILES[profile] || CRL_PROFILES[profile]
1482
+ || (function () { throw _cfg("lint/unknown-profile", "unknown lint profile \"" + profile + "\""); })());
654
1483
  return set.map(function (r) { return { id: r.id, severity: r.severity, source: r.source, citation: r.citation }; });
655
1484
  }
656
1485
 
@@ -663,12 +1492,13 @@ function rules(profile) {
663
1492
  *
664
1493
  * List the known lint-profile names.
665
1494
  * @example
666
- * pki.lint.profiles(); // -> ["rfc5280", "rfc9881", "rfc9909", "rfc9935", "cabf-tls"]
1495
+ * pki.lint.profiles(); // -> ["rfc5280", "rfc9881", "rfc9909", "rfc9935", "cabf-tls", "rfc5280-crl"]
667
1496
  */
668
- function profiles() { return Object.keys(PROFILES); }
1497
+ function profiles() { return Object.keys(PROFILES).concat(Object.keys(CRL_PROFILES)); }
669
1498
 
670
1499
  module.exports = {
671
1500
  certificate: certificate,
1501
+ crl: crl,
672
1502
  rules: rules,
673
1503
  profiles: profiles,
674
1504
  };
@@ -1387,8 +1387,6 @@ var OID_AUTHORITY_KEY_ID = oid.byName("authorityKeyIdentifier");
1387
1387
  var OID_CRL_NUMBER = oid.byName("cRLNumber");
1388
1388
  var OID_FRESHEST_CRL = oid.byName("freshestCRL");
1389
1389
 
1390
- var IDP_SCHEMA = pkix.issuingDistributionPoint("path/bad-idp");
1391
-
1392
1390
  var ALL_REASONS = 0x1FE;
1393
1391
 
1394
1392
  function reasonMaskFromBitString(bs) {
@@ -1411,37 +1409,23 @@ function interimReasonMask(idpMask, dpMask) {
1411
1409
  return ALL_REASONS;
1412
1410
  }
1413
1411
 
1412
+ /** @internal The structural read is shared with certificate-revocation-list linting through
1413
+ * schema-pkix, so the two cannot disagree about what a well-formed IssuingDistributionPoint is.
1414
+ * Only the reason-flags MASK is added here, because only path validation compares against it. */
1414
1415
  function decodeIdp(ext) {
1415
- var out = { hasDistributionPoint: false, distributionPoint: null, onlyUser: false, onlyCa: false, onlySomeReasons: null, indirect: false, onlyAttr: false, malformed: false };
1416
- var m;
1417
- try {
1418
- m = schema.walk(IDP_SCHEMA, asn1.decode(ext.value), NS);
1419
- if (m.fields.distributionPoint.present) {
1420
- var dpnWrap = m.fields.distributionPoint.node;
1421
- if (!dpnWrap.children || dpnWrap.children.length !== 1) {
1422
- throw E("path/bad-idp", "IssuingDistributionPoint distributionPoint [0] must wrap exactly one DistributionPointName");
1423
- }
1424
- out.distributionPoint = pkix.distributionPointName(NS, dpnWrap.children[0], "path/bad-idp");
1425
- }
1426
- }
1427
- catch (_e) { out.malformed = true; return out; }
1428
- function flag(f) {
1429
- if (!f.present) return false;
1430
- var isSet = f.value === true;
1431
- if (!isSet) out.malformed = true;
1432
- return isSet;
1433
- }
1434
- out.hasDistributionPoint = m.fields.distributionPoint.present;
1435
- out.onlyUser = flag(m.fields.onlyContainsUserCerts);
1436
- out.onlyCa = flag(m.fields.onlyContainsCACerts);
1437
- out.onlySomeReasons = m.fields.onlySomeReasons.present ? true : null;
1438
- out.onlySomeReasonsMask = null;
1439
- if (m.fields.onlySomeReasons.present) {
1440
- out.onlySomeReasonsMask = reasonMaskFromBitString(m.fields.onlySomeReasons.value);
1416
+ var s = pkix.decodeIssuingDistributionPointValue(NS, ext.value, "path/bad-idp");
1417
+ var out = {
1418
+ hasDistributionPoint: s.hasDistributionPoint, distributionPoint: s.distributionPoint,
1419
+ onlyUser: s.onlyUser, onlyCa: s.onlyCa,
1420
+ onlySomeReasons: s.onlySomeReasonsPresent ? true : null,
1421
+ indirect: s.indirect, onlyAttr: s.onlyAttr, malformed: s.malformed,
1422
+ onlySomeReasonsMask: null,
1423
+ };
1424
+ if (out.malformed) return out;
1425
+ if (s.onlySomeReasonsPresent) {
1426
+ out.onlySomeReasonsMask = reasonMaskFromBitString(s.onlySomeReasonsValue);
1441
1427
  if (out.onlySomeReasonsMask === null) out.malformed = true;
1442
1428
  }
1443
- out.indirect = flag(m.fields.indirectCRL);
1444
- out.onlyAttr = flag(m.fields.onlyContainsAttributeCerts);
1445
1429
  return out;
1446
1430
  }
1447
1431
 
@@ -643,6 +643,58 @@ function issuingDistributionPoint(code) {
643
643
  ], { assert: "sequence", code: code, what: "IssuingDistributionPoint" });
644
644
  }
645
645
 
646
+ /** @internal The STRUCTURAL read of an IssuingDistributionPoint value, shared so that certification
647
+ * path validation and certificate-revocation-list linting cannot disagree about what a well-formed
648
+ * one is. It lives here rather than being exported from a consumer, because this module is not on
649
+ * the public surface and a decoder taking one module's internal shape should not become API.
650
+ * Reports a fault as `malformed` rather than throwing, since both callers survey hostile input.
651
+ * The reason-flags MASK is deliberately absent: it is a path-validation concern, and that caller
652
+ * composes it on top of this. */
653
+ function decodeIssuingDistributionPointValue(ns, bytes, code) {
654
+ var out = { malformed: false, hasDistributionPoint: false, distributionPoint: null,
655
+ onlyUser: false, onlyCa: false, onlySomeReasonsPresent: false, onlySomeReasonsValue: null,
656
+ indirect: false, onlyAttr: false, fields: null };
657
+ var m;
658
+ try {
659
+ m = schema.walk(issuingDistributionPoint(code), asn1.decode(bytes), ns);
660
+ if (m.fields.distributionPoint.present) {
661
+ var wrap = m.fields.distributionPoint.node;
662
+ if (!wrap.children || wrap.children.length !== 1) {
663
+ throw ns.E(code, "IssuingDistributionPoint distributionPoint [0] must wrap exactly one DistributionPointName");
664
+ }
665
+ out.distributionPoint = distributionPointName(ns, wrap.children[0], code);
666
+ }
667
+ } catch (_e) {
668
+ out.malformed = true;
669
+ return out;
670
+ }
671
+ function flag(f) {
672
+ if (!f.present) return false;
673
+ if (f.value !== true) { out.malformed = true; return false; }
674
+ return true;
675
+ }
676
+ out.fields = m.fields;
677
+ out.hasDistributionPoint = m.fields.distributionPoint.present;
678
+ out.onlyUser = flag(m.fields.onlyContainsUserCerts);
679
+ out.onlyCa = flag(m.fields.onlyContainsCACerts);
680
+ out.onlySomeReasonsPresent = m.fields.onlySomeReasons.present;
681
+ out.onlySomeReasonsValue = m.fields.onlySomeReasons.present ? m.fields.onlySomeReasons.value : null;
682
+ /** @internal ReasonFlags is a NamedBitList, so DER requires the trailing zero bits be dropped.
683
+ * That is an encoding rule rather than a path-validation policy, so it belongs to the structural
684
+ * read both callers share: a value keeping its trailing zeros is malformed for either of them. */
685
+ if (out.onlySomeReasonsPresent) {
686
+ var bs = out.onlySomeReasonsValue;
687
+ if (!bs || !bs.bytes) out.malformed = true;
688
+ else {
689
+ try { schema.assertMinimalNamedBits(bs.unusedBits, bs.bytes, function (msg) { throw ns.E(code, msg); }); }
690
+ catch (_e) { out.malformed = true; } // allow:swallow-unverified the refusal IS the malformed flag this sets
691
+ }
692
+ }
693
+ out.indirect = flag(m.fields.indirectCRL);
694
+ out.onlyAttr = flag(m.fields.onlyContainsAttributeCerts);
695
+ return out;
696
+ }
697
+
646
698
  var _T = asn1.TAGS;
647
699
 
648
700
  function assertPolicyQualifiers(qNode, fail) {
@@ -1337,6 +1389,7 @@ module.exports = {
1337
1389
  generalNames: generalNames,
1338
1390
  distributionPointName: distributionPointName,
1339
1391
  issuingDistributionPoint: issuingDistributionPoint,
1392
+ decodeIssuingDistributionPointValue: decodeIssuingDistributionPointValue,
1340
1393
  keyUsageOf: keyUsageOf,
1341
1394
  extKeyUsageOf: extKeyUsageOf,
1342
1395
  assertsPurpose: assertsPurpose,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.7.8",
3
+ "version": "0.7.9",
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:eb6258f5-7cf4-4371-80b7-ed98047003ac",
5
+ "serialNumber": "urn:uuid:9032cd80-6a2c-49e5-8c6c-c9137a8ead5b",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-09-10T13:51:21.922Z",
8
+ "timestamp": "2026-09-10T20:15:24.465Z",
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.7.8",
22
+ "bom-ref": "@blamejs/pki@0.7.9",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.7.8",
25
+ "version": "0.7.9",
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.7.8",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.7.9",
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.7.8",
57
+ "ref": "@blamejs/pki@0.7.9",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]