Certificate, revocation-list and OCSP-response linting against RFC 5280, RFC 6960
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
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
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
- spec RFC 5280 sec. 5
pki.lint.csr
pki.lint.csr(pem | der | parsed, opts?) -> LintReport
Lint a PKCS#10 certification request, which is the artifact a CA reads before it issues. input is a PEM string, a DER Buffer, or an already-parsed pki.schema.csr 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 request produce a single fatal finding lint/unparseable carrying the strict parser's own code.
The rows cover what the request asks for: an empty subject and whether a subjectAltName carries the identity in its place, the extensions an issuer determines rather than takes from a request, a request asking for a CA certificate, the digest the request is signed under, and a challengePassword traveling inside it.
This does not check the request's signature. That signature is the requester's proof that it holds the private key, verifying it is a cryptographic operation rather than a profile question, and pki.csr.verify is the verb for it. A CA runs both.
The CA/Browser Forum rows run only under opts.profile: "cabf-tls". A request carries no extKeyUsage, so nothing in its bytes says it is for TLS, and the caller states that. Those rows are the certificate profile's own, applied to what the request asks for, so the answer before issuance is the answer after it.
Five shapes never appear here because the strict parser refuses them first and a rule for them could not fire. A version other than 0 (RFC 2986 sec. 4.1), a challengePassword or extensionRequest attribute carrying more than one value, an extensionRequest carrying no extensions (RFC 2985 sec. 5.4), the same extension OID twice inside one, and attributes out of DER order each arrive as lint/unparseable carrying the parser's own code. A SECOND extensionRequest attribute does parse, where the two sort in DER order, and is reported by lint/rfc2986/extension-request-ambiguous; every one of them is read, so nothing a later attribute asks for escapes the rows below.
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 der = await pki.csr.sign({ subject: [{ commonName: "example.com" }],
subjectPublicKey: await pki.key.export(pair.publicKey) },
{ key: await pki.key.export(pair.privateKey) });
var report = pki.lint.csr(der, { profile: "cabf-tls" });
report.findings.map(function (f) { return f.id; }); // -> ["lint/cabf-tls/san-missing"]
}
example();
References
- spec RFC 2986
- spec RFC 2985 sec. 5.4
- spec RFC 5280 sec. 4.1.2.6
- spec
CA/Browser Forum TLS BR
pki.lint.ocsp
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.cms
pki.lint.cms(pem | der | parsed, opts?) -> LintReport
Lint a CMS ContentInfo against the RFC 5652 profile. input is a PEM string (CMS), a DER Buffer, or an already-parsed pki.schema.cms.parse 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 ContentInfo produce a single fatal finding lint/unparseable carrying the strict parser's own code. A content type other than signed-data carries no signerInfos, so every row is not applicable to it.
The profile is short, and what makes it short is that the strict parser has already refused most of what sections 5 and 11 require. The SignedData and SignerInfo versions against their contents, signedAttrs being present when the content type is not id-data, the content-type and message-digest attributes and the binding of the first to eContentType, every attribute's cardinality and placement, a countersignature's own restrictions, and a time carrying no seconds, fractional seconds or a local offset all arrive as lint/unparseable with the parser's code.
What the rows cover is what survives that: a signing-time naming a year from 1950 to 2049 encoded as GeneralizedTime where section 11.3 requires UTCTime, a signer whose digest algorithm the digestAlgorithms set does not list, a version 1 attribute certificate in the certificates field, and two structural notices, a SignedData carrying no signers and a detached signature whose content a verifier must be handed separately.
Verifying a signature, resolving a signer to a certificate and checking a message digest are pki.cms.verify's, and the certificates a message carries 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 pair = await pki.key.generate("Ed25519");
var key = await pki.key.export(pair.privateKey);
var cert = await pki.x509.sign({ subject: "Example Signer", subjectPublicKey: await pki.key.export(pair.publicKey),
notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z"),
extensions: { subjectKeyIdentifier: true } }, { key: key });
var der = await pki.cms.sign(Buffer.from("hello"), { cert: cert, key: key });
pki.lint.cms(der).findings.length; // -> 0
pki.lint.cms(await pki.cms.sign(Buffer.from("hello"), { cert: cert, key: key }, { detached: true })).worst;
// -> "notice"
}
example();
References
- spec RFC 5652
pki.lint.tsp
pki.lint.tsp(pem | der, opts?) -> LintReport
Lint an RFC 3161 timestamp token against the section 2.4.2 profile. input is a PEM string or a DER Buffer holding a TimeStampToken, which is a CMS SignedData encapsulating a TSTInfo. It returns the same LintReport shape pki.lint.certificate returns and never throws on the data path: bytes that are not a well-formed token produce a single fatal finding lint/unparseable carrying the strict parser's own code.
This profile is the shortest the toolkit ships, and the reason is that pki.schema.tsp.parseToken already refuses what the section requires. A token that is not a CMS SignedData, encapsulates something other than id-ct-TSTInfo, carries detached content, carries a signature other than the TSA's own, or omits the SigningCertificate attribute the section names arrives as lint/unparseable with the parser's code rather than as a row, and so do a TSTInfo version other than v1, an Accuracy field outside its stated bounds and a DEFAULT FALSE written out.
What the rows cover is what survives that: a tsa hint naming a subject the signer's own certificate does not carry, a serial number wider than the 160 bits a conforming consumer must accommodate, and a token stating no accuracy. The tsa row resolves which certificate verifies the token from the SignerInfo's own identifier, and passes over a token that leaves that certificate to be supplied out of band.
Verifying the signature, resolving the TSA certificate and comparing the imprint against content are pki.tsp.verify's.
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 key = await pki.key.export(pair.privateKey);
var cert = await pki.x509.sign({ subject: "Example TSA", subjectPublicKey: await pki.key.export(pair.publicKey),
notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z"),
extensions: { subjectKeyIdentifier: true, keyUsage: ["digitalSignature"],
extendedKeyUsage: ["timeStamping"], extendedKeyUsageCritical: true } }, { key: key });
var imprint = { hashAlgorithm: "sha256", hashedMessage: Buffer.alloc(32, 7) };
var token = await pki.tsp.sign(imprint, { cert: cert, key: key }, { policy: "1.2.3", serialNumber: 1 });
pki.lint.tsp(token).worst; // -> "notice", since the token states no accuracy
}
example();
References
- spec RFC 3161
pki.lint.attrcert
pki.lint.attrcert(pem | der, opts?) -> LintReport
Lint an X.509 attribute certificate against the RFC 5755 profile. input is a PEM string (ATTRIBUTE CERTIFICATE) or a DER Buffer. It returns the same LintReport shape pki.lint.certificate returns and never throws on the data path: bytes that are not a well-formed attribute certificate produce a single fatal finding lint/unparseable carrying the strict parser's own code.
Section 4.2's structural requirements are already refused at parse and so carry no rows: the version being v2, the issuer being a v2Form carrying one non-empty directoryName with no baseCertificateID or objectDigestInfo beside it, a serial that is not a positive integer or runs past 20 octets, a repeated attribute type, an empty attribute sequence, a GeneralizedTime carrying no seconds or a fractional part, and a Holder baseCertificateID whose issuer is anything other than one non-empty directoryName, which section 4.2.2 requires in the same words section 4.2.3 uses for the issuer.
The rows are the clauses left, which are the ones these bytes cannot settle alone or that state a recommendation rather than a requirement: the criticality section 4.3 fixes for each extension it defines and the ProxyInfo of section 7.2, a critical extension the profile does not define, a Holder naming more than one of its three options, a Holder entityName carrying more than one name, a Holder baseCertificateID issuerUID, and an issuerUniqueID. The last two are permitted only where another certificate carries the matching field, so each is a warning naming that condition. Section 4.2.2's remaining requirements are about the public-key certificate the Holder names rather than about this one, so no row can answer them.
Verifying the signature and resolving the holder against a public-key certificate are pki.attrcert.verify's.
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 key = await pki.key.export(pair.privateKey);
var der = await pki.attrcert.sign({ holder: { entityName: { directoryName: "CN=Alice" } },
notBeforeTime: new Date("2026-01-01T00:00:00Z"), notAfterTime: new Date("2027-01-01T00:00:00Z"),
attributes: { role: { roleName: { uniformResourceIdentifier: "urn:role:admin" } } } },
{ name: "CN=Example AA", publicKey: await pki.key.export(pair.publicKey), key: key });
pki.lint.attrcert(der).findings.length; // -> 0 for a conforming attribute certificate
}
example();
References
- spec RFC 5755
pki.lint.rules
pki.lint.rules(profile?, artifact?) -> [{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. Each id appears once, including where two artifacts run the same rule.
artifact says which verb's reading of a profile name to return, and is needed because "cabf-tls" names a set for both pki.lint.certificate and pki.lint.csr and the two are not the same rows: a request has no extKeyUsage and no validity period, and carries structural rows a certificate does not. It is "certificate", "csr", "crl" or "ocsp", and defaults to the artifact that first claims the name, which is the certificate for "cabf-tls".
Example
pki.lint.rules("rfc5280").length; // -> a positive count
pki.lint.rules("cabf-tls", "csr").length; // -> the rows pki.lint.csr runs under it
References
- spec RFC 5280
- spec
CA/Browser Forum TLS BR
pki.lint.profiles
pki.lint.profiles() -> [string]
List the known lint-profile names. The two names every verb reads as naming no profile, "all" and "default", are not among them.
Example
pki.lint.profiles();
// -> ["rfc5280", "rfc9881", "rfc9909", "rfc9935", "cabf-tls", "cabf-smime", "cabf-cs",
// "etsi-qc", "mozilla-root", "chrome-root", "microsoft-root", "apple-root",
// "nist-pqc-transition", "cnsa-2.0", "rfc2986", "rfc5280-crl", "rfc6960", "rfc5019",
// "rfc5652", "rfc3161", "rfc5755"]