@blamejs/pki 0.7.25 → 0.7.27

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,36 @@ 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.27 — 2026-09-12
8
+
9
+ pki lint detects a certificate, a CRL or an OCSP response and runs the matching profile.
10
+
11
+ ### Added
12
+
13
+ - pki lint <file> detects whether the file holds a certificate, a CRL or an OCSP response, as DER or PEM, and lints it with pki.lint.certificate, pki.lint.crl or pki.lint.ocsp. --profile, --severity and --json apply to whichever verb runs, and a profile that belongs to another structure is refused with the message naming the verb that takes it. For a file holding a structure no profile covers (a certificate request, a CMS message, a key), the command reports the name the detector gives it and exits instead of linting it as a certificate.
14
+ - lint/rfc5280/extension-criticality (error) reports a certificate extension marked critical that RFC 5280 requires a conforming CA to mark non-critical: authorityKeyIdentifier (section 4.2.1.1), subjectKeyIdentifier (4.2.1.2), subjectDirectoryAttributes (4.2.1.8), freshestCRL (4.2.1.15), authorityInfoAccess (4.2.2.1) and subjectInfoAccess (4.2.2.2). Each finding names the extension and its clause.
15
+
16
+ ### Changed
17
+
18
+ - A certificate marking one of those six extensions critical was reported as lint/rfc5280/unknown-critical-extension, which told an operator the extension was not recognized when the defect was its criticality. The profile recognizes them, so that finding no longer fires for them; the new row does, at the same severity, so a report filtered at error is unchanged in its verdict and changed in its name.
19
+ - The table of extensions whose criticality RFC 5280 and RFC 6962 fix now lives once, in the shared PKIX schema module, and both pki.x509.sign (which refuses to write against it) and pki.lint.certificate (which reports against it) read it from there.
20
+
21
+ ## v0.7.26 — 2026-09-12
22
+
23
+ An OCSP response can be linted against RFC 6960, and against the RFC 5019 lightweight profile on request.
24
+
25
+ ### Added
26
+
27
+ - pki.lint.ocsp(pem | der | parsed, opts) lints an OCSP response under the rfc6960 profile: a SingleResponse whose thisUpdate follows its nextUpdate, a producedAt earlier than a thisUpdate it certifies, a certs field that is present and empty, the extended revoked definition (its criticality, its NULL value, and that it belongs in responseExtensions), the fixed shape RFC 6960 section 2.2 gives a revoked answer for a non-issued certificate (certificateHold at January 1, 1970) and what that answer must and must not carry, the list each extension belongs in (the nonce, the archive cutoff, the CRL references, and the three extensions defined for requests only), an archive cutoff later than producedAt, the criticality and value syntax of the CRL entry extensions section 4.4.5 lets a SingleResponse carry, an unrecognized critical extension on either list, the revocation reasons unspecified and removeFromCRL, and two SingleResponses answering for one CertID. Each finding names the SingleResponse it is about.
28
+ - The rfc5019 profile holds a response to the lightweight rules (nextUpdate always present, one SingleResponse, no responseExtensions, a byKey ResponderID). Nothing in a response says which profile its responder follows, so these rows run when profile: "rfc5019" is selected and count as not applicable in the default run.
29
+ - pki.schema.ocsp.parseResponse reports certsPresent beside certs, so a reader can tell a response that carried an empty certs field from one that omitted it.
30
+ - A lint-ocsp fuzz target drives the never-throw promise with hostile bytes on the ingestion boundary, spliced into a real response, and as the value of every extension the profile decodes.
31
+ - All three lint verbs report a signature field that carries no signature (lint/rfc5280/signature-empty, lint/rfc5280-crl/signature-empty, lint/rfc6960/signature-empty). An empty BIT STRING is well-formed DER, so the strict parsers admit it and the profiles grade it.
32
+
33
+ ### Changed
34
+
35
+ - The three lint verbs refuse each other's profile names with a message naming the verb that takes the profile, and pki.lint.profiles() and pki.lint.rules() enumerate the two OCSP profiles beside the certificate and CRL ones.
36
+
7
37
  ## v0.7.25 — 2026-09-12
8
38
 
9
39
  An OCSP response can say how far back its status reaches and which CRL it came from.
package/README.md CHANGED
@@ -252,10 +252,10 @@ 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`, `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` |
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, the six extensions a conforming CA must mark non-critical (reported by clause rather than as unrecognized), 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), `ocsp` (the RFC 6960 response profile: an empty signature, each SingleResponse's update ordering and its relation to producedAt, an empty `certs` field, the extended revoked definition and the fixed shape of a revoked answer for a non-issued certificate, the list each extension belongs in, the criticality and value syntax of the CRL entry extensions a SingleResponse may carry, unrecognized critical extensions, the reasons unspecified and removeFromCRL, and two answers for one CertID; the RFC 5019 lightweight rows run on request), `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
- | `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>` |
258
+ | `pki` CLI | `pki version`, `pki oid <dotted\|name>`, `pki parse <cert>`, `pki inspect <cert>`, `pki lint <cert\|crl\|ocsp-response>`, `pki convert <file> --to der\|pem`, `pki verify <cert>... --anchor <cert>`, `pki sign <file> --cert <c> --key <k>` |
259
259
 
260
260
  ### CLI
261
261
 
@@ -267,6 +267,8 @@ pki parse cert.pem # structured JSON summary of a certific
267
267
  pki inspect cert.pem # openssl x509 -text style report (pki.inspect)
268
268
  pki lint cert.pem # graded conformance findings; exit 1 on an error
269
269
  pki lint cert.pem --json --profile cabf-tls
270
+ pki lint list.crl # detects a CRL or an OCSP response and runs its profile
271
+ pki lint status.ors --profile rfc5019 # the RFC 5019 lightweight rows for an OCSP response
270
272
  pki convert cert.pem --to der > cert.der # transcode between PEM and DER (round-trips)
271
273
  pki verify leaf.pem --anchor root.pem --time 2026-01-01T00:00:00Z # RFC 5280 path validation
272
274
  pki sign msg.txt --cert signer.pem --key signer-key.pem --out msg.p7s # CMS SignedData (pki.cms.sign)
package/bin/pki.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * pki oid <dotted|name> resolve an OID <-> name
10
10
  * pki parse <cert> parse an X.509 certificate to JSON
11
11
  * pki inspect <cert> render a certificate as text (openssl x509 -text style)
12
- * pki lint <cert> [--profile P] lint a certificate; exit non-zero on an error finding
12
+ * pki lint <file> [--profile P] lint a certificate, CRL or OCSP response; exit non-zero on an error finding
13
13
  * [--severity S] [--json]
14
14
  * pki convert <file> --to der|pem transcode a DER/PEM file between the two encodings
15
15
  * [--label LABEL]
