@blamejs/pki 0.2.9 → 0.2.11

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,26 @@ 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.2.11 — 2026-07-13
8
+
9
+ The pki command-line tool gains inspect, lint, convert, and verify -- front-ends over the certificate inspector, the linter, the PEM codecs, and RFC 5280 path validation.
10
+
11
+ ### Added
12
+
13
+ - pki inspect <cert> renders a certificate as an openssl x509 -text style report (composes pki.inspect.certificate).
14
+ - pki lint <cert> [--profile <name>] [--severity <floor>] [--json] lints a certificate against the RFC 5280 and CABF TLS profiles, exiting non-zero when an error or fatal finding is present (composes pki.lint.certificate).
15
+ - pki convert <file> --to der|pem [--label <label>] transcodes between DER and PEM with auto-detected input encoding and byte-exact round-tripping.
16
+ - pki verify <cert>... --anchor <cert> [--time <ISO>] validates an ordered certification path against a trust anchor per RFC 5280 section 6.1 (composes pki.path.validate), exiting non-zero and naming the failing check on rejection.
17
+
18
+ ## v0.2.10 — 2026-07-13
19
+
20
+ Certificate linting arrives as pki.lint -- graded, advisory conformance findings against the RFC 5280 profile and a representative CA/Browser Forum TLS Baseline Requirements subset.
21
+
22
+ ### Added
23
+
24
+ - pki.lint.certificate(input, opts) lints a certificate against the RFC 5280 profile and a representative CA/Browser Forum TLS BR subset, returning a report of graded advisory findings (id, severity, source, spec citation, message). The data path never throws -- malformed input becomes a fatal lint/unparseable finding rather than an exception -- so a corpus lints without per-file error handling; only config-time misuse raises a typed LintError.
25
+ - pki.lint.rules(profile) and pki.lint.profiles() enumerate the rule registry and the available profiles (rfc5280, cabf-tls) so findings are traceable to a stable id and a spec clause.
26
+
7
27
  ## v0.2.9 — 2026-07-13
8
28
 
9
29
  Certification-path validation verifies composite ML-DSA signatures, accepting a certificate only when both its post-quantum and traditional components verify.
