@blamejs/pki 0.2.10 → 0.2.12

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,25 @@ 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.12 — 2026-07-13
8
+
9
+ pki.ct.verifySct verifies a Signed Certificate Timestamp's signature against a Certificate Transparency log's public key.
10
+
11
+ ### Added
12
+
13
+ - pki.ct.verifySct(entry, sct, logPublicKey) verifies a Signed Certificate Timestamp's signature against a CT log's public key (RFC 6962 section 3.2), composing pki.ct.reconstructSignedData, the shared ECDSA-Sig-Value conformance gate, and pki.webcrypto. Resolves true/false on the cryptographic verdict; throws a typed CtError on a structural fault.
14
+
15
+ ## v0.2.11 — 2026-07-13
16
+
17
+ 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.
18
+
19
+ ### Added
20
+
21
+ - pki inspect <cert> renders a certificate as an openssl x509 -text style report (composes pki.inspect.certificate).
22
+ - 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).
23
+ - pki convert <file> --to der|pem [--label <label>] transcodes between DER and PEM with auto-detected input encoding and byte-exact round-tripping.
24
+ - 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.
25
+
7
26
  ## v0.2.10 — 2026-07-13
8
27
 
9
28
  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
@@ -221,7 +221,7 @@ is callable today; nothing below is a stub.
221
221
  | `pki.schema.smime` | Decode S/MIME ESS signed-attribute values (RFC 5035 / RFC 8551) — `parseSigningCertificate` / `parseSigningCertificateV2` bind a signature to its signing certificate (cert hash, hash algorithm, issuer `GeneralNames` + serial), `parseSmimeCapabilities` decodes the ordered capability list, and `decodeAttribute` OID-dispatches a CMS attribute (enforcing the single-value rule, recognize-and-defer for unknown types). A companion decoder for CMS signed attributes, not an auto-routed format, fail-closed — `parseSigningCertificate`, `parseSigningCertificateV2`, `parseSmimeCapabilities`, `decodeAttribute` |
222
222
  | `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk` / `encode` / `embeddedDer` plus the schema combinators |
