Certificate, revocation-list and OCSP-response linting against RFC 5280, RFC 6960

The LINTING engine, the zlint / pkilint of JavaScript. It walks an ALREADY-PARSED certificate, certificate revocation list or OCSP response (from pki.schema.x509.parse, pki.schema.crl.parse or pki.schema.ocsp.parseResponse, whose extension values it decodes with the shared RFC 5280 decoders) and emits graded, advisory FINDINGS: each with a stable id, a severity (fatal > error > warn > notice > pass), a source, a spec-clause citation, and a human message. It ships the RFC 5280 certificate profile, the RFC 5280 section 5 revocation-list profile, the RFC 6960 OCSP response profile with the RFC 5019 lightweight rows, the post-quantum certificate profiles for ML-KEM, ML-DSA and SLH-DSA, and a representative CA/Browser Forum TLS Baseline Requirements subset.

Unlike every other toolkit entry, the lint data path never throws. A linter surveys a corpus that includes malformed members, so pki.lint.certificate(hostileBytes) returns a report whose worst finding is a fatal id lint/unparseable (carrying the inner PkiError.code) and does not raise. The one throw path is config-time misuse (an unknown profile, an out-of-range severity threshold, or a wrong-type input), which raises a typed LintError. This deliberate inversion of the toolkit's fail-closed-throw posture is what lets an operator lint a whole directory without a try/catch per file.

pki.lint.certificate

since 0.2.10 stable
pki.lint.certificate(input, opts?) -> LintReport

Lint a certificate against the RFC 5280 profile, the post-quantum certificate profiles, and a representative CABF TLS BR subset. input is a PEM string, a DER Buffer, or an already-parsed pki.schema.x509 object. Returns a LintReport { findings: [{id, severity, source, citation, message, context?}], counts, worst, ran }.

The data path never throws: hostile bytes produce a single fatal finding lint/unparseable and no exception. The one throw path is config-time misuse (opts.profile unknown, opts.severity out of range, or a wrong-type input), which raises a typed LintError.

Options

severity  Suppress findings below this floor (default `"notice"`). `counts` and
                 `worst` always reflect the complete, unfiltered result.

Example