package/README.md CHANGED
@@ -229,9 +229,10 @@ is callable today; nothing below is a stub.
229
229
  | `pki.sigstore` | Offline verifier for a Sigstore bundle — the exact artifact `npm publish --provenance` produces and the registry serves. `verifyBundle` composes five fail-closed legs against caller-pinned trust (Fulcio CA roots + 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. Zero runtime deps — reuses the X.509 parser, RFC 5280 path validator, and Merkle verifier; the net-new codecs are the DSSE PAE byte-builder and a fail-closed JSON reader. `pae`, `parseBundle`, `verifyBundle` |
230
230
  | `pki.inspect` | Human-readable certificate inspection — the pure-JS equivalent of `openssl x509 -text`. `certificate(pem \| der \| parsed)` renders a familiar OpenSSL-style report: version, serial, signature algorithm, issuer/subject distinguished names, validity, public-key details (curve or modulus size + the raw point/modulus), every decoded extension with its critical flag, and the signature. Built over the strict parser and the two-way OID registry, so it names extension and algorithm OIDs an OpenSSL build shows only as raw bytes. No OpenSSL dependency; the format is stable and OpenSSL-familiar rather than pinned to one OpenSSL version; a malformed extension falls back to a hex dump rather than throwing — `certificate` |
231
231
  | `pki.webauthn` | WebAuthn / passkey attestation verification — offline trust evaluation of a W3C WebAuthn (Level 3) attestation. `parseAttestationObject(bytes)` decodes the CBOR attestation object + authenticatorData + COSE credential key over the strict `pki.cbor` codec; `verify(attestationObject, clientDataHash, opts)` checks the attestation-statement signature and each format's structural bindings for **packed / tpm / android-key / apple / fido-u2f / none** — the x5c leaf key, the apple nonce, the tpm `certInfo` Name/`extraData` over the `pubArea`, the android `KeyDescription`, the fido-u2f `verificationData` — binding the credential public key to each attestation (via the signed authenticatorData for packed/fido-u2f, or a cert/`pubArea`-key equality check for android-key/apple/tpm) and enforcing each leaf's certificate requirements. The credential-key check covers the full WebAuthn COSE algorithm set — ES256/384/512, RS256/384/512, PS256, 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. A verifier, not a ceremony client; fail-closed with typed `webauthn/*` errors. Chaining the returned trust path to a pinned root (and aaguid→root via FIDO MDS) is the caller's step through `pki.path.validate` — `parseAttestationObject`, `verify` |
232
+ | `pki.lint` | Certificate linting — the zlint / 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 plus a representative CA/Browser Forum TLS BR subset (serial sign/size, validity ordering + the the SC081v3 reducing validity schedule, keyCertSign coherence, unknown critical extensions, empty-subject SAN, SKI/AKI presence, SAN required + CN-in-SAN, dNSName syntax, serverAuth EKU, weak keys). Unlike every other entry the DATA path never throws: hostile bytes return a `fatal` `lint/unparseable` finding (with the strict parser's code) so a whole directory lints without a try/catch; only config-time misuse throws a typed `LintError`. `certificate`, `rules`, `profiles` |
232
233
  | `pki.C` / `pki.constants` | Version-stable constants — functional scale helpers (`C.TIME.*`, `C.BYTES.*`), codec `LIMITS`, `version` |
233
- | `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`, each carrying a stable `code` in `domain/reason` form |
234
- | `pki` CLI | `pki version`, `pki oid <dotted\|name>`, `pki parse <cert>` |
234
+ | `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` / `LintError`, each carrying a stable `code` in `domain/reason` form |
235
+ | `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>` |
235
236
 
236
237
  ### CLI
237
238
 
@@ -240,8 +241,18 @@ pki version # @blamejs/pki v0.1.0
240
241
  pki oid 1.2.840.113549.1.1.11 # sha256WithRSAEncryption
241
242
  pki oid sha256 # 2.16.840.1.101.3.4.2.1
242
243
  pki parse cert.pem # structured JSON summary of a certificate
244
+ pki inspect cert.pem # openssl x509 -text style report (pki.inspect)
245
+ pki lint cert.pem # graded conformance findings; exit 1 on an error
246
+ pki lint cert.pem --json --profile cabf-tls
247
+ pki convert cert.pem --to der > cert.der # transcode between PEM and DER (round-trips)
248
+ pki verify leaf.pem --anchor root.pem --time 2026-01-01T00:00:00Z # RFC 5280 path validation
243
249
  ```
244
250
 
251
+ `inspect`/`lint`/`convert`/`verify` are thin front-ends over `pki.inspect`, `pki.lint`, the
252
+ per-format PEM codecs, and `pki.path.validate` — the CLI never does anything the library
253
+ API can't. `lint` exits non-zero when any `error`/`fatal` finding is present; `verify` exits
254
+ non-zero when the path does not validate.
255
+
245
256
  ### What's coming
246
257
 
247
258
  Certificate build/sign/verify, CMS AuthenticatedData /
package/bin/pki.js CHANGED
@@ -5,14 +5,21 @@
5
5
  /**
6
6
  * pki — command-line front-end for @blamejs/pki.
7
7
  *
8
- * pki version
9
- * pki oid <dotted|name> resolve an OID <-> name
10
- * pki parse <cert.pem|cert.der> parse an X.509 certificate and print
11
- * its fields as JSON
8
+ * pki version print the package version
9
+ * pki oid <dotted|name> resolve an OID <-> name
10
+ * pki parse <cert> parse an X.509 certificate to JSON
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
13
+ * [--severity S] [--json]
14
+ * pki convert <file> --to der|pem transcode a DER/PEM file between the two encodings
15
+ * [--label LABEL]
16
+ * pki verify <cert>... --anchor <cert> validate an ordered certification path (anchor->target)
17
+ * [--time ISO]
12
18
  *
13
- * The CLI is a thin operator convenience over the library surface; it
14
- * validates its arguments (entry-point tier — bad input exits non-zero
15
- * with a message) and never does anything the public API can't.
19
+ * The CLI is a thin operator convenience over the library surface: it validates its
20
+ * arguments (entry-point tier — bad input exits non-zero with a message) and never does
21
+ * anything the public API cannot. inspect / lint / convert / verify compose pki.inspect,
22
+ * pki.lint, the per-format PEM codecs, and pki.path.validate respectively.
16
23
  */
17
24
 
18
25
  var fs = require("node:fs");
@@ -23,6 +30,65 @@ function fail(msg) {
23
30
  process.exit(1);
24
31
  }
25
32
 
33
+ // Minimal flag parser: `--flag value` for value-taking flags, `--flag` for booleans, the
34
+ // rest are positionals in `_`. A value-taking flag whose value is absent or is itself another
35
+ // flag is a usage error (never silently coerced to `true`, which would make e.g. `--time`
36
+ // parse as `new Date(true)` = a real 1970 timestamp). No clustering, no `=value`.
37
+ var VALUE_FLAGS = { to: 1, profile: 1, severity: 1, label: 1, anchor: 1, time: 1 };
38
+ function parseArgs(argv) {
39
+ var out = { _: [] };
40
+ for (var i = 0; i < argv.length; i++) {
41
+ var a = argv[i];
42
+ if (a.indexOf("--") === 0) {
43
+ var key = a.slice(2);
44
+ if (VALUE_FLAGS[key]) {
45
+ if (i + 1 >= argv.length || argv[i + 1].indexOf("--") === 0) fail("--" + key + " requires a value");
46
+ out[key] = argv[++i];
47
+ } else { out[key] = true; }
48
+ } else { out._.push(a); }
49
+ }
50
+ return out;
51
+ }
52
+
53
+ function readFileBytes(file) {
54
+ try { return fs.readFileSync(file); } catch (e) { return fail("cannot read " + file + ": " + e.message); }
55
+ }
56
+
57
+ // For the library entry points (parse / inspect / lint / verify) that accept EITHER a DER
58
+ // Buffer or a PEM string and own the decode + error handling: hand a Buffer when the bytes
59
+ // are a well-formed DER structure, otherwise the text so the library pemDecodes it (and
60
+ // applies its own canonical-base64 policy). This defers ALL error handling to the library,
61
+ // which is what preserves the linter's never-throw survey -- malformed bytes become a fatal
62
+ // lint/unparseable finding rather than a CLI hard-fail. DER-first is unambiguous (a PEM file
63
+ // is ASCII text and never decodes as one DER TLV).
64
+ function readForLib(file) {
65
+ var bytes = readFileBytes(file);
66
+ try { pki.asn1.decode(bytes); return bytes; }
67
+ catch (_derErr) { return bytes.toString("latin1"); } // not DER -- let the library pemDecode / report it
68
+ }
69
+
70
+ // For `convert`, which transcodes RAW bytes and bypasses the library parse: extract DER
71
+ // explicitly (a well-formed DER file as-is, or a canonical PEM body), failing on anything
72
+ // else. Returns { der, label } where `label` is the PEM armor when the input was PEM.
73
+ function readDer(file) {
74
+ var bytes = readFileBytes(file);
75
+ try { pki.asn1.decode(bytes); return { der: bytes, label: null }; }
76
+ catch (_derErr) { /* not a single well-formed DER structure -- try PEM */ }
77
+ // Match the library's PEM grammar exactly (an uppercase A-Z0-9 label, and ONLY CR/LF/TAB/
78
+ // space ignored in the body -- not every JS whitespace), so convert is not a looser
79
+ // validation path than the codecs it composes.
80
+ var m = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/.exec(bytes.toString("latin1"));
81
+ if (!m) return fail(file + ": input is neither a well-formed DER structure nor a PEM block");
82
+ var b64 = m[2].replace(/[\r\n\t ]+/g, "");
83
+ // Enforce CANONICAL base64 (RFC 4648 sec. 3.5), matching the library's fail-closed PEM
84
+ // policy: Node's decoder silently drops invalid characters and tolerates non-canonical
85
+ // trailing pad bits. Gate alphabet/length, then require that re-encoding reproduces the body.
86
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(b64) || b64.length % 4 !== 0) return fail(file + ": malformed PEM base64");
87
+ var der = Buffer.from(b64, "base64");
88
+ if (der.toString("base64") !== b64) return fail(file + ": non-canonical PEM base64");
89
+ return { der: der, label: m[1] };
90
+ }
91
+
26
92
  function cmdVersion() {
27
93
  process.stdout.write("@blamejs/pki v" + pki.version + "\n");
28
94
  }
@@ -41,10 +107,8 @@ function cmdOid(arg) {
41
107
 
42
108
  function cmdParse(file) {
43
109
  if (!file) fail("usage: pki parse <cert.pem|cert.der>");
44
- var bytes;
45
- try { bytes = fs.readFileSync(file); } catch (e) { return fail("cannot read " + file + ": " + e.message); }
46
110
  var cert;
47
- try { cert = pki.schema.x509.parse(bytes); } catch (e) { return fail(e.code + ": " + e.message); }
111
+ try { cert = pki.schema.x509.parse(readForLib(file)); } catch (e) { return fail(e.code + ": " + e.message); }
48
112
  var view = {
49
113
  version: cert.version,
50
114
  serialNumber: cert.serialNumberHex,
@@ -59,19 +123,101 @@ function cmdParse(file) {
59
123
  process.stdout.write(JSON.stringify(view, null, 2) + "\n");
60
124
  }
61
125
 
126
+ // pki inspect <cert> -- the human-readable certificate render (pki.inspect.certificate),
127
+ // the pure-JS equivalent of `openssl x509 -text`.
128
+ function cmdInspect(file) {
129
+ if (!file) fail("usage: pki inspect <cert.pem|cert.der>");
130
+ try { process.stdout.write(pki.inspect.certificate(readForLib(file))); }
131
+ catch (e) { return fail(e.code + ": " + e.message); }
132
+ }
133
+
134
+ function pad(s, n) { while (s.length < n) s += " "; return s; }
135
+
136
+ // pki lint <cert> -- lint against pki.lint's profiles. Prints one line per finding and
137
+ // exits non-zero when any error/fatal finding is present (0 when the worst is advisory).
138
+ function cmdLint(args) {
139
+ var file = args._[0];
140
+ if (!file) fail("usage: pki lint <cert> [--profile <name>] [--severity <floor>] [--json]");
141
+ var report;
142
+ // Config-time misuse (unknown profile / bad severity) throws a typed LintError; the data
143
+ // path never throws (malformed bytes become a fatal lint/unparseable finding).
144
+ try { report = pki.lint.certificate(readForLib(file), { profile: args.profile, severity: args.severity }); }
145
+ catch (e) { return fail(e.code + ": " + e.message); }
146
+ if (args.json) {
147
+ process.stdout.write(JSON.stringify(report, null, 2) + "\n");
148
+ } else {
149
+ report.findings.forEach(function (f) {
150
+ process.stdout.write(pad(f.severity.toUpperCase(), 7) + " " + f.id + " -- " + f.message + "\n");
151
+ });
152
+ process.stdout.write("\n" + (report.findings.length || "no") + " finding(s); worst: " + (report.worst || "pass") + "\n");
153
+ }
154
+ // Set the exit CODE and let Node drain — process.exit() can truncate a buffered stdout
155
+ // write to a pipe before it flushes.
156
+ process.exitCode = (report.counts.error || report.counts.fatal) ? 1 : 0;
157
+ }
158
+
159
+ // pki convert <file> --to der|pem -- transcode between DER and PEM. The input encoding is
160
+ // auto-detected; the bytes must be well-formed DER (we never wrap/emit garbage).
161
+ function cmdConvert(args) {
162
+ var file = args._[0], to = args.to;
163
+ if (!file) fail("usage: pki convert <file> --to der|pem [--label LABEL]");
164
+ if (to !== "der" && to !== "pem") fail("convert: --to must be 'der' or 'pem'");
165
+ var input = readDer(file);
166
+ try { pki.asn1.decode(input.der); } catch (e) { return fail("input is not well-formed DER: " + (e.code || e.message)); }
167
+ if (to === "der") { process.stdout.write(input.der); return; }
168
+ var label = args.label || input.label || "CERTIFICATE";
169
+ // The armor label must be re-readable by the library's PEM grammar (uppercase A-Z0-9 words,
170
+ // single spaces) -- reject a lowercase/invalid label rather than emit an unparseable file.
171
+ if (!/^[A-Z0-9]+( [A-Z0-9]+)*$/.test(label)) return fail("convert: --label must be an uppercase A-Z0-9 label with single spaces (RFC 7468)");
172
+ var b64 = input.der.toString("base64").replace(/(.{1,64})/g, "$1\n");
173
+ process.stdout.write("-----BEGIN " + label + "-----\n" + b64 + "-----END " + label + "-----\n");
174
+ }
175
+
176
+ // pki verify <cert>... --anchor <cert> -- validate an ordered certification path
177
+ // (anchor->target) against a trust anchor via pki.path.validate (RFC 5280 sec. 6.1).
178
+ function cmdVerify(args) {
179
+ var certFiles = args._, anchorFile = args.anchor;
180
+ if (!certFiles.length || !anchorFile) fail("usage: pki verify <cert>... --anchor <anchor-cert> [--time ISO]");
181
+ var certs, anchor;
182
+ try { certs = certFiles.map(function (f) { return pki.schema.x509.parse(readForLib(f)); }); }
183
+ catch (e) { return fail("cannot parse a path certificate: " + (e.code || e.message)); }
184
+ try { anchor = pki.schema.x509.parse(readForLib(anchorFile)); }
185
+ catch (e2) { return fail("cannot parse the anchor certificate: " + (e2.code || e2.message)); }
186
+ var time = args.time ? new Date(args.time) : new Date();
187
+ if (isNaN(time.getTime())) fail("verify: --time must be an ISO-8601 date");
188
+ var spki = anchor.subjectPublicKeyInfo;
189
+ return pki.path.validate(certs, {
190
+ time: time,
191
+ trustAnchor: { name: anchor.subject, publicKey: spki.bytes, algorithm: spki.algorithm.oid, parameters: spki.algorithm.parameters },
192
+ }).then(function (res) {
193
+ process.stdout.write(res.valid ? "valid\n" : "invalid\n");
194
+ if (!res.valid) {
195
+ (res.results || []).forEach(function (r, i) {
196
+ (r.checks || []).forEach(function (c) { if (c.ok === false) process.stdout.write(" cert[" + i + "] " + c.code + "\n"); });
197
+ });
198
+ }
199
+ process.exitCode = res.valid ? 0 : 1; // let Node flush stdout before it exits
200
+ }, function (e) { return fail(e.code + ": " + e.message); });
201
+ }
202
+
203
+ var USAGE = "usage: pki <version|oid|parse|inspect|lint|convert|verify> [args]\n";
204
+
62
205
  function main(argv) {
63
206
  var cmd = argv[0];
64
207
  switch (cmd) {
65
208
  case "version": case "--version": case "-v": return cmdVersion();
66
209
  case "oid": return cmdOid(argv[1]);
67
210
  case "parse": return cmdParse(argv[1]);
211
+ case "inspect": return cmdInspect(argv[1]);
212
+ case "lint": return cmdLint(parseArgs(argv.slice(1)));
213
+ case "convert": return cmdConvert(parseArgs(argv.slice(1)));
214
+ case "verify": return cmdVerify(parseArgs(argv.slice(1)));
68
215
  case undefined: case "help": case "--help": case "-h":
69
- process.stdout.write("usage: pki <version|oid|parse> [args]\n");
216
+ process.stdout.write(USAGE);
70
217
  return;
71
218
  default:
72
219
  return fail("unknown command: " + cmd);
73
220
  }
74
221
  }
75
222
 
76
- main(process.argv.slice(2));
77
-
223
+ Promise.resolve(main(process.argv.slice(2))).catch(function (e) { fail(e && (e.stack || e.message) || String(e)); });
package/index.js CHANGED
@@ -45,6 +45,7 @@ var jose = require("./lib/jose");
45
45
  var acme = require("./lib/acme");
46
46
  var trust = require("./lib/trust");
47
47
  var inspect = require("./lib/inspect");
48
+ var lint = require("./lib/lint");
48
49
  var webauthn = require("./lib/webauthn");
49
50
 
50
51
  module.exports = {
@@ -117,6 +118,7 @@ module.exports = {
117
118
  // OID registry, naming extension/algorithm OIDs OpenSSL shows only as raw bytes.
118
119
  // Pure, no OpenSSL dependency; best-effort (a bad extension falls back to hex).
119
120
  inspect: inspect,
121
+ lint: lint,
120
122
  // `webauthn` verifies a W3C WebAuthn / passkey attestation -- pki.webauthn.verify
121
123
  // checks the attestation-statement signature + each format's structural bindings
122
124
  // (packed / tpm / android-key / apple / fido-u2f / none) and surfaces the x5c chain
@@ -303,6 +303,13 @@ var InspectError = defineClass("InspectError", { withCause: true });
303
303
  // its bounded layout, an attestation-statement signature that does not verify, or a
304
304
  // trust path that does not reach the caller's anchor. Fail-closed, typed reason.
305
305
  var WebauthnError = defineClass("WebauthnError", { withCause: true });
306
+ // LintError -- CONFIG-time misuse of the certificate linter (pki.lint) ONLY: an
307
+ // unknown profile name, an out-of-range severity threshold, or an input that is
308
+ // neither a parsed structure, DER Buffer, nor PEM string. This is the lint engine's
309
+ // SOLE throw path: unlike every other toolkit entry, the lint DATA path never throws
310
+ // on malformed input -- a linter surveys a corpus that includes malformed members,
311
+ // so hostile bytes surface a `fatal` finding (lint/unparseable) and return a report.
312
+ var LintError = defineClass("LintError", { withCause: true });
306
313
 
307
314
  module.exports = {
308
315
  PkiError: PkiError,
@@ -331,6 +338,7 @@ module.exports = {
331
338
  SigstoreError: SigstoreError,
332
339
  InspectError: InspectError,
333
340
  WebauthnError: WebauthnError,
341
+ LintError: LintError,
334
342
  MerkleError: MerkleError,
335
343
  SmimeError: SmimeError,
336
344
  CsrattrsError: CsrattrsError,
package/lib/lint.js ADDED
@@ -0,0 +1,567 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * @module pki.lint
6
+ * @nav Tooling
7
+ * @title Lint
8
+ * @intro The certificate LINTING engine -- the zlint / pkilint of JavaScript. It walks an
9
+ * ALREADY-PARSED certificate (from `pki.schema.x509.parse`, whose extension values it
10
+ * decodes with the shared RFC 5280 decoders) and emits graded, advisory FINDINGS: each
11
+ * with a stable id, a severity (`fatal` > `error` > `warn` > `notice` > `pass`), a source,
12
+ * a spec-clause citation, and a human message. It ships the RFC 5280 certificate profile
13
+ * plus a representative CA/Browser Forum TLS Baseline Requirements subset.
14
+ *
15
+ * Unlike every other toolkit entry, the lint DATA path NEVER throws. A linter surveys a
16
+ * corpus that includes malformed members, so `pki.lint.certificate(hostileBytes)` returns
17
+ * a report whose worst finding is a `fatal` id `lint/unparseable` (carrying the inner
18
+ * `PkiError.code`) rather than raising. The SOLE throw path is CONFIG-time misuse -- an
19
+ * unknown profile, an out-of-range severity threshold, or a wrong-type input -- which
20
+ * raises a typed `LintError`. This deliberate inversion of the toolkit's fail-closed-throw
21
+ * posture is what lets an operator lint a whole directory without a try/catch per file.
22
+ * @spec RFC 5280, CA/Browser Forum TLS Baseline Requirements
23
+ * @card Lint a certificate against RFC 5280 + CABF TLS BR, in pure JS.
24
+ */
25
+
26
+ var net = require("net");
27
+ var frameworkError = require("./framework-error");
28
+ var asn1 = require("./asn1-der");
29
+ var oid = require("./oid");
30
+ var x509 = require("./schema-x509");
31
+ var pkix = require("./schema-pkix");
32
+ var C = require("./constants");
33
+
34
+ var MS_PER_DAY = C.TIME.days(1);
35
+
36
+ var LintError = frameworkError.LintError;
37
+ function _cfg(code, message, cause) { return new LintError(code, message, cause); }
38
+
39
+ // Strict-parse pre-emption: this toolkit's parser is strict-DER, so several RFC 5280
40
+ // profile violations a lenient linter (zlint/pkilint) would report as a SPECIFIC finding
41
+ // are instead rejected fail-closed at PARSE and surface here as a single `fatal`
42
+ // lint/unparseable whose context.code names the exact structural violation -- e.g. a
43
+ // duplicate extension OID (x509/duplicate-extension), a pathLenConstraint without cA
44
+ // (bad-basic-constraints), an explicit cA=FALSE, or a signatureAlgorithm/tbs mismatch.
45
+ // The registry therefore ships no dedicated rule for a violation the parser already
46
+ // rejects (a dead rule that could never fire); the specific reason is preserved in the
47
+ // unparseable finding's context.
48
+ //
49
+ // The shared RFC 5280 extension decoders, composed exactly as path-validate / inspect
50
+ // compose them -- the linter owns no second decoder table.
51
+ var NS = pkix.makeNS("lint", LintError, oid);
52
+ var EXT_DECODERS = pkix.certExtensionDecoders(NS).byOid;
53
+
54
+ // Coverage note: the rules and helpers below carry defensive guards -- `|| []` / `|| ""`
55
+ // on fields the strict parser ALWAYS sets (extensions, serialNumberHex, subject.rdns), and
56
+ // `!Buffer.isBuffer` / `instanceof Date` / minimal-encoding checks against malformed-but-
57
+ // parsed shapes the strict parser never produces. These are fail-safe belts, not reachable
58
+ // paths; they stay verified-unreachable (documented) rather than removed, since the lint
59
+ // data path must never throw even on an unexpected upstream shape.
60
+
61
+ // ---- the advisory result surface (the Do-FIRST: graded findings, not a throw) ----
62
+
63
+ // Severity ordering (zlint's LintStatus). NA (not applicable) and NE (not effective --
64
+ // outside a rule's date window) are rule OUTCOMES, tracked in counts, not severities.
65
+ var SEVERITY = { fatal: 5, error: 4, warn: 3, notice: 2, pass: 1 };
66
+ var VALID_SEVERITY = Object.keys(SEVERITY);
67
+
68
+ function _worst(findings) {
69
+ var w = null, wv = 0;
70
+ findings.forEach(function (f) { var v = SEVERITY[f.severity] || 0; if (v > wv) { wv = v; w = f.severity; } });
71
+ return w;
72
+ }
73
+
74
+ // A profile groups rules by source; a rule is `{ id, severity, source, citation,
75
+ // appliesTo?, effectiveDate?, check }`. `check(cert, ctx)` returns null (pass) or a detail
76
+ // / array of details, each becoming one finding under the rule. A detail carries only an
77
+ // optional `context` object; the human message is the rule's.
78
+ function _finding(rule, detail) {
79
+ var f = { id: rule.id, severity: rule.severity, source: rule.source, citation: rule.citation, message: rule.message };
80
+ if (detail && detail.context) f.context = detail.context;
81
+ return f;
82
+ }
83
+
84
+ // ---- ingestion: bytes/PEM/parsed -> parsed, or a fatal lint/unparseable finding ----
85
+
86
+ function _looksParsed(o) {
87
+ return o && typeof o === "object" && !Buffer.isBuffer(o) &&
88
+ Buffer.isBuffer(o.tbsBytes) && o.validity && o.subjectPublicKeyInfo && Array.isArray(o.extensions);
89
+ }
90
+
91
+ // Returns { cert } on success, or { fatal: <Finding> } when hostile bytes do not parse
92
+ // (the never-throw data path). Throws LintError ONLY on a wrong-TYPE input (config misuse).
93
+ function _ingest(input) {
94
+ if (_looksParsed(input)) return { cert: input };
95
+ var der;
96
+ if (Buffer.isBuffer(input)) der = input;
97
+ else if (typeof input === "string") {
98
+ try { der = x509.pemDecode(input, "CERTIFICATE"); }
99
+ catch (e) { return { fatal: { id: "lint/unparseable", severity: "fatal", source: "engine", citation: "pki.lint", message: "input is not a decodable PEM/DER certificate", context: { code: e.code } } }; }
100
+ } else {
101
+ throw _cfg("lint/bad-input", "pki.lint input must be a parsed certificate, a DER Buffer, or a PEM string");
102
+ }
103
+ try { return { cert: x509.parse(der) }; }
104
+ catch (e) { return { fatal: { id: "lint/unparseable", severity: "fatal", source: "engine", citation: "RFC 5280", message: "input is not a well-formed X.509 certificate", context: { code: e.code } } }; }
105
+ }
106
+
107
+ // ---- per-cert context the rules read ----
108
+
109
+ function _findRaw(cert, name) {
110
+ var dotted = oid.byName(name);
111
+ var list = cert.extensions || [];
112
+ for (var i = 0; i < list.length; i++) { if (list[i].oid === dotted) return list[i]; }
113
+ return null;
114
+ }
115
+ // Decode a known extension to { critical, value }, or null when absent OR undecodable.
116
+ // The undecodable case is surfaced by the dedicated `extension-undecodable` rule; every
117
+ // other rule treats an undecodable extension as "not usable" and simply does not fire.
118
+ // allow:swallow-unverified the decode fault is reported by the extension-undecodable rule
119
+ function _decodeOrNull(cert, name) {
120
+ var ext = _findRaw(cert, name);
121
+ if (!ext) return null;
122
+ var dec = EXT_DECODERS[oid.byName(name)];
123
+ if (!dec) return null;
124
+ try { return { critical: ext.critical, value: dec(ext.value) }; }
125
+ catch (_e) { return null; }
126
+ }
127
+
128
+ function _hasEku(cert, ekuName) {
129
+ var d = _decodeOrNull(cert, "extKeyUsage");
130
+ return !!(d && Array.isArray(d.value) && d.value.indexOf(oid.byName(ekuName)) !== -1);
131
+ }
132
+
133
+ function _makeCtx(cert, profile) {
134
+ var explicitTls = profile === "cabf-tls";
135
+ // A CA certificate is not a TLS server (leaf) certificate even when it carries a
136
+ // serverAuth EKU, so the default profile must NOT apply the CABF leaf rules (SAN
137
+ // required, CN-in-SAN, validity ceiling, ...) to it. An explicit cabf-tls selection
138
+ // still lints whatever the caller hands it as a server cert.
139
+ var bc = _decodeOrNull(cert, "basicConstraints");
140
+ var isCa = !!(bc && bc.value && bc.value.cA === true);
141
+ return {
142
+ profile: profile,
143
+ explicitTlsProfile: explicitTls,
144
+ isTlsServerCert: explicitTls || (_hasEku(cert, "serverAuth") && !isCa),
145
+ decode: function (name) { return _decodeOrNull(cert, name); },
146
+ raw: function (name) { return _findRaw(cert, name); },
147
+ };
148
+ }
149
+
150
+ // ---- the runner ----
151
+
152
+ // A date-gated rule is effective for a cert only when the cert's notBefore is at or after
153
+ // the rule's effectiveDate; a cert issued before it reports NE (not effective, never fires).
154
+ // This encodes a scheduled requirement (the CABF validity-day ceiling) against issuance date.
155
+ function _effective(rule, cert) {
156
+ if (!rule.effectiveDate) return true;
157
+ var nb = cert.validity && cert.validity.notBefore;
158
+ return (nb instanceof Date) && nb.getTime() >= rule.effectiveDate.getTime();
159
+ }
160
+
161
+ function _runLints(rules, cert, ctx) {
162
+ var findings = [], counts = { fatal: 0, error: 0, warn: 0, notice: 0, pass: 0, na: 0, ne: 0 }, ran = [];
163
+ rules.forEach(function (rule) {
164
+ if (rule.appliesTo && !rule.appliesTo(cert, ctx)) { counts.na++; return; }
165
+ if (!_effective(rule, cert)) { counts.ne++; return; }
166
+ ran.push(rule.id);
167
+ var res = rule.check(cert, ctx);
168
+ if (res == null || res === false) { counts.pass++; return; }
169
+ var details = Array.isArray(res) ? res : [res];
170
+ details.forEach(function (d) {
171
+ var f = _finding(rule, d === true ? null : d);
172
+ findings.push(f);
173
+ counts[f.severity] = (counts[f.severity] || 0) + 1;
174
+ });
175
+ });
176
+ return { findings: findings, counts: counts, ran: ran };
177
+ }
178
+
179
+ // ---- rule registry ----
180
+
181
+ function _serialOctets(cert) {
182
+ var hex = cert.serialNumberHex || "";
183
+ if (hex.length % 2) hex = "0" + hex;
184
+ var buf = Buffer.from(hex, "hex");
185
+ // Strip a single DER positive-sign 00 (present only when the value's high bit is set);
186
+ // the octet COUNT the profile bounds is the value's, not the encoding's leading pad.
187
+ if (buf.length > 1 && buf[0] === 0x00 && (buf[1] & 0x80)) buf = buf.subarray(1);
188
+ return buf.length;
189
+ }
190
+
191
+ // dNSName syntax (a representative CABF check): no whitespace, no leading/trailing dot,
192
+ // no empty label, and no underscore (forbidden in a dNSName). IDN must already be an
193
+ // A-label (we do not transcode). Returns a reason string, or null when well-formed.
194
+ function _dnsNameProblem(s) {
195
+ if (typeof s !== "string" || !s.length) return "empty";
196
+ if (s.length > 253) return "exceeds 253 octets";
197
+ if (/\s/.test(s)) return "whitespace";
198
+ if (s.charAt(0) === "." || s.charAt(s.length - 1) === ".") return "leading/trailing dot";
199
+ if (s.indexOf("_") !== -1) return "underscore forbidden in dNSName";
200
+ var labels = s.split(".");
201
+ for (var i = 0; i < labels.length; i++) {
202
+ var label = labels[i];
203
+ if (label.length === 0) return "empty label";
204
+ if (label.length > 63) return "label exceeds 63 octets";
205
+ // A leftmost "*" wildcard label is permitted only when at least one more label follows
206
+ // (a bare "*" is not a domain name).
207
+ if (i === 0 && label === "*") {
208
+ if (labels.length < 2) return "bare wildcard";
209
+ continue;
210
+ }
211
+ // RFC 1034 preferred name syntax: an LDH label that neither begins nor ends with a
212
+ // hyphen. Rejects "-bad" / "bad-" and any non-letter/digit/hyphen character.
213
+ if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i.test(label)) return "invalid label syntax";
214
+ }
215
+ return null;
216
+ }
217
+
218
+ // A genuine IPv4 or IPv6 literal (validated by net.isIP, which rejects an out-of-range
219
+ // dotted quad like 999.999.999.999 or a colon-bearing non-address like "api:443") -- a CN
220
+ // validated against an iPAddress SAN rather than a dNSName.
221
+ function _looksLikeIp(s) { return typeof s === "string" && net.isIP(s) !== 0; }
222
+
223
+ function _sanDnsNames(ctx) {
224
+ var d = ctx.decode("subjectAltName");
225
+ if (!d || !d.value || !Array.isArray(d.value.names)) return [];
226
+ return d.value.names.filter(function (n) { return n && n.tagNumber === 2; }).map(function (n) { return n.value; });
227
+ }
228
+ // The subject CN string values (an RDN attribute of type commonName), if any.
229
+ function _subjectCNs(cert) {
230
+ var out = [];
231
+ ((cert.subject && cert.subject.rdns) || []).forEach(function (rdn) {
232
+ rdn.forEach(function (a) { if (a.type === oid.byName("commonName") && typeof a.value === "string") out.push(a.value); });
233
+ });
234
+ return out;
235
+ }
236
+
237
+ // An extension is "recognized" (RFC 5280 4.2: a consumer MUST reject a critical extension
238
+ // it does NOT recognize / cannot process) when the toolkit has a DECODER for it -- the
239
+ // shared certExtensionDecoders table IS the set of extensions this toolkit processes. This
240
+ // is registry-driven (no curated list to omit a legitimately-critical extension such as
241
+ // policyMappings, which the table decodes) and correctly scoped to EXTENSION OIDs: an
242
+ // algorithm / EKU-purpose OID the name registry happens to resolve is NOT in the decoder
243
+ // table, so a critical extension carrying such an OID is still flagged.
244
+ function _isUnknownExtension(extOid) { return !EXT_DECODERS[extOid]; }
245
+
246
+ // RSA-modulus / EC-curve helpers for the weak-key lint. Each returns null when the key
247
+ // material cannot be read as expected (a malformed RSA modulus, or EC parameters that are
248
+ // absent / explicit / not a named-curve OID); the weak-key rule treats null as fail-closed
249
+ // (a key it cannot confirm meets the minimum is flagged, not silently passed).
250
+ function _rsaModulusBits(spki) {
251
+ try {
252
+ var pk = spki.publicKey && (spki.publicKey.bytes || spki.publicKey);
253
+ if (!Buffer.isBuffer(pk)) return null;
254
+ var seq = asn1.decode(pk);
255
+ // RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER } -- exactly two
256
+ // INTEGERs, and the modulus MUST be positive. A negative/zero modulus (a high-bit key
257
+ // mis-encoded without a sign pad) or a missing exponent is malformed, not a real key.
258
+ if (!seq.children || seq.children.length !== 2) return null;
259
+ var mod = asn1.read.integer(seq.children[0]);
260
+ asn1.read.integer(seq.children[1]); // the exponent must be a valid INTEGER
261
+ if (mod <= 0n) return null;
262
+ return mod.toString(2).length;
263
+ } catch (_e) { return null; } // a modulus that will not decode cannot be sized
264
+ }
265
+ // Every RSA-family SPKI (rsaEncryption, RSASSA-PSS, RSAES-OAEP) carries an RSAPublicKey
266
+ // SEQUENCE, so all are weighed by modulus size; matching only "rsaEncryption" would let an
267
+ // RSA-PSS or RSAES-OAEP key skip the check.
268
+ var RSA_KEY_ALGS = { rsaEncryption: 1, rsassaPss: 1, rsaesOaep: 1 };
269
+ var APPROVED_EC_CURVES = ["prime256v1", "secp384r1", "secp521r1"]; // P-256 / P-384 / P-521
270
+ function _ecCurveName(spki) {
271
+ try {
272
+ var params = spki.algorithm && spki.algorithm.parameters;
273
+ if (!Buffer.isBuffer(params)) return null;
274
+ return oid.name(asn1.read.oid(asn1.decode(params))); // named-curve OID, or throw on explicit params
275
+ } catch (_e) { return null; } // explicit / invalid EC parameters are not an approved named curve
276
+ }
277
+
278
+ var RFC5280_RULES = [
279
+ {
280
+ id: "lint/rfc5280/serial-not-positive", severity: "error", source: "rfc5280", citation: "RFC 5280 4.1.2.2",
281
+ message: "the certificate serialNumber must be a positive integer",
282
+ check: function (cert) { return cert.serialNumber <= 0n ? true : null; },
283
+ },
284
+ {
285
+ id: "lint/rfc5280/serial-too-long", severity: "error", source: "rfc5280", citation: "RFC 5280 4.1.2.2",
286
+ message: "the certificate serialNumber must not exceed 20 octets",
287
+ // Counts the VALUE magnitude (a single DER sign-pad 0x00 stripped), not the encoded
288
+ // length: a 20-byte random serial whose high bit is set encodes as 21 octets but is a
289
+ // conformant 20-octet value, so this avoids false-positiving on the common CA serial.
290
+ check: function (cert) { return _serialOctets(cert) > 20 ? { context: { octets: _serialOctets(cert) } } : null; },
291
+ },
292
+ {
293
+ id: "lint/rfc5280/validity-inverted", severity: "error", source: "rfc5280", citation: "RFC 5280 4.1.2.5",
294
+ message: "the certificate notBefore must not be later than notAfter",
295
+ check: function (cert) {
296
+ var v = cert.validity;
297
+ return (v.notBefore instanceof Date && v.notAfter instanceof Date && v.notBefore.getTime() > v.notAfter.getTime()) ? true : null;
298
+ },
299
+ },
300
+ {
301
+ id: "lint/rfc5280/ca-without-keycertsign", severity: "error", source: "rfc5280", citation: "RFC 5280 4.2.1.3 / 4.2.1.9",
302
+ message: "a CA certificate (basicConstraints cA=TRUE) must assert the keyCertSign key usage",
303
+ appliesTo: function (cert, ctx) { var bc = ctx.decode("basicConstraints"); return !!(bc && bc.value && bc.value.cA === true); },
304
+ check: function (cert, ctx) { var ku = ctx.decode("keyUsage"); return (ku && ku.value && ku.value.keyCertSign === true) ? null : true; },
305
+ },
306
+ {
307
+ id: "lint/rfc5280/keycertsign-without-ca", severity: "error", source: "rfc5280", citation: "RFC 5280 4.2.1.3",
308
+ message: "a certificate asserting the keyCertSign key usage must also assert basicConstraints cA=TRUE",
309
+ // The inverse coherence check: keyCertSign asserted -> cA MUST be TRUE. Fires when a
310
+ // cert claims certificate-signing usage but omits basicConstraints or is not a CA.
311
+ appliesTo: function (cert, ctx) { var ku = ctx.decode("keyUsage"); return !!(ku && ku.value && ku.value.keyCertSign === true); },
312
+ check: function (cert, ctx) { var bc = ctx.decode("basicConstraints"); return (bc && bc.value && bc.value.cA === true) ? null : true; },
313
+ },
314
+ {
315
+ id: "lint/rfc5280/unknown-critical-extension", severity: "error", source: "rfc5280", citation: "RFC 5280 4.2",
316
+ message: "a critical extension is not recognized by the profile -- a conforming consumer must reject it",
317
+ check: function (cert) {
318
+ var out = [];
319
+ (cert.extensions || []).forEach(function (e) {
320
+ if (e.critical === true && _isUnknownExtension(e.oid)) out.push({ context: { oid: e.oid, name: e.name || null } });
321
+ });
322
+ return out.length ? out : null;
323
+ },
324
+ },
325
+ {
326
+ id: "lint/rfc5280/empty-subject-san-not-critical", severity: "error", source: "rfc5280", citation: "RFC 5280 4.1.2.6",
327
+ message: "a certificate with an empty subject must carry a subjectAltName marked critical",
328
+ appliesTo: function (cert) { return !((cert.subject && cert.subject.rdns) || []).length; },
329
+ check: function (cert, ctx) { var san = ctx.decode("subjectAltName"); return (san && san.critical === true) ? null : true; },
330
+ },
331
+ {
332
+ id: "lint/rfc5280/extension-undecodable", severity: "error", source: "rfc5280", citation: "RFC 5280 4.2",
333
+ message: "a recognized extension's value does not decode under its RFC 5280 syntax",
334
+ check: function (cert) {
335
+ var out = [];
336
+ (cert.extensions || []).forEach(function (e) {
337
+ var dec = EXT_DECODERS[e.oid];
338
+ if (!dec) return;
339
+ try { dec(e.value); }
340
+ catch (err) {
341
+ out.push({ context: { oid: e.oid, name: e.name || null, code: err.code } });
342
+ }
343
+ });
344
+ return out.length ? out : null;
345
+ },
346
+ },
347
+ {
348
+ id: "lint/rfc5280/ski-missing", severity: "notice", source: "rfc5280", citation: "RFC 5280 4.2.1.2",
349
+ message: "a CA certificate should carry a subjectKeyIdentifier extension",
350
+ appliesTo: function (cert, ctx) { var bc = ctx.decode("basicConstraints"); return !!(bc && bc.value && bc.value.cA === true); },
351
+ check: function (cert, ctx) { return ctx.raw("subjectKeyIdentifier") ? null : true; },
352
+ },
353
+ {
354
+ id: "lint/rfc5280/aki-missing", severity: "notice", source: "rfc5280", citation: "RFC 5280 4.2.1.1",
355
+ message: "a non-self-issued certificate should carry an authorityKeyIdentifier extension",
356
+ appliesTo: function (cert) { return !!(cert.issuer && cert.subject && cert.issuer.dn !== cert.subject.dn); },
357
+ check: function (cert, ctx) { return ctx.raw("authorityKeyIdentifier") ? null : true; },
358
+ },
359
+ ];
360
+
361
+ function _isTls(cert, ctx) { return ctx.isTlsServerCert; }
362
+
363
+ // The CABF TLS BR maximum validity period (in days) by certificate issuance date --
364
+ // the SC22 398-day ceiling (2020-09-01) reduced on the SC081v3 schedule: 200 days from
365
+ // 2026-03-15, 100 days from 2027-03-15, 47 days from 2029-03-15. Newest-first so the first
366
+ // entry whose `from` is at or before the cert's notBefore gives the applicable ceiling.
367
+ var VALIDITY_SCHEDULE = [
368
+ { from: new Date("2029-03-15T00:00:00Z"), maxDays: 47 },
369
+ { from: new Date("2027-03-15T00:00:00Z"), maxDays: 100 },
370
+ { from: new Date("2026-03-15T00:00:00Z"), maxDays: 200 },
371
+ { from: new Date("2020-09-01T00:00:00Z"), maxDays: 398 },
372
+ ];
373
+ var VALIDITY_SCHEDULE_START = VALIDITY_SCHEDULE[VALIDITY_SCHEDULE.length - 1].from;
374
+ function _validityCeilingDays(notBefore) {
375
+ for (var i = 0; i < VALIDITY_SCHEDULE.length; i++) {
376
+ if (notBefore.getTime() >= VALIDITY_SCHEDULE[i].from.getTime()) return VALIDITY_SCHEDULE[i].maxDays;
377
+ }
378
+ // Unreachable: the rule's effectiveDate (VALIDITY_SCHEDULE_START) reports NE for any cert
379
+ // whose notBefore predates the schedule, so the loop always matches the last entry.
380
+ return VALIDITY_SCHEDULE[VALIDITY_SCHEDULE.length - 1].maxDays;
381
+ }
382
+
383
+ var CABF_TLS_RULES = [
384
+ {
385
+ id: "lint/cabf-tls/san-missing", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.4.2.1",
386
+ message: "a TLS server certificate must include a usable subjectAltName extension",
387
+ appliesTo: _isTls,
388
+ // A SAN that is absent -- OR present but undecodable / carrying no names -- is not a
389
+ // usable subjectAltName, so all three cases are treated as missing for the TLS profile.
390
+ check: function (cert, ctx) { var san = ctx.decode("subjectAltName"); return (san && san.value && san.value.names && san.value.names.length) ? null : true; },
391
+ },
392
+ {
393
+ id: "lint/cabf-tls/cn-not-in-san", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.4.2.2",
394
+ message: "a subject commonName value must also appear as a subjectAltName dNSName",
395
+ appliesTo: _isTls,
396
+ check: function (cert, ctx) {
397
+ // dNSNames are case-insensitive (RFC 4343), so match case-folded.
398
+ var sans = _sanDnsNames(ctx).map(function (s) { return typeof s === "string" ? s.toLowerCase() : s; }), out = [];
399
+ _subjectCNs(cert).forEach(function (cn) {
400
+ // A commonName MUST match a dNSName OR an iPAddress SAN (CABF 7.1.4.2.2). This
401
+ // subset checks dNSName values only, so an IP-literal CN (validated against the
402
+ // iPAddress SAN this subset does not yet render) is skipped, not false-flagged.
403
+ if (_looksLikeIp(cn)) return;
404
+ if (sans.indexOf(cn.toLowerCase()) === -1) out.push({ context: { cn: cn } });
405
+ });
406
+ return out.length ? out : null;
407
+ },
408
+ },
409
+ {
410
+ id: "lint/cabf-tls/dnsname-bad-syntax", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.4.2.1",
411
+ message: "a subjectAltName dNSName is not well-formed",
412
+ appliesTo: _isTls,
413
+ check: function (cert, ctx) {
414
+ var out = [];
415
+ _sanDnsNames(ctx).forEach(function (name) { var p = _dnsNameProblem(name); if (p) out.push({ context: { dnsName: name, problem: p } }); });
416
+ return out.length ? out : null;
417
+ },
418
+ },
419
+ {
420
+ id: "lint/cabf-tls/eku-missing-serverauth", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.2.7.6",
421
+ message: "a TLS server certificate's extKeyUsage must include id-kp-serverAuth",
422
+ // Only meaningful when the caller EXPLICITLY lints against the TLS profile; under the
423
+ // default profile a cert is recognized as TLS BY its serverAuth EKU, so the check is
424
+ // vacuous and reports NA rather than firing on every non-TLS certificate.
425
+ appliesTo: function (cert, ctx) { return ctx.explicitTlsProfile; },
426
+ check: function (cert, ctx) { return _hasEku(cert, "serverAuth") ? null : true; },
427
+ },
428
+ {
429
+ id: "lint/cabf-tls/weak-key", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 6.1.5",
430
+ message: "the subject public key is below the CABF TLS BR minimum (RSA < 2048 bits, or a non-approved EC curve)",
431
+ appliesTo: _isTls,
432
+ check: function (cert) {
433
+ var spki = cert.subjectPublicKeyInfo, name = spki && spki.algorithm && spki.algorithm.name;
434
+ // Fail closed: a key whose size/curve cannot be confirmed to meet the minimum is
435
+ // flagged, not passed. RSA below 2048 bits (or an unreadable modulus), or an EC key
436
+ // NOT on an approved named curve (including absent / explicit / invalid parameters,
437
+ // which resolve to a null curve) is a finding -- CABF permits only P-256/384/521.
438
+ if (RSA_KEY_ALGS[name]) { var bits = _rsaModulusBits(spki); return (bits === null || bits < 2048) ? { context: { rsaBits: bits } } : null; }
439
+ if (name === "ecPublicKey") { var curve = _ecCurveName(spki); return APPROVED_EC_CURVES.indexOf(curve) === -1 ? { context: { curve: curve } } : null; }
440
+ // Any other key type (e.g. EdDSA) is out of this rule's RSA-size / EC-curve scope --
441
+ // key-TYPE approval for TLS is a separate concern, so weak-key does not fire here.
442
+ return null;
443
+ },
444
+ },
445
+ {
446
+ id: "lint/cabf-tls/validity-too-long", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 6.3.2 (Ballots SC22 + SC081v3)",
447
+ message: "a TLS server certificate validity period exceeds the CABF maximum for its issuance date",
448
+ appliesTo: _isTls,
449
+ effectiveDate: VALIDITY_SCHEDULE_START,
450
+ check: function (cert) {
451
+ var v = cert.validity;
452
+ if (!(v.notBefore instanceof Date) || !(v.notAfter instanceof Date)) return null;
453
+ var maxDays = _validityCeilingDays(v.notBefore);
454
+ var days = (v.notAfter.getTime() - v.notBefore.getTime()) / MS_PER_DAY;
455
+ return days > maxDays ? { context: { days: Math.round(days), maxDays: maxDays } } : null;
456
+ },
457
+ },
458
+ ];
459
+
460
+ var PROFILES = {
461
+ "rfc5280": RFC5280_RULES,
462
+ "cabf-tls": CABF_TLS_RULES,
463
+ };
464
+ var ALL_RULES = RFC5280_RULES.concat(CABF_TLS_RULES);
465
+
466
+ function _selectRules(profile) {
467
+ if (profile == null || profile === "all" || profile === "default") return ALL_RULES;
468
+ if (!PROFILES[profile]) throw _cfg("lint/unknown-profile", "unknown lint profile \"" + profile + "\" (known: " + Object.keys(PROFILES).join(", ") + ")");
469
+ // An explicit cabf-tls run still needs the always-on rfc5280 structural rules? No: the
470
+ // caller asked for ONE profile. Return exactly that profile's rules.
471
+ return PROFILES[profile];
472
+ }
473
+
474
+ // Filter the emitted findings to the threshold floor (default `notice`). The threshold
475
+ // is validated at the certificate() entry, so it is a known severity here; `counts` and
476
+ // `worst` are computed before this filter and stay complete.
477
+ function _applyThreshold(report, severity) {
478
+ var floor = SEVERITY[severity == null ? "notice" : severity];
479
+ return report.findings.filter(function (f) { return (SEVERITY[f.severity] || 0) >= floor; });
480
+ }
481
+
482
+ // ---- public surface ----
483
+
484
+ /**
485
+ * @primitive pki.lint.certificate
486
+ * @signature pki.lint.certificate(input, opts?) -> LintReport
487
+ * @since 0.2.10
488
+ * @status experimental
489
+ * @spec RFC 5280, CA/Browser Forum TLS BR
490
+ * @related pki.schema.x509.parse, pki.inspect.certificate, pki.path.validate
491
+ *
492
+ * Lint a certificate against the RFC 5280 profile plus a representative CABF TLS BR
493
+ * subset. `input` is a PEM string, a DER `Buffer`, or an already-parsed
494
+ * `pki.schema.x509` object. Returns a `LintReport`
495
+ * `{ findings: [{id, severity, source, citation, message, context?}], counts, worst, ran }`.
496
+ *
497
+ * The DATA path never throws: hostile bytes produce a single `fatal` finding
498
+ * `lint/unparseable` rather than raising. The ONLY throw path is config-time misuse
499
+ * (`opts.profile` unknown, `opts.severity` out of range, or a wrong-type input) -- a
500
+ * typed `LintError`.
501
+ *
502
+ * @opts profile One of `pki.lint.profiles()` (default runs every profile). Selecting
503
+ * `"cabf-tls"` lints the input AS a TLS server certificate.
504
+ * @opts severity Suppress findings below this floor (default `"notice"`). `counts` and
505
+ * `worst` always reflect the complete, unfiltered result.
506
+ * @example
507
+ * var report = pki.lint.certificate(pemString);
508
+ * report.worst; // "notice" | "error" | ...
509
+ * report.findings.map(function (f) { return f.id; });
510
+ */
511
+ function certificate(input, opts) {
512
+ opts = opts || {};
513
+ if (typeof opts !== "object") throw _cfg("lint/bad-input", "pki.lint options must be an object");
514
+ // Validate BOTH config options up front so a config error fails fast, before any work.
515
+ if (opts.severity != null && VALID_SEVERITY.indexOf(opts.severity) === -1) {
516
+ throw _cfg("lint/bad-severity", "unknown severity threshold \"" + opts.severity + "\" (known: " + VALID_SEVERITY.join(", ") + ")");
517
+ }
518
+ var rules = _selectRules(opts.profile);
519
+ var ingested = _ingest(input);
520
+ var report;
521
+ if (ingested.fatal) {
522
+ report = { findings: [ingested.fatal], counts: { fatal: 1, error: 0, warn: 0, notice: 0, pass: 0, na: 0, ne: 0 }, ran: [] };
523
+ } else {
524
+ var ctx = _makeCtx(ingested.cert, opts.profile);
525
+ report = _runLints(rules, ingested.cert, ctx);
526
+ }
527
+ report.worst = _worst(report.findings);
528
+ report.findings = _applyThreshold(report, opts.severity);
529
+ return report;
530
+ }
531
+
532
+ /**
533
+ * @primitive pki.lint.rules
534
+ * @signature pki.lint.rules(profile?) -> [{id, severity, source, citation}]
535
+ * @since 0.2.10
536
+ * @status experimental
537
+ * @spec RFC 5280, CA/Browser Forum TLS BR
538
+ *
539
+ * Enumerate the rule registry (all rules, or one profile's). Each entry exposes its
540
+ * stable `id`, `severity`, `source`, and spec-clause `citation` for documentation and
541
+ * corpus tooling.
542
+ * @example
543
+ * pki.lint.rules("rfc5280").length; // -> a positive count
544
+ */
545
+ function rules(profile) {
546
+ var set = profile == null ? ALL_RULES : (PROFILES[profile] || (function () { throw _cfg("lint/unknown-profile", "unknown lint profile \"" + profile + "\""); })());
547
+ return set.map(function (r) { return { id: r.id, severity: r.severity, source: r.source, citation: r.citation }; });
548
+ }
549
+
550
+ /**
551
+ * @primitive pki.lint.profiles
552
+ * @signature pki.lint.profiles() -> [string]
553
+ * @since 0.2.10
554
+ * @status experimental
555
+ * @spec RFC 5280, CA/Browser Forum TLS BR
556
+ *
557
+ * List the known lint-profile names.
558
+ * @example
559
+ * pki.lint.profiles(); // -> ["rfc5280", "cabf-tls"]
560
+ */
561
+ function profiles() { return Object.keys(PROFILES); }
562
+
563
+ module.exports = {
564
+ certificate: certificate,
565
+ rules: rules,
566
+ profiles: profiles,
567
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
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:a09496c1-2301-420c-a786-1cb883b3f77c",
5
+ "serialNumber": "urn:uuid:a5150e4c-cfd7-4241-8d30-2a5f74cea49e",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-13T10:49:09.267Z",
8
+ "timestamp": "2026-07-13T17:30:07.505Z",
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.2.9",
22
+ "bom-ref": "@blamejs/pki@0.2.11",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.2.9",
25
+ "version": "0.2.11",
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.2.9",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.2.11",
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.2.9",
57
+ "ref": "@blamejs/pki@0.2.11",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]