@blamejs/pki 0.2.10 → 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 +11 -0
- package/README.md +11 -1
- package/bin/pki.js +159 -13
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@ 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
|
+
|
|
7
18
|
## v0.2.10 — 2026-07-13
|
|
8
19
|
|
|
9
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.
|
package/README.md
CHANGED
|
@@ -232,7 +232,7 @@ is callable today; nothing below is a stub.
|
|
|
232
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` |
|
|
233
233
|
| `pki.C` / `pki.constants` | Version-stable constants — functional scale helpers (`C.TIME.*`, `C.BYTES.*`), codec `LIMITS`, `version` |
|
|
234
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>` |
|
|
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>` |
|
|
236
236
|
|
|
237
237
|
### CLI
|
|
238
238
|
|
|
@@ -241,8 +241,18 @@ pki version # @blamejs/pki v0.1.0
|
|
|
241
241
|
pki oid 1.2.840.113549.1.1.11 # sha256WithRSAEncryption
|
|
242
242
|
pki oid sha256 # 2.16.840.1.101.3.4.2.1
|
|
243
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
|
|
244
249
|
```
|
|
245
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
|
+
|
|
246
256
|
### What's coming
|
|
247
257
|
|
|
248
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>
|
|
10
|
-
* pki parse <cert
|
|
11
|
-
*
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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(
|
|
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(
|
|
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/package.json
CHANGED
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:
|
|
5
|
+
"serialNumber": "urn:uuid:a5150e4c-cfd7-4241-8d30-2a5f74cea49e",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.2.11",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.2.
|
|
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.
|
|
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.
|
|
57
|
+
"ref": "@blamejs/pki@0.2.11",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|