async function example() {
  var pair = await pki.key.generate("Ed25519");
  var pemString = await pki.x509.sign({ subject: "example.com", subjectPublicKey: await pki.key.export(pair.publicKey),
    notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z") },
    { key: await pki.key.export(pair.privateKey) }, { pem: true });
  var report = pki.lint.certificate(pemString);
  report.worst;                              // "notice" | "error" | ...
  report.findings.map(function (f) { return f.id; });
}
example();

References

pki.lint.crl

since 0.7.9 stable
pki.lint.crl(pem | der | parsed, opts?) -> LintReport

Lint a certificate revocation list against the RFC 5280 section 5 profile. input is a PEM string, a DER Buffer, or an already-parsed pki.schema.crl object. It returns the same LintReport shape pki.lint.certificate returns, and it never throws on the data path: bytes that are not a well-formed CRL produce a single fatal finding lint/unparseable carrying the strict parser's own code.

The rows cover the CRL's update times (nextUpdate presence and ordering), its extensions (cRLNumber presence and length, authorityKeyIdentifier presence and the key identifier method, the criticality every profiled extension is required to carry, the scope an issuingDistributionPoint states, freshestCRL in a delta CRL, and an unrecognized critical extension), and each revoked entry (the serial profile, a reason code against the scope the CRL declares, certificateIssuer, and entry-extension criticality).

Six section 5 rules never appear here because the strict parser refuses them first, and a rule for them could not fire. A CRL carrying extensions without the version that admits them (section 5.1.2.1), a signatureAlgorithm differing from 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 arrive as lint/unparseable carrying the parser's own code.

Options

severity  Suppress findings below this floor (default `"notice"`). `counts` and `worst`
                 always reflect the complete, unfiltered result.

Example

async function example() {
  var pair = await pki.key.generate("Ed25519");
  var crlDer = await pki.crl.sign({ thisUpdate: new Date("2026-01-01T00:00:00Z"),
    nextUpdate: new Date("2026-02-01T00:00:00Z"), crlNumber: 1n },
    { name: "Example CA", publicKey: await pki.key.export(pair.publicKey), key: pair.privateKey });
  var report = pki.lint.crl(crlDer);
  report.worst;                                       // -> "error", for the missing AKI
  report.findings.map(function (f) { return f.id; }); // -> ["lint/rfc5280-crl/aki-missing"]
}
example();

References

pki.lint.ocsp

since 0.7.26 stable
pki.lint.ocsp(pem | der | parsed, opts?) -> LintReport

Lint an OCSP response against the RFC 6960 profile, and on request against the RFC 5019 lightweight profile. input is a PEM string (OCSP RESPONSE), a DER Buffer, or an already-parsed pki.schema.ocsp.parseResponse object. It returns the same LintReport shape pki.lint.certificate returns, and it never throws on the data path: bytes that are not a well-formed response produce a single fatal finding lint/unparseable carrying the strict parser's own code. A non-successful response carries no BasicOCSPResponse and draws no row.

The rfc6960 rows cover each SingleResponse's update times and their relation to producedAt, an empty certs field, the extended revoked definition (criticality, value and placement) and the fixed shape of a revoked answer for a non-issued certificate, the placement of the nonce, archive cutoff, CRL references and the request-only extensions, an archive cutoff later than producedAt, the criticality and value syntax of the CRL entry extensions a SingleResponse may carry, an unrecognized critical extension, the revocation reasons unspecified and removeFromCRL, and two SingleResponses answering for one CertID. The rfc5019 rows (nextUpdate present, one SingleResponse, no responseExtensions, a byKey ResponderID) run only when that profile is selected, since nothing in a response says which profile its responder follows; in the default run they count as not applicable.

Shapes the strict parser refuses never reach a rule and arrive as lint/unparseable carrying the parser's code: a version other than v1, an undefined revocation reason, an empty or repeated extension, an empty responses, a nonce outside 1..128 octets, a malformed archive cutoff or CRL reference, a responseStatus that disagrees with responseBytes, and a time not in the YYYYMMDDHHMMSSZ form RFC 5019 requires. What needs the issuer or the signature (responder authorization, the ResponderID matching the signing certificate, freshness against a clock) is pki.ocsp.verify's, and the certificates in certs are linted with pki.lint.certificate.

Options

severity  Suppress findings below this floor (default `"notice"`). `counts` and `worst`
                 always reflect the complete, unfiltered result.

Example

async function example() {
  var ca = await pki.key.generate("Ed25519");
  var caKey = await pki.key.export(ca.privateKey);
  var caDer = await pki.x509.sign({ subject: "Example CA", subjectPublicKey: await pki.key.export(ca.publicKey),
    notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z"),
    extensions: { basicConstraints: { cA: true }, keyUsage: ["keyCertSign"], subjectKeyIdentifier: true } }, { key: caKey });
  var leaf = await pki.key.generate("Ed25519");
  var leafDer = await pki.x509.sign({ subject: "leaf.example", subjectPublicKey: await pki.key.export(leaf.publicKey),
    notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z") },
    { cert: caDer, key: caKey });
  var der = await pki.ocsp.sign({ responderID: "byKey", responses: [{ cert: leafDer, issuer: caDer, status: "good" }] },
    { cert: caDer, key: caKey });
  var report = pki.lint.ocsp(der);
  report.findings.length;                              // -> 0
  pki.lint.ocsp(der, { profile: "rfc5019" }).worst;    // -> null
}
example();

References

pki.lint.rules

since 0.2.10 stable
pki.lint.rules(profile?) -> [{id, severity, source, citation}]

Enumerate the rule registry (all rules, or one profile's). Each entry exposes its stable id, severity, source, and spec-clause citation for documentation and corpus tooling.

Example

pki.lint.rules("rfc5280").length;   // -> a positive count

References

  • spec RFC 5280
  • spec CA/Browser Forum TLS BR

pki.lint.profiles

since 0.2.10 stable
pki.lint.profiles() -> [string]

List the known lint-profile names.

Example

pki.lint.profiles();   // -> ["rfc5280", "rfc9881", "rfc9909", "rfc9935", "cabf-tls", "rfc5280-crl", "rfc6960", "rfc5019"]

References