@@ -135,15 +135,31 @@ function cmdInspect(file) {
135
135
 
136
136
  function pad(s, n) { while (s.length < n) s += " "; return s; }
137
137
 
138
- // pki lint <cert> -- lint against pki.lint's profiles. Prints one line per finding and
139
- // exits non-zero when any error/fatal finding is present (0 when the worst is advisory).
138
+ // The lint verb for each structure `pki lint` detects. A file holding anything else (a request, a
139
+ // CMS message, a key) has no lint profile and is refused by the name the detector gives it.
140
+ var LINT_VERB_FOR = { "x509": "certificate", "crl": "crl", "ocsp-response": "ocsp" };
141
+
142
+ // pki lint <file> -- detect whether the file holds a certificate, a CRL or an OCSP response and
143
+ // lint it against that structure's profiles. Prints one line per finding and exits non-zero
144
+ // when any error/fatal finding is present (0 when the worst is advisory).
140
145
  function cmdLint(args) {
141
146
  var file = args._[0];
142
- if (!file) fail("usage: pki lint <cert> [--profile <name>] [--severity <floor>] [--json]");
147
+ if (!file) fail("usage: pki lint <cert|crl|ocsp-response> [--profile <name>] [--severity <floor>] [--json]");
148
+ var input = readForLib(file);
149
+ // Bytes no detector recognizes (garbage, or a structure outside the detector's set) fall
150
+ // through to the certificate verb, whose never-throw data path reports them as a fatal
151
+ // lint/unparseable finding; a structure the detector DOES name but no profile covers is a
152
+ // config-time refusal naming it, so a request is not silently linted as a certificate.
153
+ var verb = "certificate", format;
154
+ try { format = pki.schema.detectFormat(input); } catch (_e) { format = null; }
155
+ if (format !== null) {
156
+ verb = LINT_VERB_FOR[format];
157
+ if (verb === undefined) return fail(file + ": holds a " + format + " structure, and pki lint covers a certificate, CRL, or OCSP response");
158
+ }
143
159
  var report;
144
160
  // Config-time misuse (unknown profile / bad severity) throws a typed LintError; the data
145
161
  // path never throws (malformed bytes become a fatal lint/unparseable finding).
146
- try { report = pki.lint.certificate(readForLib(file), { profile: args.profile, severity: args.severity }); }
162
+ try { report = pki.lint[verb](input, { profile: args.profile, severity: args.severity }); }
147
163
  catch (e) { return fail(e.code + ": " + e.message); }
148
164
  if (args.json) {
149
165
  process.stdout.write(JSON.stringify(report, null, 2) + "\n");
package/lib/lint.js CHANGED
@@ -5,15 +5,16 @@
5
5
  * @module pki.lint
6
6
  * @nav Tooling
7
7
  * @title Lint
8
- * @fullname Certificate and revocation-list linting against RFC 5280 and CA/Browser Forum
9
- * requirements
8
+ * @fullname Certificate, revocation-list and OCSP-response linting against RFC 5280, RFC 6960
9
+ * and CA/Browser Forum requirements
10
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
11
+ * certificate, certificate revocation list or OCSP response (from `pki.schema.x509.parse`,
12
+ * `pki.schema.crl.parse` or `pki.schema.ocsp.parseResponse`, whose extension values it
13
+ * decodes with the shared RFC 5280 decoders) and emits graded, advisory FINDINGS: each with
14
+ * a stable id, a severity (`fatal` > `error` > `warn` > `notice` > `pass`), a source, a
15
+ * spec-clause citation, and a human message. It ships the RFC 5280 certificate profile, the
16
+ * RFC 5280 section 5 revocation-list profile, the RFC 6960 OCSP response profile with the
17
+ * RFC 5019 lightweight rows, the post-quantum certificate profiles for ML-KEM, ML-DSA and
17
18
  * SLH-DSA, and a representative CA/Browser Forum TLS Baseline Requirements subset.
18
19
  *
19
20
  * Unlike every other toolkit entry, the lint data path never throws. A linter surveys a
@@ -23,8 +24,8 @@
23
24
  * unknown profile, an out-of-range severity threshold, or a wrong-type input), which
24
25
  * raises a typed `LintError`. This deliberate inversion of the toolkit's fail-closed-throw
25
26
  * posture is what lets an operator lint a whole directory without a try/catch per file.
26
- * @spec RFC 5280, RFC 9881, RFC 9909, RFC 9935, CA/Browser Forum TLS Baseline Requirements
27
- * @card Lint a certificate or CRL against RFC 5280 + CABF TLS BR, in pure JS.
27
+ * @spec RFC 5280, RFC 6960, RFC 5019, RFC 9654, RFC 9881, RFC 9909, RFC 9935, CA/Browser Forum TLS Baseline Requirements
28
+ * @card Lint a certificate, CRL or OCSP response against RFC 5280, RFC 6960 + CABF TLS BR, in pure JS.
28
29
  */
29
30
 
30
31
  var frameworkError = require("./framework-error");
@@ -33,6 +34,7 @@ var guard = require("./guard-all");
33
34
  var oid = require("./oid");
34
35
  var x509 = require("./schema-x509");
35
36
  var crlSchema = require("./schema-crl");
37
+ var ocspSchema = require("./schema-ocsp");
36
38
  var pkix = require("./schema-pkix");
37
39
  var schema = require("./schema-engine");
38
40
  var path = require("./path-validate");
@@ -193,9 +195,18 @@ function _subjectCNs(cert) {
193
195
  return out;
194
196
  }
195
197
 
198
+ /** @internal The criticality RFC 5280 fixes for a certificate extension, read from the shared table
199
+ * the certificate signer refuses to write against. The profile has its own row for each extension
200
+ * the table holds CRITICAL (basicConstraints, nameConstraints, policyConstraints, inhibitAnyPolicy),
201
+ * so only the NON-critical direction is graded through this table, under one id, the way the CRL
202
+ * profile grades its criticality rows. */
203
+ var CERT_FIXED_CRITICALITY = pkix.certFixedCriticality(oid);
196
204
  var _CRITICAL_LEGIT = Object.create(null);
197
205
  _CRITICAL_LEGIT[oid.byName("precertificatePoison")] = true;
206
+ /** @internal An extension whose criticality the profile fixes is one it recognizes: marked against
207
+ * the table it is a criticality fault with its own row, never an unrecognized extension. */
198
208
  function _isUnknownExtension(extOid) {
209
+ if (CERT_FIXED_CRITICALITY[extOid] !== undefined) return false;
199
210
  if (path.PROCESSED_EXTENSIONS[extOid] !== true) return _CRITICAL_LEGIT[extOid] !== true;
200
211
  return path.TARGET_UNPROCESSED_IF_CRITICAL[extOid] === true;
201
212
  }
@@ -261,6 +272,29 @@ function _criticalityRule(name, id, severity, citation, message) {
261
272
  }
262
273
 
263
274
  var RFC5280_RULES = [
275
+ {
276
+ id: "lint/rfc5280/signature-empty", severity: "error", source: "rfc5280", citation: "RFC 5280 4.1.1.3",
277
+ /** @internal The strict parser admits an empty BIT STRING, since one is well-formed DER; a
278
+ * signatureValue that contains no signature is not the field section 4.1.1.3 defines. */
279
+ message: "the signatureValue contains no signature",
280
+ check: function (cert) { return cert.signatureValue.bytes.length === 0 ? true : null; },
281
+ },
282
+ {
283
+ id: "lint/rfc5280/extension-criticality", severity: "error", source: "rfc5280", citation: "RFC 5280 4.2.1.1 / 4.2.1.2 / 4.2.1.8 / 4.2.1.15 / 4.2.2.1 / 4.2.2.2",
284
+ message: "an extension a conforming CA must mark non-critical is marked critical",
285
+ /** @internal Keyed by the extension's OID throughout. The display name the parser attaches can
286
+ * be re-pointed through the registry, so a lookup by name would answer for a different
287
+ * extension, or for none. */
288
+ check: function (cert) {
289
+ var rows = [];
290
+ (cert.extensions || []).forEach(function (e) {
291
+ var fixed = CERT_FIXED_CRITICALITY[e.oid];
292
+ if (fixed === undefined || fixed.critical !== false || e.critical !== true) return;
293
+ rows.push({ context: { extension: e.name || e.oid, oid: e.oid, required: "non-critical", was: "critical", scope: "extensions", citation: fixed.citation } });
294
+ });
295
+ return rows.length ? rows : null;
296
+ },
297
+ },
264
298
  {
265
299
  id: "lint/rfc5280/serial-not-positive", severity: "error", source: "rfc5280", citation: "RFC 5280 4.1.2.2",
266
300
  message: "the certificate serialNumber must be a positive integer",
@@ -832,6 +866,11 @@ function _criticalityRows(list, table, what) {
832
866
  * and a present-but-empty revokedCertificates (sec. 5.1.2.6, `crl/bad-revoked-certificates`). Each
833
867
  * reaches a caller as the engine's fatal lint/unparseable carrying that code. */
834
868
  var RFC5280_CRL_RULES = [
869
+ {
870
+ id: "lint/rfc5280-crl/signature-empty", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.1.1.3",
871
+ message: "the signatureValue contains no signature",
872
+ check: function (crl) { return crl.signatureValue.bytes.length === 0 ? true : null; },
873
+ },
835
874
  {
836
875
  id: "lint/rfc5280-crl/next-update-missing", severity: "error", source: "rfc5280-crl", citation: "RFC 5280 5.1.2.5",
837
876
  message: "a conforming CRL issuer includes the nextUpdate field in every CRL",
@@ -1281,6 +1320,398 @@ var RFC5280_CRL_RULES = [
1281
1320
  var CRL_PROFILES = Object.assign(Object.create(null), { "rfc5280-crl": RFC5280_CRL_RULES });
1282
1321
  var ALL_CRL_RULES = RFC5280_CRL_RULES;
1283
1322
 
1323
+
1324
+ /** @internal The extensions RFC 6960 and RFC 9654 place on a response, by the list each belongs
1325
+ * in. The nonce and the extended revoked definition are response extensions; the archive cutoff and
1326
+ * the CRL references are single extensions, and sec. 4.4.5 carries every RFC 5280 sec. 5.3 CRL
1327
+ * entry extension into a SingleResponse as well. Three more are defined for REQUESTS only. */
1328
+ var OCSP_RESPONSE_KNOWN = Object.create(null);
1329
+ ["ocspNonce", "ocspExtendedRevoke"].forEach(function (n) { OCSP_RESPONSE_KNOWN[oid.byName(n)] = true; });
1330
+ var OCSP_SINGLE_KNOWN = Object.create(null);
1331
+ ["ocspArchiveCutoff", "ocspCrl", "reasonCode", "invalidityDate", "certificateIssuer"]
1332
+ .forEach(function (n) { OCSP_SINGLE_KNOWN[oid.byName(n)] = true; });
1333
+ var OCSP_REQUEST_ONLY = Object.create(null);
1334
+ [["ocspResponse", "RFC 6960 4.4.3"], ["ocspServiceLocator", "RFC 6960 4.4.6"], ["ocspPrefSigAlgs", "RFC 6960 4.4.7"]]
1335
+ .forEach(function (r) { OCSP_REQUEST_ONLY[oid.byName(r[0])] = r[1]; });
1336
+ /** @internal Only the criticality a clause states. Sec. 4.4.8 forbids a critical extended revoked
1337
+ * definition; sec. 4.4.5 carries the RFC 5280 sec. 5.3 rules for the three entry extensions. RFC
1338
+ * 6960 states nothing about the criticality of the nonce, the archive cutoff or the CRL references,
1339
+ * so they are absent here. */
1340
+ var OCSP_RESPONSE_CRITICALITY = Object.create(null);
1341
+ OCSP_RESPONSE_CRITICALITY[oid.byName("ocspExtendedRevoke")] = false;
1342
+ var OCSP_SINGLE_CRITICALITY = CRL_ENTRY_CRITICALITY;
1343
+
1344
+ var OID_OCSP_NONCE = oid.byName("ocspNonce");
1345
+ var OID_OCSP_EXTENDED_REVOKE = oid.byName("ocspExtendedRevoke");
1346
+ var OID_OCSP_ARCHIVE_CUTOFF = oid.byName("ocspArchiveCutoff");
1347
+ var OID_OCSP_CRL = oid.byName("ocspCrl");
1348
+ var OID_REASON_CODE = oid.byName("reasonCode");
1349
+
1350
+ /** @internal How each single extension the OCSP parser leaves opaque is read: reasonCode and
1351
+ * invalidityDate through the CRL parser's own entry-extension reader, so the linter and the CRL
1352
+ * parser cannot disagree about what a well-formed one is, and certificateIssuer through the shared
1353
+ * GeneralNames walk the CRL rows use. The parser decodes the nonce, the archive cutoff and the CRL
1354
+ * references itself and refuses a malformed one, so an entry for any of those would be dead. */
1355
+ var OCSP_SINGLE_VALUE_READER = Object.create(null);
1356
+ OCSP_SINGLE_VALUE_READER[OID_REASON_CODE] = function (bytes) {
1357
+ crlSchema.decodeExtension({ oid: OID_REASON_CODE, name: "reasonCode", critical: false, value: bytes });
1358
+ };
1359
+ OCSP_SINGLE_VALUE_READER[oid.byName("invalidityDate")] = function (bytes) {
1360
+ crlSchema.decodeExtension({ oid: oid.byName("invalidityDate"), name: "invalidityDate", critical: false, value: bytes });
1361
+ };
1362
+ OCSP_SINGLE_VALUE_READER[oid.byName("certificateIssuer")] = CRL_ENTRY_VALUE_READER[oid.byName("certificateIssuer")];
1363
+ var OCSP_RESPONSE_VALUE_READER = Object.create(null);
1364
+ /** @internal Read through the codec's NULL reader, which refuses content octets as well as a wrong
1365
+ * tag; the decoder alone frames `05 01 00` as a NULL carrying one octet. */
1366
+ OCSP_RESPONSE_VALUE_READER[OID_OCSP_EXTENDED_REVOKE] = function (bytes) {
1367
+ asn1.read.nullValue(asn1.decode(bytes));
1368
+ };
1369
+
1370
+ function _ocspBasic(resp) { return resp.basicResponse; }
1371
+ /** @internal The parser refuses an empty `responses`, so the list is never empty here. */
1372
+ function _ocspResponses(resp) { return _ocspBasic(resp).responses; }
1373
+ function _ocspResponseExts(resp) { return _ocspBasic(resp).responseExtensions || []; }
1374
+ function _ocspSingleExts(sr) { return sr.singleExtensions || []; }
1375
+ function _extIn(list, dotted) {
1376
+ for (var i = 0; i < list.length; i++) { if (list[i].oid === dotted) return list[i]; }
1377
+ return null;
1378
+ }
1379
+ /** @internal The revocation reason a single extension carries, read with the CRL parser's reader,
1380
+ * or null when the extension is absent or does not read (the value-syntax row reports that). */
1381
+ function _singleReasonCode(sr) {
1382
+ var e = _extIn(_ocspSingleExts(sr), OID_REASON_CODE);
1383
+ if (!e || !Buffer.isBuffer(e.value)) return null;
1384
+ // allow:swallow-unverified a value the CRL reader refuses carries no reason; the value-syntax row reports it
1385
+ try { return crlSchema.decodeExtension(e).value; }
1386
+ catch (_e) { return null; }
1387
+ }
1388
+ /** @internal RFC 6960 sec. 2.2 gives a "revoked" answer for a non-issued certificate a fixed shape:
1389
+ * the reason certificateHold and the revocation time January 1, 1970. That shape is how a response
1390
+ * says it is answering for a certificate that was never issued, and the rows that hold such an
1391
+ * answer to the rest of sec. 2.2 recognize it by exactly those two values. */
1392
+ function _isNonIssuedShape(sr) {
1393
+ var st = sr.certStatus;
1394
+ return !!st && st.type === "revoked" && st.revocationReason === "certificateHold" &&
1395
+ guard.time.isDate(st.revocationTime) && guard.time.instantOf(st.revocationTime) === 0;
1396
+ }
1397
+ /** @internal The rows in one list that carry a per-response index, so a finding names which
1398
+ * SingleResponse it is about the way the CRL entry rows name their entry. */
1399
+ function _perResponse(resp, fn) {
1400
+ var rows = [];
1401
+ _ocspResponses(resp).forEach(function (sr, i) {
1402
+ var out = fn(sr, i);
1403
+ if (out == null) return;
1404
+ (Array.isArray(out) ? out : [out]).forEach(function (row) { row.context.response = i; rows.push(row); });
1405
+ });
1406
+ return rows.length ? rows : null;
1407
+ }
1408
+ /** @internal A CertID as one lookup key, every field included, so two SingleResponses name the same
1409
+ * certificate under the same hash exactly when their keys match. Keyed rather than compared
1410
+ * pairwise: a response lists as many SingleResponses as its author chooses, and this verb exists to
1411
+ * survey ones that arrived from elsewhere, so a pairwise scan would be quadratic in that choice. */
1412
+ function _certIdKey(c) {
1413
+ return c.hashAlgorithm.algorithm + "|" + c.issuerNameHash.toString("hex") + "|" + c.issuerKeyHash.toString("hex") + "|" + c.serialNumberHex;
1414
+ }
1415
+ function _statusKey(st) {
1416
+ return st.type + (st.type === "revoked" ? ":" + guard.time.instantOf(st.revocationTime) + ":" + (st.revocationReason || "") : "");
1417
+ }
1418
+ /** @internal The placement rows share one shape: an extension found in the list the specification
1419
+ * does not define it for. `where` names the list it was found in; `context.placed` says where the
1420
+ * specification puts it. */
1421
+ function _placementRow(name, placed, where) {
1422
+ return { context: { extension: name, placed: placed, was: where } };
1423
+ }
1424
+
1425
+ /** @internal Four sec. 4.2 rules have no row because the strict parser refuses them first and a rule
1426
+ * for them would be dead: a version other than v1 (sec. 4.2.2.3, `ocsp/bad-version`), an undefined
1427
+ * CRLReason value (`ocsp/bad-revocation-reason`), an empty extension list (`ocsp/bad-extensions`)
1428
+ * and a repeated extension (`ocsp/duplicate-extension`); so are an empty `responses`
1429
+ * (`ocsp/bad-responses`), a nonce outside 1..128 octets (`ocsp/bad-nonce`), a malformed archive
1430
+ * cutoff or CrlID, a responseStatus that disagrees with responseBytes, and every time-format rule of
1431
+ * RFC 5019 sec. 2.2.4, which the DER codec enforces. Each reaches a caller as the engine's fatal
1432
+ * lint/unparseable carrying that code. */
1433
+ var RFC6960_OCSP_RULES = [
1434
+ {
1435
+ id: "lint/rfc6960/signature-empty", severity: "error", source: "rfc6960", citation: "RFC 6960 2.2",
1436
+ message: "a definitive response shall be digitally signed; the signature BIT STRING is empty",
1437
+ check: function (resp) { return _ocspBasic(resp).signature.length === 0 ? true : null; },
1438
+ },
1439
+ {
1440
+ id: "lint/rfc6960/update-times-inverted", severity: "error", source: "rfc6960", citation: "RFC 6960 2.4 / 4.2.2.1",
1441
+ /** @internal Sec. 4.2.2.1 defines the two as a validity interval and says it corresponds to the
1442
+ * CRL's, whose inversion the CRL profile grades the same way; an interval whose end precedes
1443
+ * its start is a fault of definition, like an extension on the wrong list, rather than a
1444
+ * reading, and pki.ocsp.sign refuses to write one. */
1445
+ message: "a SingleResponse's thisUpdate must not follow its nextUpdate",
1446
+ check: function (resp) {
1447
+ return _perResponse(resp, function (sr) {
1448
+ // allow:nan-date-comparison-unguarded -- both are codec-parsed OCSP times (asn1 readTime rejects a NaN instant).
1449
+ if (sr.nextUpdate === null) return null;
1450
+ return guard.time.instantOf(sr.thisUpdate) > guard.time.instantOf(sr.nextUpdate) ? { context: {} } : null;
1451
+ });
1452
+ },
1453
+ },
1454
+ {
1455
+ id: "lint/rfc6960/produced-before-this-update", severity: "notice", source: "rfc6960", citation: "RFC 6960 2.4 / 2.5",
1456
+ /** @internal producedAt is the time the responder signed, thisUpdate the most recent time the
1457
+ * status was known to be correct; a response signed before that time certifies status it did
1458
+ * not yet know. No clause forbids it, so this is advisory. */
1459
+ message: "producedAt is earlier than a SingleResponse's thisUpdate, so the response was signed before the status it certifies was known",
1460
+ check: function (resp) {
1461
+ var produced = _ocspBasic(resp).producedAt;
1462
+ return _perResponse(resp, function (sr) {
1463
+ // allow:nan-date-comparison-unguarded -- both are codec-parsed OCSP times (asn1 readTime rejects a NaN instant).
1464
+ return guard.time.instantOf(produced) < guard.time.instantOf(sr.thisUpdate) ? { context: {} } : null;
1465
+ });
1466
+ },
1467
+ },
1468
+ {
1469
+ id: "lint/rfc6960/certs-present-but-empty", severity: "warn", source: "rfc6960", citation: "RFC 6960 4.2.1",
1470
+ message: "certs should be absent when no certificates are included",
1471
+ check: function (resp) { var bs = _ocspBasic(resp); return bs.certsPresent === true && bs.certs.length === 0 ? true : null; },
1472
+ },
1473
+ {
1474
+ id: "lint/rfc6960/response-extension-criticality", severity: "error", source: "rfc6960", citation: "RFC 6960 4.4.8",
1475
+ message: "a response extension is marked with a criticality the profile does not allow",
1476
+ check: function (resp) {
1477
+ var rows = _criticalityRows(_ocspResponseExts(resp), OCSP_RESPONSE_CRITICALITY, "responseExtensions");
1478
+ return rows.length ? rows : null;
1479
+ },
1480
+ },
1481
+ {
1482
+ id: "lint/rfc6960/single-extension-criticality", severity: "error", source: "rfc6960", citation: "RFC 6960 4.4.5 / RFC 5280 5.3.1 / 5.3.2 / 5.3.3",
1483
+ message: "a single extension is marked with a criticality the profile does not allow",
1484
+ check: function (resp) {
1485
+ return _perResponse(resp, function (sr) { return _criticalityRows(_ocspSingleExts(sr), OCSP_SINGLE_CRITICALITY, "singleExtensions"); });
1486
+ },
1487
+ },
1488
+ {
1489
+ id: "lint/rfc6960/extended-revoke-value-not-null", severity: "error", source: "rfc6960", citation: "RFC 6960 4.4.8",
1490
+ message: "the value of the extended revoked definition extension shall be NULL",
1491
+ check: function (resp) {
1492
+ var e = _extIn(_ocspResponseExts(resp), OID_OCSP_EXTENDED_REVOKE);
1493
+ return e && _valueSyntaxFault(e, OCSP_RESPONSE_KNOWN, OCSP_RESPONSE_VALUE_READER) !== null ? true : null;
1494
+ },
1495
+ },
1496
+ {
1497
+ id: "lint/rfc6960/extended-revoke-in-single-extensions", severity: "error", source: "rfc6960", citation: "RFC 6960 4.4.8",
1498
+ message: "the extended revoked definition extension must be placed in responseExtensions and must not appear in singleExtensions",
1499
+ check: function (resp) {
1500
+ return _perResponse(resp, function (sr) {
1501
+ return _extIn(_ocspSingleExts(sr), OID_OCSP_EXTENDED_REVOKE) ? _placementRow("ocspExtendedRevoke", "responseExtensions", "singleExtensions") : null;
1502
+ });
1503
+ },
1504
+ },
1505
+ {
1506
+ id: "lint/rfc6960/non-issued-without-extended-revoke", severity: "error", source: "rfc6960", citation: "RFC 6960 2.2",
1507
+ message: "a revoked answer for a non-issued certificate (certificateHold at January 1, 1970) must be accompanied by the extended revoked definition response extension",
1508
+ check: function (resp) {
1509
+ if (_extIn(_ocspResponseExts(resp), OID_OCSP_EXTENDED_REVOKE)) return null;
1510
+ return _perResponse(resp, function (sr) { return _isNonIssuedShape(sr) ? { context: {} } : null; });
1511
+ },
1512
+ },
1513
+ {
1514
+ id: "lint/rfc6960/non-issued-with-crl-extensions", severity: "error", source: "rfc6960", citation: "RFC 6960 2.2",
1515
+ message: "a revoked answer for a non-issued certificate must not include a CRL references extension or any CRL entry extension",
1516
+ check: function (resp) {
1517
+ return _perResponse(resp, function (sr) {
1518
+ if (!_isNonIssuedShape(sr)) return null;
1519
+ var rows = [];
1520
+ _ocspSingleExts(sr).forEach(function (e) {
1521
+ if (e.oid === OID_OCSP_CRL || CRL_ENTRY_CRITICALITY[e.oid] !== undefined) rows.push({ context: { extension: e.name } });
1522
+ });
1523
+ return rows.length ? rows : null;
1524
+ });
1525
+ },
1526
+ },
1527
+ {
1528
+ id: "lint/rfc6960/archive-cutoff-in-response-extensions", severity: "error", source: "rfc6960", citation: "RFC 6960 4.4.4",
1529
+ message: "an archive cutoff shall be provided as a singleExtensions extension",
1530
+ check: function (resp) {
1531
+ return _extIn(_ocspResponseExts(resp), OID_OCSP_ARCHIVE_CUTOFF) ? _placementRow("ocspArchiveCutoff", "singleExtensions", "responseExtensions") : null;
1532
+ },
1533
+ },
1534
+ {
1535
+ id: "lint/rfc6960/crl-references-in-response-extensions", severity: "error", source: "rfc6960", citation: "RFC 6960 4.4.2",
1536
+ message: "the CRL references extension is specified as a singleExtensions extension",
1537
+ check: function (resp) {
1538
+ return _extIn(_ocspResponseExts(resp), OID_OCSP_CRL) ? _placementRow("ocspCrl", "singleExtensions", "responseExtensions") : null;
1539
+ },
1540
+ },
1541
+ {
1542
+ id: "lint/rfc6960/nonce-in-single-extensions", severity: "error", source: "rfc6960", citation: "RFC 9654 2.1",
1543
+ message: "in a response the nonce is included as one of the responseExtensions",
1544
+ check: function (resp) {
1545
+ return _perResponse(resp, function (sr) {
1546
+ return _extIn(_ocspSingleExts(sr), OID_OCSP_NONCE) ? _placementRow("ocspNonce", "responseExtensions", "singleExtensions") : null;
1547
+ });
1548
+ },
1549
+ },
1550
+ {
1551
+ id: "lint/rfc6960/request-extension-in-response", severity: "error", source: "rfc6960", citation: "RFC 6960 4.4.3 / 4.4.6 / 4.4.7",
1552
+ message: "an extension defined for OCSP requests only is carried on a response",
1553
+ check: function (resp) {
1554
+ var rows = [];
1555
+ _ocspResponseExts(resp).forEach(function (e) {
1556
+ if (OCSP_REQUEST_ONLY[e.oid] !== undefined) rows.push({ context: { extension: e.name, defined: OCSP_REQUEST_ONLY[e.oid], was: "responseExtensions" } });
1557
+ });
1558
+ var per = _perResponse(resp, function (sr) {
1559
+ var out = [];
1560
+ _ocspSingleExts(sr).forEach(function (e) {
1561
+ if (OCSP_REQUEST_ONLY[e.oid] !== undefined) out.push({ context: { extension: e.name, defined: OCSP_REQUEST_ONLY[e.oid], was: "singleExtensions" } });
1562
+ });
1563
+ return out.length ? out : null;
1564
+ });
1565
+ if (per) per.forEach(function (row) { rows.push(row); });
1566
+ return rows.length ? rows : null;
1567
+ },
1568
+ },
1569
+ {
1570
+ id: "lint/rfc6960/archive-cutoff-after-produced-at", severity: "notice", source: "rfc6960", citation: "RFC 6960 4.4.4",
1571
+ /** @internal The archive cutoff is defined as producedAt minus the responder's retention
1572
+ * interval, so a cutoff after producedAt states a negative interval. Stated by definition
1573
+ * rather than as a requirement, so advisory. */
1574
+ message: "an archive cutoff later than producedAt implies a negative retention interval",
1575
+ check: function (resp) {
1576
+ var produced = _ocspBasic(resp).producedAt;
1577
+ return _perResponse(resp, function (sr) {
1578
+ var e = _extIn(_ocspSingleExts(sr), OID_OCSP_ARCHIVE_CUTOFF);
1579
+ // allow:nan-date-comparison-unguarded -- both are codec-parsed OCSP times (the parser refuses an archive cutoff that is not a GeneralizedTime).
1580
+ if (!e) return null;
1581
+ return guard.time.instantOf(e.archiveCutoff) > guard.time.instantOf(produced) ? { context: {} } : null;
1582
+ });
1583
+ },
1584
+ },
1585
+ {
1586
+ id: "lint/rfc6960/extension-value-syntax", severity: "error", source: "rfc6960", citation: "RFC 6960 4.4.5 / 4.4.8",
1587
+ message: "a recognized extension's value does not decode as the structure its identifier names",
1588
+ check: function (resp) {
1589
+ var rows = [];
1590
+ _ocspResponseExts(resp).forEach(function (e) {
1591
+ var why = _valueSyntaxFault(e, OCSP_RESPONSE_KNOWN, OCSP_RESPONSE_VALUE_READER);
1592
+ if (why !== null) rows.push({ context: { extension: e.name, scope: "responseExtensions", problem: why } });
1593
+ });
1594
+ var per = _perResponse(resp, function (sr) {
1595
+ var out = [];
1596
+ _ocspSingleExts(sr).forEach(function (e) {
1597
+ var why = _valueSyntaxFault(e, OCSP_SINGLE_KNOWN, OCSP_SINGLE_VALUE_READER);
1598
+ if (why !== null) out.push({ context: { extension: e.name, scope: "singleExtensions", problem: why } });
1599
+ });
1600
+ return out.length ? out : null;
1601
+ });
1602
+ if (per) per.forEach(function (row) { rows.push(row); });
1603
+ return rows.length ? rows : null;
1604
+ },
1605
+ },
1606
+ {
1607
+ id: "lint/rfc6960/unknown-critical-extension", severity: "error", source: "rfc6960", citation: "RFC 6960 4.4 / RFC 5019 2.2.1",
1608
+ /** @internal RFC 6960 adopts the RFC 5280 extension model, under which a consumer that cannot
1609
+ * process a critical extension must not use the structure, and RFC 5019 lets a client ignore
1610
+ * only an unrecognized NON-critical response extension. The recognized set is the one this
1611
+ * profile reads, per list. */
1612
+ message: "a critical extension the profile does not recognize is present",
1613
+ check: function (resp) {
1614
+ var rows = [];
1615
+ _ocspResponseExts(resp).forEach(function (e) {
1616
+ if (e.critical === true && OCSP_RESPONSE_KNOWN[e.oid] === undefined) rows.push({ context: { extension: e.name || e.oid, scope: "responseExtensions" } });
1617
+ });
1618
+ var per = _perResponse(resp, function (sr) {
1619
+ var out = [];
1620
+ _ocspSingleExts(sr).forEach(function (e) {
1621
+ if (e.critical === true && OCSP_SINGLE_KNOWN[e.oid] === undefined) out.push({ context: { extension: e.name || e.oid, scope: "singleExtensions" } });
1622
+ });
1623
+ return out.length ? out : null;
1624
+ });
1625
+ if (per) per.forEach(function (row) { rows.push(row); });
1626
+ return rows.length ? rows : null;
1627
+ },
1628
+ },
1629
+ /** @internal The two reason rules each have two carriers graded apart. The reasonCode single
1630
+ * extension IS the RFC 5280 sec. 5.3.1 extension, carried into a SingleResponse by sec. 4.4.5, so
1631
+ * its rows grade at that clause's strength. RevokedInfo's own revocationReason is a separate field
1632
+ * of the same CRLReason type, and the clause is applied to it by reading, so its rows are advisory. */
1633
+ {
1634
+ id: "lint/rfc6960/reason-code-unspecified", severity: "warn", source: "rfc6960", citation: "RFC 6960 4.4.5 / RFC 5280 5.3.1",
1635
+ message: "a reasonCode single extension of unspecified(0) should be absent instead",
1636
+ check: function (resp) { return _perResponse(resp, function (sr) { return _singleReasonCode(sr) === 0 ? { context: {} } : null; }); },
1637
+ },
1638
+ {
1639
+ id: "lint/rfc6960/remove-from-crl-reason", severity: "error", source: "rfc6960", citation: "RFC 6960 4.4.5 / RFC 5280 5.3.1",
1640
+ message: "a reasonCode of removeFromCRL may only appear in a delta CRL; a status response is not one",
1641
+ check: function (resp) { return _perResponse(resp, function (sr) { return _singleReasonCode(sr) === 8 ? { context: {} } : null; }); },
1642
+ },
1643
+ {
1644
+ id: "lint/rfc6960/revocation-reason-unspecified", severity: "notice", source: "rfc6960", citation: "RFC 6960 4.2.1 / RFC 5280 5.3.1",
1645
+ message: "a revocationReason of unspecified(0) states no reason; the reasonCode extension it mirrors should be absent instead",
1646
+ check: function (resp) {
1647
+ return _perResponse(resp, function (sr) {
1648
+ return sr.certStatus.type === "revoked" && sr.certStatus.revocationReason === "unspecified" ? { context: {} } : null;
1649
+ });
1650
+ },
1651
+ },
1652
+ {
1653
+ id: "lint/rfc6960/revocation-reason-remove-from-crl", severity: "notice", source: "rfc6960", citation: "RFC 6960 4.2.1 / RFC 5280 5.3.1",
1654
+ message: "a revocationReason of removeFromCRL names a delta-CRL action, which a status response cannot state",
1655
+ check: function (resp) {
1656
+ return _perResponse(resp, function (sr) {
1657
+ return sr.certStatus.type === "revoked" && sr.certStatus.revocationReason === "removeFromCRL" ? { context: {} } : null;
1658
+ });
1659
+ },
1660
+ },
1661
+ {
1662
+ id: "lint/rfc6960/duplicate-cert-id", severity: "notice", source: "rfc6960", citation: "RFC 6960 4.2.2.3",
1663
+ /** @internal A response carries a SingleResponse per certificate. Two for the same CertID
1664
+ * answer one certificate twice, and when the two statuses differ a relying party cannot tell
1665
+ * which governs; the context says which case it is. No clause forbids the repeat, so advisory. */
1666
+ message: "two SingleResponses answer for the same CertID",
1667
+ check: function (resp) {
1668
+ var rows = [], first = Object.create(null);
1669
+ _ocspResponses(resp).forEach(function (sr, i) {
1670
+ var key = _certIdKey(sr.certID), seen = first[key];
1671
+ if (seen === undefined) { first[key] = { index: i, status: _statusKey(sr.certStatus) }; return; }
1672
+ rows.push({ context: { response: i, first: seen.index, conflicting: seen.status !== _statusKey(sr.certStatus) } });
1673
+ });
1674
+ return rows.length ? rows : null;
1675
+ },
1676
+ },
1677
+ ];
1678
+
1679
+ /** @internal Nothing in a response says which profile its responder follows, so the RFC 5019 rows
1680
+ * run only when that profile is selected and count as not applicable otherwise. */
1681
+ function _isLightweight(resp, ctx) { return ctx.profile === "rfc5019"; }
1682
+
1683
+ var RFC5019_OCSP_RULES = [
1684
+ {
1685
+ id: "lint/rfc5019/next-update-missing", severity: "error", source: "rfc5019", citation: "RFC 5019 2.2.4",
1686
+ message: "a lightweight-profile responder must always include nextUpdate",
1687
+ appliesTo: _isLightweight,
1688
+ check: function (resp) { return _perResponse(resp, function (sr) { return sr.nextUpdate == null ? { context: {} } : null; }); },
1689
+ },
1690
+ {
1691
+ id: "lint/rfc5019/multiple-single-responses", severity: "warn", source: "rfc5019", citation: "RFC 5019 2.2.1",
1692
+ message: "a lightweight-profile response should include only one SingleResponse",
1693
+ appliesTo: _isLightweight,
1694
+ check: function (resp) { var n = _ocspResponses(resp).length; return n > 1 ? { context: { responses: n } } : null; },
1695
+ },
1696
+ {
1697
+ id: "lint/rfc5019/response-extensions-present", severity: "warn", source: "rfc5019", citation: "RFC 5019 2.2.1",
1698
+ message: "a lightweight-profile responder should not include responseExtensions",
1699
+ appliesTo: _isLightweight,
1700
+ check: function (resp) { return _ocspBasic(resp).responseExtensions ? true : null; },
1701
+ },
1702
+ {
1703
+ id: "lint/rfc5019/responder-id-by-name", severity: "notice", source: "rfc5019", citation: "RFC 5019 2.2.2",
1704
+ /** @internal A SHOULD conditioned on bandwidth being at issue, which the response cannot show,
1705
+ * so it grades as advisory rather than as the warning an unconditional SHOULD would draw. */
1706
+ message: "a lightweight-profile responder should use the byKey ResponderID where reducing bandwidth matters",
1707
+ appliesTo: _isLightweight,
1708
+ check: function (resp) { var rid = _ocspBasic(resp).responderID; return rid && rid.byName !== undefined ? true : null; },
1709
+ },
1710
+ ];
1711
+
1712
+ var OCSP_PROFILES = Object.assign(Object.create(null), { "rfc6960": RFC6960_OCSP_RULES, "rfc5019": RFC5019_OCSP_RULES });
1713
+ var ALL_OCSP_RULES = RFC6960_OCSP_RULES.concat(RFC5019_OCSP_RULES);
1714
+
1284
1715
  var PROFILES = Object.assign(Object.create(null), {
1285
1716
  "rfc5280": RFC5280_RULES,
1286
1717
  "rfc9881": RFC9881_RULES,
@@ -1294,6 +1725,7 @@ function _selectRules(profile) {
1294
1725
  if (profile == null || profile === "all" || profile === "default") return ALL_RULES;
1295
1726
  if (!PROFILES[profile]) {
1296
1727
  if (CRL_PROFILES[profile]) throw _cfg("lint/unknown-profile", "\"" + profile + "\" is a CRL profile; lint a CRL with pki.lint.crl");
1728
+ if (OCSP_PROFILES[profile]) throw _cfg("lint/unknown-profile", "\"" + profile + "\" is an OCSP profile; lint a response with pki.lint.ocsp");
1297
1729
  throw _cfg("lint/unknown-profile", "unknown lint profile \"" + profile + "\" (known: " + Object.keys(PROFILES).join(", ") + ")");
1298
1730
  }
1299
1731
  return PROFILES[profile];
@@ -1447,6 +1879,7 @@ function crl(input, opts) {
1447
1879
  if (profile == null || profile === "all" || profile === "default") rules_ = ALL_CRL_RULES;
1448
1880
  else if (CRL_PROFILES[profile]) rules_ = CRL_PROFILES[profile];
1449
1881
  else if (PROFILES[profile]) throw _cfg("lint/unknown-profile", "\"" + profile + "\" is a certificate profile; lint a certificate with pki.lint.certificate");
1882
+ else if (OCSP_PROFILES[profile]) throw _cfg("lint/unknown-profile", "\"" + profile + "\" is an OCSP profile; lint a response with pki.lint.ocsp");
1450
1883
  else throw _cfg("lint/unknown-profile", "unknown CRL lint profile \"" + profile + "\" (known: " + Object.keys(CRL_PROFILES).join(", ") + ")");
1451
1884
 
1452
1885
  var ingested = _ingestCrl(input);
@@ -1462,6 +1895,121 @@ function crl(input, opts) {
1462
1895
  return report;
1463
1896
  }
1464
1897
 
1898
+ var _OCSP_OPTS = Object.assign(Object.create(null), { profile: 1, severity: 1 });
1899
+ var _OCSP_CLAIM = ["responseStatus", "basicResponse", "tbsResponseDataBytes"];
1900
+
1901
+ /** @internal The door pki.ocsp.verify uses for a parsed response: a result the parser recorded is
1902
+ * re-read from the source it came from, which is the DER when the parser was fed bytes and the PEM
1903
+ * text when it was fed a string, and an object that merely carries a response's field names is
1904
+ * refused as config-time misuse rather than linted, since its fields could describe different
1905
+ * responses. Bytes and PEM take the fatal path below, never a throw. */
1906
+ function _ingestOcsp(input) {
1907
+ var der;
1908
+ if (Buffer.isBuffer(input)) der = input;
1909
+ else if (typeof input === "string") {
1910
+ try { der = ocspSchema.pemDecode(input); }
1911
+ catch (e) { return { fatal: { id: "lint/unparseable", severity: "fatal", source: "engine", citation: "pki.lint", message: "input is not a decodable PEM/DER OCSP response", context: { code: e.code } } }; }
1912
+ } else if (input !== null && typeof input === "object" && !guard.bytes.isByteSource(input)) {
1913
+ return { response: guard.parsed.fromTrustedSource(input, "ocspResponse", _OCSP_CLAIM, function (src) {
1914
+ if (!Buffer.isBuffer(src) && typeof src !== "string") throw _cfg("lint/bad-input", "pki.lint.ocsp input must be a parsed OCSP response, a DER Buffer, or a PEM string");
1915
+ return ocspSchema.parseResponse(src);
1916
+ }, _cfg, "lint/bad-input",
1917
+ "pki.lint.ocsp input must be its DER bytes, a PEM string, or an unmodified pki.schema.ocsp.parseResponse result: the signed byte range, the signature and the fields that range encodes are separate properties of a parsed object, so a rebuilt response could have them describe different responses") };
1918
+ } else {
1919
+ throw _cfg("lint/bad-input", "pki.lint.ocsp input must be a parsed OCSP response, a DER Buffer, or a PEM string");
1920
+ }
1921
+ try { return { response: ocspSchema.parseResponse(der) }; }
1922
+ catch (e) { return { fatal: { id: "lint/unparseable", severity: "fatal", source: "engine", citation: "RFC 6960", message: "input is not a well-formed OCSP response", context: { code: e.code } } }; }
1923
+ }
1924
+
1925
+ /**
1926
+ * @primitive pki.lint.ocsp
1927
+ * @signature pki.lint.ocsp(pem | der | parsed, opts?) -> LintReport
1928
+ * @since 0.7.26
1929
+ * @status stable
1930
+ * @spec RFC 6960, RFC 5019, RFC 9654
1931
+ * @related pki.lint.certificate, pki.lint.crl, pki.schema.ocsp.parseResponse, pki.ocsp.sign, pki.ocsp.verify
1932
+ *
1933
+ * Lint an OCSP response against the RFC 6960 profile, and on request against the RFC 5019
1934
+ * lightweight profile. `input` is a PEM string (`OCSP RESPONSE`), a DER `Buffer`, or an
1935
+ * already-parsed `pki.schema.ocsp.parseResponse` object. It returns the same `LintReport` shape
1936
+ * `pki.lint.certificate` returns, and it never throws on the data path: bytes that are not a
1937
+ * well-formed response produce a single `fatal` finding `lint/unparseable` carrying the strict
1938
+ * parser's own code. A non-successful response carries no BasicOCSPResponse and draws no row.
1939
+ *
1940
+ * The `rfc6960` rows cover each SingleResponse's update times and their relation to producedAt,
1941
+ * an empty `certs` field, the extended revoked definition (criticality, value and placement) and
1942
+ * the fixed shape of a revoked answer for a non-issued certificate, the placement of the nonce,
1943
+ * archive cutoff, CRL references and the request-only extensions, an archive cutoff later than
1944
+ * producedAt, the criticality and value syntax of the CRL entry extensions a SingleResponse may
1945
+ * carry, an unrecognized critical extension, the revocation reasons unspecified and removeFromCRL,
1946
+ * and two SingleResponses answering for one CertID. The `rfc5019` rows (nextUpdate present, one
1947
+ * SingleResponse, no responseExtensions, a byKey ResponderID) run only when that profile is
1948
+ * selected, since nothing in a response says which profile its responder follows; in the default
1949
+ * run they count as not applicable.
1950
+ *
1951
+ * Shapes the strict parser refuses never reach a rule and arrive as `lint/unparseable` carrying the
1952
+ * parser's code: a version other than v1, an undefined revocation reason, an empty or repeated
1953
+ * extension, an empty `responses`, a nonce outside 1..128 octets, a malformed archive cutoff or
1954
+ * CRL reference, a responseStatus that disagrees with responseBytes, and a time not in the
1955
+ * `YYYYMMDDHHMMSSZ` form RFC 5019 requires. What needs the issuer or the signature (responder
1956
+ * authorization, the ResponderID matching the signing certificate, freshness against a clock)
1957
+ * is `pki.ocsp.verify`'s, and the certificates in `certs` are linted with `pki.lint.certificate`.
1958
+ *
1959
+ * @opts profile `"rfc6960"` or `"rfc5019"` (default runs the rfc6960 rows and marks the rfc5019
1960
+ * rows not applicable). A certificate or CRL profile name is refused rather than
1961
+ * run against a response.
1962
+ * @opts severity Suppress findings below this floor (default `"notice"`). `counts` and `worst`
1963
+ * always reflect the complete, unfiltered result.
1964
+ * @example
1965
+ * var ca = await pki.key.generate("Ed25519");
1966
+ * var caKey = await pki.key.export(ca.privateKey);
1967
+ * var caDer = await pki.x509.sign({ subject: "Example CA", subjectPublicKey: await pki.key.export(ca.publicKey),
1968
+ * notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z"),
1969
+ * extensions: { basicConstraints: { cA: true }, keyUsage: ["keyCertSign"], subjectKeyIdentifier: true } }, { key: caKey });
1970
+ * var leaf = await pki.key.generate("Ed25519");
1971
+ * var leafDer = await pki.x509.sign({ subject: "leaf.example", subjectPublicKey: await pki.key.export(leaf.publicKey),
1972
+ * notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z") },
1973
+ * { cert: caDer, key: caKey });
1974
+ * var der = await pki.ocsp.sign({ responderID: "byKey", responses: [{ cert: leafDer, issuer: caDer, status: "good" }] },
1975
+ * { cert: caDer, key: caKey });
1976
+ * var report = pki.lint.ocsp(der);
1977
+ * report.findings.length; // -> 0
1978
+ * pki.lint.ocsp(der, { profile: "rfc5019" }).worst; // -> null
1979
+ */
1980
+ function ocsp(input, opts) {
1981
+ opts = guard.identifier.optionsObject(opts, _cfg, "lint/bad-input", "pki.lint options");
1982
+ guard.identifier.assertKnownKeys(opts, _OCSP_OPTS, _cfg, "lint/bad-input",
1983
+ "pki.lint.ocsp has an unknown option: ");
1984
+ if (opts.severity != null && VALID_SEVERITY.indexOf(opts.severity) === -1) {
1985
+ throw _cfg("lint/bad-severity", "unknown severity threshold \"" + opts.severity + "\" (known: " + VALID_SEVERITY.join(", ") + ")");
1986
+ }
1987
+ var profile = opts.profile;
1988
+ var rules_;
1989
+ if (profile == null || profile === "all" || profile === "default") rules_ = ALL_OCSP_RULES;
1990
+ else if (OCSP_PROFILES[profile]) rules_ = OCSP_PROFILES[profile];
1991
+ else if (PROFILES[profile]) throw _cfg("lint/unknown-profile", "\"" + profile + "\" is a certificate profile; lint a certificate with pki.lint.certificate");
1992
+ else if (CRL_PROFILES[profile]) throw _cfg("lint/unknown-profile", "\"" + profile + "\" is a CRL profile; lint a CRL with pki.lint.crl");
1993
+ else throw _cfg("lint/unknown-profile", "unknown OCSP lint profile \"" + profile + "\" (known: " + Object.keys(OCSP_PROFILES).join(", ") + ")");
1994
+
1995
+ var ingested = _ingestOcsp(input);
1996
+ var report;
1997
+ if (ingested.fatal) {
1998
+ report = { findings: [ingested.fatal], ran: [],
1999
+ counts: Object.assign(Object.create(null), { fatal: 1, error: 0, warn: 0, notice: 0, pass: 0, na: 0, ne: 0 }) };
2000
+ } else if (!ingested.response.basicResponse) {
2001
+ /** @internal A non-successful response carries no BasicOCSPResponse, so every row is not
2002
+ * applicable to it: the profile grades responses, and an error status is not one. */
2003
+ report = { findings: [], ran: [],
2004
+ counts: Object.assign(Object.create(null), { fatal: 0, error: 0, warn: 0, notice: 0, pass: 0, na: rules_.length, ne: 0 }) };
2005
+ } else {
2006
+ report = _runLints(rules_, ingested.response, { profile: profile, decode: function () { return null; }, raw: function () { return null; } });
2007
+ }
2008
+ report.worst = _worst(report.findings);
2009
+ report.findings = _applyThreshold(report, opts.severity);
2010
+ return report;
2011
+ }
2012
+
1465
2013
  /**
1466
2014
  * @primitive pki.lint.rules
1467
2015
  * @signature pki.lint.rules(profile?) -> [{id, severity, source, citation}]
@@ -1477,8 +2025,8 @@ function crl(input, opts) {
1477
2025
  */
1478
2026
  function rules(profile) {
1479
2027
  var set = profile == null
1480
- ? ALL_RULES.concat(ALL_CRL_RULES)
1481
- : (PROFILES[profile] || CRL_PROFILES[profile]
2028
+ ? ALL_RULES.concat(ALL_CRL_RULES).concat(ALL_OCSP_RULES)
2029
+ : (PROFILES[profile] || CRL_PROFILES[profile] || OCSP_PROFILES[profile]
1482
2030
  || (function () { throw _cfg("lint/unknown-profile", "unknown lint profile \"" + profile + "\""); })());
1483
2031
  return set.map(function (r) { return { id: r.id, severity: r.severity, source: r.source, citation: r.citation }; });
1484
2032
  }
@@ -1492,13 +2040,14 @@ function rules(profile) {
1492
2040
  *
1493
2041
  * List the known lint-profile names.
1494
2042
  * @example
1495
- * pki.lint.profiles(); // -> ["rfc5280", "rfc9881", "rfc9909", "rfc9935", "cabf-tls", "rfc5280-crl"]
2043
+ * pki.lint.profiles(); // -> ["rfc5280", "rfc9881", "rfc9909", "rfc9935", "cabf-tls", "rfc5280-crl", "rfc6960", "rfc5019"]
1496
2044
  */
1497
- function profiles() { return Object.keys(PROFILES).concat(Object.keys(CRL_PROFILES)); }
2045
+ function profiles() { return Object.keys(PROFILES).concat(Object.keys(CRL_PROFILES)).concat(Object.keys(OCSP_PROFILES)); }
1498
2046
 
1499
2047
  module.exports = {
1500
2048
  certificate: certificate,
1501
2049
  crl: crl,
2050
+ ocsp: ocsp,
1502
2051
  rules: rules,
1503
2052
  profiles: profiles,
1504
2053
  };
package/lib/pki-build.js CHANGED
@@ -317,17 +317,9 @@ function reqDenseArrayImpl(list, what, E, code) {
317
317
  return out;
318
318
  }
319
319
 
320
- /** @internal RFC 5280 sec. 4.2 fixes the criticality of these certificate extensions. Each entry maps
321
- * the extension OID to the flag the RFC requires and the clause that sets it. */
322
- var CERT_FIXED_CRITICALITY = intrinsic.create(null);
323
- [["authorityKeyIdentifier", false, "RFC 5280 sec. 4.2.1.1"], ["subjectKeyIdentifier", false, "RFC 5280 sec. 4.2.1.2"],
324
- ["subjectDirectoryAttributes", false, "RFC 5280 sec. 4.2.1.8"], ["nameConstraints", true, "RFC 5280 sec. 4.2.1.10"],
325
- ["policyConstraints", true, "RFC 5280 sec. 4.2.1.11"], ["inhibitAnyPolicy", true, "RFC 5280 sec. 4.2.1.14"],
326
- ["freshestCRL", false, "RFC 5280 sec. 4.2.1.15"], ["authorityInfoAccess", false, "RFC 5280 sec. 4.2.2.1"],
327
- ["subjectInfoAccess", false, "RFC 5280 sec. 4.2.2.2"],
328
- ["precertificatePoison", true, "RFC 6962 sec. 3.1"]].forEach(function (r) {
329
- CERT_FIXED_CRITICALITY[oid.byName(r[0])] = { critical: r[1], citation: r[2] };
330
- });
320
+ /** @internal The criticality RFC 5280 sec. 4.2 fixes for a certificate extension, by OID, read from
321
+ * the shared table the certificate lint profile reports from, so the two cannot disagree. */
322
+ var CERT_FIXED_CRITICALITY = pkix.certFixedCriticality(oid);
331
323
 
332
324
  var NC_KEYS = intrinsic.assign(intrinsic.create(null), { permitted: 1, excluded: 1 });
333
325
  var NC_FORM_TAG = intrinsic.assign(intrinsic.create(null), {
package/lib/schema-crl.js CHANGED
@@ -239,4 +239,9 @@ module.exports = {
239
239
  pemDecode: pemDecode,
240
240
  pemEncode: pemEncode,
241
241
  matches: matches,
242
+ /** @internal The reader this parser applies to a CRL entry extension (reasonCode, invalidityDate),
243
+ * for a consumer that meets the same extension elsewhere: RFC 6960 sec. 4.4.5 carries every CRL
244
+ * entry extension into an OCSP SingleResponse, and the linter reads it with this rather than a
245
+ * second one. Not on the curated `pki.schema.crl` surface. */
246
+ decodeExtension: decodeExt,
242
247
  };
@@ -297,6 +297,7 @@ var BASIC_OCSP_RESPONSE = schema.seq([
297
297
  signatureAlgorithm: m.fields.signatureAlgorithm.value.result,
298
298
  signature: _rawSignature(m.fields.signature),
299
299
  certs: m.fields.certs.present ? m.fields.certs.value.items.map(function (it) { return it.value; }) : [],
300
+ certsPresent: m.fields.certs.present,
300
301
  };
301
302
  },
302
303
  });
@@ -459,8 +460,10 @@ var parseRequest = pkix.makeParser({ pemLabel: "OCSP REQUEST", PemError: PemErro
459
460
  * Parse a DER `Buffer` or a PEM (`OCSP RESPONSE`) string into a structured
460
461
  * OCSPResponse: `{ responseStatus, responseBytes, basicResponse }`. For a successful
461
462
  * basic response, `basicResponse` carries `{ tbsResponseDataBytes, responderID,
462
- * producedAt, responses, signatureAlgorithm, signature, certs }`; each
463
- * `responses[i].certStatus` is `{ type: "good" | "revoked" | "unknown" }`. A
463
+ * producedAt, responses, signatureAlgorithm, signature, certs, certsPresent }`; each
464
+ * `responses[i].certStatus` is `{ type: "good" | "revoked" | "unknown" }`. `certs` is the
465
+ * embedded certificate list, empty when the field is absent, and `certsPresent` says
466
+ * whether the response carried the field at all. A
464
467
  * non-successful status carries no `responseBytes`. A malformed structure throws a
465
468
  * typed `OcspError` (`ocsp/*`); an unsupported `responseType` throws
466
469
  * `ocsp/unsupported-response-type`.
@@ -458,6 +458,29 @@ function rawNonEmptySequence(ns, opts) {
458
458
 
459
459
  var CRL_REASON_NAMES = constants.NAMES.CRL_REASON;
460
460
 
461
+ /** @internal The criticality RFC 5280 (and RFC 6962 for the poison) fixes for a certificate
462
+ * extension, by extension name, each with its clause. One table for the two consumers that state
463
+ * the rule: the certificate signer refuses to write an extension against it, and the certificate
464
+ * lint profile reports one that arrived against it. Extensions whose criticality the CA chooses
465
+ * (basicConstraints, keyUsage, the alternative names) are absent. */
466
+ var CERT_FIXED_CRITICALITY_ROWS = [
467
+ ["authorityKeyIdentifier", false, "RFC 5280 sec. 4.2.1.1"], ["subjectKeyIdentifier", false, "RFC 5280 sec. 4.2.1.2"],
468
+ ["subjectDirectoryAttributes", false, "RFC 5280 sec. 4.2.1.8"], ["nameConstraints", true, "RFC 5280 sec. 4.2.1.10"],
469
+ ["policyConstraints", true, "RFC 5280 sec. 4.2.1.11"], ["inhibitAnyPolicy", true, "RFC 5280 sec. 4.2.1.14"],
470
+ ["freshestCRL", false, "RFC 5280 sec. 4.2.1.15"], ["authorityInfoAccess", false, "RFC 5280 sec. 4.2.2.1"],
471
+ ["subjectInfoAccess", false, "RFC 5280 sec. 4.2.2.2"],
472
+ ["precertificatePoison", true, "RFC 6962 sec. 3.1"],
473
+ ];
474
+ /** @internal The table keyed by dotted OID through the registry the caller passes, since this
475
+ * module takes its registry as a parameter rather than requiring it. */
476
+ function certFixedCriticality(oidRegistry) {
477
+ var table = _create(null);
478
+ _forEach(CERT_FIXED_CRITICALITY_ROWS, function (r) {
479
+ table[oidRegistry.byName(r[0])] = { critical: r[1], citation: r[2] };
480
+ });
481
+ return table;
482
+ }
483
+
461
484
  var GN_CONSTRUCTED = _assign(_create(null), { 0: 1, 3: 1, 4: 1, 5: 1 });
462
485
  var GN_IA5 = _assign(_create(null), { 1: 1, 2: 1, 6: 1 });
463
486
  /** @internal DirectoryString ::= CHOICE { teletexString, printableString, universalString,
@@ -1439,6 +1462,7 @@ module.exports = {
1439
1462
  utf8Text: utf8Text,
1440
1463
  rawNonEmptySequence: rawNonEmptySequence,
1441
1464
  CRL_REASON_NAMES: CRL_REASON_NAMES,
1465
+ certFixedCriticality: certFixedCriticality,
1442
1466
  certExtensionDecoders: certExtensionDecoders,
1443
1467
  _decodeHelpers: _decodeHelpers,
1444
1468
  attribute: attribute,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.7.25",
3
+ "version": "0.7.27",
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:6c1d7d34-8c46-44b5-aab6-26ca7c728d37",
5
+ "serialNumber": "urn:uuid:c684e528-5bc7-435f-8e5c-076712d49f7f",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-09-12T02:32:19.183Z",
8
+ "timestamp": "2026-09-12T04:29:07.726Z",
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.25",
22
+ "bom-ref": "@blamejs/pki@0.7.27",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.7.25",
25
+ "version": "0.7.27",
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.25",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.7.27",
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.25",
57
+ "ref": "@blamejs/pki@0.7.27",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]