223
223
  | `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining across RSA, ECDSA, EdDSA, ML-DSA, SLH-DSA and hybrid composite ML-DSA signatures — a composite is accepted only when **both** its post-quantum and traditional components verify; validity windows, name chaining, basic constraints and path length, key usage, name constraints, the certificate-policy tree) over an ordered path and a trust anchor, returning a structured verdict with per-check reason codes, and enforces a `pki.trust` anchor's per-purpose distrust-after dates and delegator purposes via `checkPurpose`; `crlChecker` supplies CRL-based revocation — including partitioned/sharded CRLs, whose §6.3.3 Distribution Point ↔ IDP correspondence lets a corresponding full-reason shard establish non-revocation — and `ocspChecker` supplies OCSP-based revocation (RFC 6960 — CertID binding, responder authorization, signature, currency) over the same pluggable hook. Pure and re-entrant, fail-closed — `validate`, `crlChecker`, `ocspChecker` |
224
- | `pki.ct` | Parse RFC 6962 Certificate Transparency SCT lists — `parseSctList` decodes the `SignedCertificateTimestampList` a certificate or OCSP response carries in the SCT extension (a TLS-presentation-language payload inside the §3.3 double DER wrap) into per-SCT log id, exact `timestamp` (BigInt), named signature algorithm, and raw signature; `reconstructSignedData` rebuilds the exact `digitally-signed` preimage for external verification. Structure decoded, crypto surfaced raw, bounded decode, fail-closed — `parseSctList`, `reconstructSignedData` |
224
+ | `pki.ct` | RFC 6962 Certificate Transparency SCTs — `parseSctList` decodes the `SignedCertificateTimestampList` a certificate or OCSP response carries in the SCT extension (a TLS-presentation-language payload inside the §3.3 double DER wrap) into per-SCT log id, exact `timestamp` (BigInt), named signature algorithm, and raw signature; `reconstructSignedData` rebuilds the exact `digitally-signed` preimage; `verifySct(entry, sct, logKey)` verifies the SCT signature against a log's public key — reconstructing the signed data, routing an ECDSA signature through the strict DER-conformance gate, and verifying through the crypto engine (resolves `true`/`false`, throws typed `ct/*` on a structural fault). Structure decoded, crypto fail-closed — `parseSctList`, `reconstructSignedData`, `verifySct` |
225
225
  | `pki.merkle` | RFC 6962 / RFC 9162 Merkle-tree proof verification — `leafHash` / `nodeHash` / `emptyRootHash` build the domain-separated (0x00 leaf / 0x01 node) SHA-256 tree hashes; `verifyInclusion` folds an audit proof back to a root and `verifyConsistency` reconstructs both the old and new root (the append-only guarantee), each constant-time-compared to a trusted checkpoint root. Fail-closed on bad geometry, sync hashing, transport-free — `leafHash`, `nodeHash`, `emptyRootHash`, `verifyInclusion`, `verifyConsistency` |
226
226
  | `pki.trust` | Mozilla / CCADB trust-store ingestion — `parseCertdata` reads the NSS `certdata.txt` object stream and `parseCcadbCsv` the CCADB CSV export into one identical constraint-carrying anchor shape: the per-purpose trust bits (only `CKT_NSS_TRUSTED_DELEGATOR` grants) and the per-purpose distrust-after dates the bare root list omits. Certificate and trust objects pair by byte-exact issuer + serial (never adjacency) and are cross-checked against the parsed DER, so metadata can never attach to the wrong root; `anchor()` hands an entry to `pki.path.validate({ trustAnchor, checkPurpose })`. Offline, fail-closed, bounded — `parseCertdata`, `parseCcadbCsv`, `anchor` |
227
227
  | `pki.shbs` | Stateful hash-based signature **verification** — HSS/LMS (RFC 8554), carried in X.509 by RFC 9802 and CMS by RFC 9708, profiled by NIST SP 800-208 (CNSA 2.0 firmware signing). `verify` checks an HSS signature (every level must pass) and `verifyLms` a single-tree LMS, over the raw public-key / signature blobs the parsers already surface. Pure public-input SHA-256 / SHAKE256 hashing, a data-driven typecode registry, bounds-before-slice reads; a malformed blob throws a typed `ShbsError`, a well-formed-but-wrong signature returns `false`. **Verify only by design** — stateful signing needs atomic one-time-key state that belongs in an HSM — `verify`, `verifyLms` |
@@ -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> 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/lib/ct.js CHANGED
@@ -43,6 +43,10 @@ var constants = require("./constants.js");
43
43
  var frameworkError = require("./framework-error.js");
44
44
  var guard = require("./guard-all.js");
45
45
  var ByteReader = require("./byte-reader.js");
46
+ var oid = require("./oid.js");
47
+ var webcrypto = require("./webcrypto.js");
48
+ var validator = require("./validator-all.js");
49
+ var subtle = webcrypto.webcrypto.subtle;
46
50
 
47
51
  var CtError = frameworkError.CtError;
48
52
  var C = constants;
@@ -287,9 +291,128 @@ function reconstructSignedData(entry, sct) {
287
291
  return Buffer.concat(parts);
288
292
  }
289
293
 
294
+ // RFC 6962 sec. 2.1.4 constrains an SCT to SHA-256 with either ECDSA (NIST P-256) or RSA;
295
+ // the verifier enforces exactly that rather than accepting an off-profile hash / curve a
296
+ // conformant log never uses. The SCT hash name -> the WebCrypto digest.
297
+ var CT_HASH = { sha256: "SHA-256" };
298
+ // The one EC named curve an SCT log key may use (P-256), keyed by the curve OID its SPKI
299
+ // carries -> the WebCrypto curve name + the r/s coordinate width.
300
+ var CT_EC_CURVE = {};
301
+ CT_EC_CURVE[oid.byName("prime256v1")] = { curve: "P-256", coordLen: 32 };
302
+
303
+ // Read the SubjectPublicKeyInfo algorithm OID (+ the EC curve OID) off a log key SPKI so
304
+ // the verifier picks the WebCrypto import descriptor from the KEY, not just the SCT's
305
+ // self-declared signature type. Fail-closed: a non-SPKI shape throws a typed ct/bad-input.
306
+ function _spkiAlg(spki) {
307
+ var node;
308
+ try { node = asn1.decode(spki); } catch (e) { throw new CtError("ct/bad-input", "the CT log public key is not a well-formed SubjectPublicKeyInfo", e); }
309
+ var algId = node.children && node.children[0];
310
+ if (!algId || !algId.children || !algId.children.length) throw new CtError("ct/bad-input", "the CT log public key is not a SubjectPublicKeyInfo");
311
+ var out;
312
+ try { out = { algOid: asn1.read.oid(algId.children[0]) }; }
313
+ catch (e1) { throw new CtError("ct/bad-input", "the CT log key SPKI algorithm identifier is not an OID", e1); }
314
+ if (out.algOid === oid.byName("ecPublicKey")) {
315
+ if (!algId.children[1]) throw new CtError("ct/bad-input", "the EC log key SPKI is missing its named-curve parameters");
316
+ try { out.curveOid = asn1.read.oid(algId.children[1]); }
317
+ catch (e2) { throw new CtError("ct/bad-input", "the EC log key SPKI curve parameters are not a named-curve OID", e2); }
318
+ } else if (out.algOid === oid.byName("rsaEncryption")) {
319
+ // RFC 6962 sec. 2.1.4 requires an RSA log key of at least 2048 bits -- size it from the
320
+ // RSAPublicKey modulus in the subjectPublicKey BIT STRING.
321
+ var mod, exp;
322
+ try {
323
+ var rsaSeq = asn1.decode(asn1.read.bitString(node.children[1]).bytes);
324
+ mod = asn1.read.integer(rsaSeq.children[0]);
325
+ exp = asn1.read.integer(rsaSeq.children[1]);
326
+ } catch (e3) { throw new CtError("ct/bad-input", "the RSA log key SPKI is not a well-formed RSAPublicKey", e3); }
327
+ // A non-positive modulus is malformed -- fail closed rather than size its absolute value.
328
+ if (mod <= 0n) throw new CtError("ct/bad-input", "the RSA log key modulus is not a positive integer");
329
+ // A small or even public exponent makes RSASSA-PKCS1-v1_5 verification forgeable (with
330
+ // e=1 the signature is simply the encoded DigestInfo). Require an odd exponent >= 3 --
331
+ // critical when the log key is taken from an untrusted log list rather than a pinned key.
332
+ if (exp < 3n || (exp & 1n) === 0n) throw new CtError("ct/bad-input", "the RSA log key public exponent must be an odd integer >= 3");
333
+ out.rsaBits = mod.toString(2).length;
334
+ }
335
+ return out;
336
+ }
337
+
338
+ /**
339
+ * @primitive pki.ct.verifySct
340
+ * @signature pki.ct.verifySct(entry, sct, logPublicKey) -> Promise<boolean>
341
+ * @since 0.2.12
342
+ * @status experimental
343
+ * @spec RFC 6962
344
+ * @defends sct-signature-forgery (CWE-347)
345
+ * @related pki.ct.parseSctList, pki.ct.reconstructSignedData
346
+ *
347
+ * Verify a Signed Certificate Timestamp's signature against a Certificate Transparency
348
+ * log's public key (RFC 6962 sec. 3.2). `entry` is the log entry the SCT covers
349
+ * (`{ entryType: 0, leafCert }` or `{ entryType: 1, tbsCertificate, issuerKeyHash }`,
350
+ * as for `reconstructSignedData`), `sct` a decoded v1 SCT from `parseSctList().scts[]`,
351
+ * and `logPublicKey` the log's SubjectPublicKeyInfo (DER `Buffer`). Reconstructs the exact
352
+ * signed data, imports the log key, and verifies the SCT signature -- an ECDSA signature is
353
+ * routed through the strict DER ECDSA-Sig-Value conformance gate before conversion to the
354
+ * raw r||s WebCrypto expects, an RSA signature verifies directly.
355
+ *
356
+ * Resolves `true` on a valid signature and `false` on a cryptographic mismatch (a false
357
+ * verdict is a verdict). Throws a typed `CtError` on structural failure -- a malformed
358
+ * entry/SCT, an unusable log key, or an unsupported hash/signature algorithm.
359
+ *
360
+ * @example
361
+ * var sct = pki.ct.parseSctList(sctExtValue).scts[0];
362
+ * // Resolve the CT log's DER SubjectPublicKeyInfo from a trusted log list, keyed by log id.
363
+ * var logKeysByLogId = {}; // { sct.logIdHex: <SPKI Buffer>, ... }
364
+ * var logKey = logKeysByLogId[sct.logIdHex];
365
+ * var ok = await pki.ct.verifySct({ entryType: 0, leafCert: certDer }, sct, logKey);
366
+ */
367
+ async function verifySct(entry, sct, logPublicKey) {
368
+ // reconstructSignedData validates the entry + SCT and rebuilds the exact preimage.
369
+ var message = reconstructSignedData(entry, sct);
370
+ var spki = _toBuffer(logPublicKey, "the CT log public key (SPKI)");
371
+ // RFC 6962 sec. 3.2: an SCT names its log by LogID = SHA-256(log SPKI). When the SCT
372
+ // carries a logId, it MUST match the key it is verified against, so an SCT is never
373
+ // accepted against a different log's key (a key-confusion the bare signature check misses).
374
+ if (sct.logId != null) {
375
+ var keyId = Buffer.from(await subtle.digest("SHA-256", spki));
376
+ if (!keyId.equals(_toBuffer(sct.logId, "sct.logId"))) {
377
+ throw new CtError("ct/log-id-mismatch", "the SCT logId does not match SHA-256 of the provided log key (RFC 6962 sec. 3.2)");
378
+ }
379
+ }
380
+ var sigInfo = sct.signatureAlgorithm || {};
381
+ var hashName = CT_HASH[sigInfo.hashName];
382
+ if (!hashName) throw new CtError("ct/unsupported-algorithm", "unsupported SCT hash algorithm " + JSON.stringify(sigInfo.hashName) + " (RFC 6962 sec. 2.1.4 mandates sha256)");
383
+ var alg = _spkiAlg(spki);
384
+ var imp, ver, sig = _toBuffer(sct.signature, "sct.signature");
385
+ if (sigInfo.signatureName === "ecdsa") {
386
+ if (alg.algOid !== oid.byName("ecPublicKey")) throw new CtError("ct/bad-input", "the SCT declares an ECDSA signature but the log key is not an EC key");
387
+ var ec = CT_EC_CURVE[alg.curveOid];
388
+ if (!ec) throw new CtError("ct/unsupported-algorithm", "unsupported SCT log EC curve (RFC 6962 sec. 2.1.4 mandates NIST P-256)");
389
+ imp = { name: "ECDSA", namedCurve: ec.curve };
390
+ ver = { name: "ECDSA", hash: hashName };
391
+ // The SCT signature is a DER ECDSA-Sig-Value; route it through the strict conformance
392
+ // gate (primitive, minimal, positive, bounded r/s) before converting to raw r||s.
393
+ sig = validator.sig.ecdsaSigToRaw(sig, ec.coordLen, CtError, "ct/bad-signature");
394
+ } else if (sigInfo.signatureName === "rsa") {
395
+ if (alg.algOid !== oid.byName("rsaEncryption")) throw new CtError("ct/bad-input", "the SCT declares an RSA signature but the log key is not an RSA key");
396
+ if (!(alg.rsaBits >= 2048)) throw new CtError("ct/unsupported-algorithm", "the SCT log RSA key is below the RFC 6962 sec. 2.1.4 minimum of 2048 bits");
397
+ imp = { name: "RSASSA-PKCS1-v1_5", hash: hashName };
398
+ ver = { name: "RSASSA-PKCS1-v1_5" };
399
+ } else {
400
+ throw new CtError("ct/unsupported-algorithm", "unsupported SCT signature algorithm " + JSON.stringify(sigInfo.signatureName) + " (RFC 6962 sec. 2.1.4 supports ecdsa/rsa)");
401
+ }
402
+ // A wrong signature resolves false from subtle.verify (a verdict); a structural failure
403
+ // -- an unimportable key, an algorithm/key mismatch -- is re-thrown fail-closed.
404
+ try {
405
+ var key = await subtle.importKey("spki", spki, imp, false, ["verify"]);
406
+ return await subtle.verify(ver, key, sig, message);
407
+ } catch (e) {
408
+ throw new CtError("ct/verify-error", "the SCT signature could not be evaluated", e);
409
+ }
410
+ }
411
+
290
412
  module.exports = {
291
413
  parseSctList: parseSctList,
292
414
  reconstructSignedData: reconstructSignedData,
415
+ verifySct: verifySct,
293
416
  HASH_ALGORITHMS: HASH_ALGORITHMS,
294
417
  SIGNATURE_ALGORITHMS: SIGNATURE_ALGORITHMS,
295
418
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
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:63ae20ee-b75f-4b87-9846-27c435f26822",
5
+ "serialNumber": "urn:uuid:f99392dd-064e-4cea-b273-77302f5d396d",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-13T16:00:51.631Z",
8
+ "timestamp": "2026-07-13T18:39:01.236Z",
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.10",
22
+ "bom-ref": "@blamejs/pki@0.2.12",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.2.10",
25
+ "version": "0.2.12",
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.10",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.2.12",
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.10",
57
+ "ref": "@blamejs/pki@0.2.12",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]