@blamejs/pki 0.8.14 → 0.8.15

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,20 @@ 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.8.15 — 2026-09-24
8
+
9
+ The host's own CA bundle becomes trust anchors, and says which set you got.
10
+
11
+ ### Added
12
+
13
+ - `pki.trust.systemAnchors(opts?)` returns `{ anchors, source, path, skipped }`. `source` is `"ca-bundle"` for a file on this host, with `path` naming it, or `"node-bundled"` for the Mozilla set compiled into Node, whose `path` is `null`. The anchors are the same shape `parseCertdata` and `parseCcadbCsv` produce, so they reach `pki.path.validate` without being mapped by hand. `opts.path` reads a named file, `opts.maxBytes` caps the read.
14
+ - The searched locations are the bundles a Unix host keeps: `/etc/ssl/certs/ca-certificates.crt`, `/etc/pki/tls/certs/ca-bundle.crt`, `/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem`, `/etc/ssl/ca-bundle.pem`, `/etc/ssl/cert.pem` and `/usr/local/share/certs/ca-root-nss.crt`. Each candidate is tried by reading it, so one that exists but cannot be opened does not strand a readable one below it.
15
+ - `pki.trust.discoversBundles(platform)` says whether that search runs. It does not on macOS or Windows: `/etc/ssl/cert.pem` can exist on macOS as OpenSSL's own copy, and returning it would report anchors that omit both locally installed roots and the Keychain trust decisions the host actually applies. Those platforms are refused instead, and `opts.path` still reads an exported bundle anywhere.
16
+ - A host with no readable bundle is refused with `trust/no-readable-store`, and the message names the command that exports its roots. macOS keeps them in a binary keychain and Windows in a registry-backed store; reading either means running another program, which this toolkit does not do. `opts.allowNodeBundle: true` returns Node's set instead, reported as `"node-bundled"` so the difference is visible.
17
+ - A block the bundle carries that is not a certificate, and a certificate the strict reader refuses, are both listed in `skipped` with the label, the byte offset and the reason. A CA bundle is distribution-managed and carries certificates that do not conform: the set Node ships has a root whose validity dates are before 2050 and encoded as GeneralizedTime, which RFC 5280 section 4.1.2.5 requires to be UTCTime. Refusing the whole file over one such root would leave you with no anchors, so it is named instead. A bundle from which no certificate parsed is still refused. A `BEGIN` boundary that does not start its line is reported too: RFC 7468 section 2 reads it as text rather than as a block, which in a trust bundle is an anchor you believe you installed and do not have.
18
+ - An OpenSSL `TRUSTED CERTIFICATE` block is reported rather than ingested. It carries the purposes the operator trusted the root FOR in a trailing `X509_CERT_AUX`, and reading the certificate while dropping that would turn a root restricted to one purpose into an unrestricted anchor. The reported reason names the conversion, `openssl x509 -in <file> -out <file>.pem`. Reading the auxiliary data and keeping its restrictions is not yet implemented.
19
+ - `opts.maxBytes` bounds what is read rather than what is kept, so a bundle far above the cap is refused without being loaded. A bundle that exceeds it is refused even under `opts.allowNodeBundle`: the cap is a fault about the file you named, and a resource refusal must not quietly swap the trust set a validation runs against.
20
+
7
21
  ## v0.8.14 — 2026-09-24
8
22
 
9
23
  The 0.8.14 tarball is the one to install for everything 0.8.13 added.
package/README.md CHANGED
@@ -283,7 +283,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
283
283
  | `pki.ocsp` | Online Certificate Status Protocol (RFC 6960), both the responder and relying-party surface. `buildRequest(query, opts)` builds an OCSPRequest for one or more `{ cert, issuer }` pairs, with the CertID hashed under SHA-1 by default per the RFC 5019 lightweight profile (obsoleted by RFC 9919) or under SHA-2, plus an optional RFC 9654 nonce (32 to 128 octets, the requester floor that section sets) and an optional requestor signature, under which the signer certificate's subject is the requestorName unless one is stated (RFC 6960 §4.1.2). `sign(responseData, responder, opts)` produces a signed BasicOCSPResponse over the exact `ResponseData` DER, from the issuing CA directly or a delegated responder, under any `pki.cms.sign` key including the post-quantum ML-DSA and SLH-DSA sets, with `good`, `revoked` (reason and time), or `unknown` per-certificate status, and per-response `singleExtensions` naming the §4.4.4 archive cutoff and the §4.4.2 CRL reference as fields, each read back by name by the parser. The signer refuses what its own lint would grade an error: a pre-encoded singleExtension that RFC 6960 places in requests or in responseExtensions, a CRL entry extension carried at the wrong criticality, and CRL references or entry extensions on a non-issued response, which itself places the §4.4.8 extended revoked definition in responseExtensions without being asked. `buildErrorResponse(status)` produces the unsigned §2.3 error (`tryLater`, `unauthorized`, and the rest). `verify(response, opts)` verifies a response fail-closed against the same hardened gates `pki.path.ocspChecker` runs: the CertID binding, responder authorization (the issuing CA, or a CA-issued delegate bearing id-kp-OCSPSigning and id-pkix-ocsp-nocheck and passing the full out-of-path certificate gates), the signature over `tbsResponseDataBytes`, currency against `thisUpdate` and `nextUpdate`, and the request-nonce echo. It returns `{ status: "good" / "revoked" / "unknown", ... }` and never silently accepts. `verifyRequest(request, opts)` is the responder-side inverse of `buildRequest`: it verifies a client's signed request (RFC 6960 §4.1.1) under the requestor certificate the request carries, trying each embedded certificate since the field is unordered, and reports `signed`, `signatureValid`, every certificate whose key verified the signature and whose subject is the `requestorName` the request states (`signerCerts`, so a key carried under an expired certificate beside its renewal is not hidden behind ordering, while a same-key certificate under another name is not the requestor) with the first one's decoded subject, the full embedded certificate bag (`certs`, including intermediates for path building), and the request's CertIDs; the name check is reported as `requestorNamed` (RFC 6960 §4.1.2) and `valid` requires it, a request no verifying certificate names falls back to listing the verifying ones with `valid: false`, and an unsigned request is reported with `signed: false` rather than refused. Transport-free — `buildRequest`, `sign`, `buildErrorResponse`, `verify`, `verifyRequest` |
284
284
  | `pki.ct` | Certificate Transparency (RFC 6962). `parseSctList` decodes the `SignedCertificateTimestampList` a certificate or OCSP response carries, 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, and `verifySct` verifies an SCT signature against a log's public key, routing an ECDSA signature through the strict DER-conformance gate and verifying through the crypto engine, resolving true or false and throwing a typed error on a structural fault. On the producing side, `encodeSctList` builds the extension value byte for byte as the exact inverse of `parseSctList`, and `signSct` performs a log's signing step. For trust, `parseLogList` ingests the CT log-list JSON into constraint-carrying trusted logs, recomputing each log's id as SHA-256 of its key and refusing a disagreeing id (a swapped key, §3.2) and decoding the state and temporal-interval constraints; `verifySctWithLogList` resolves the log key from an SCT's log id, enforces the state (usable, qualified, and readonly trusted; retired only before retirement; pending and rejected refused) and the temporal-interval window, then delegates the signature check to `verifySct`. `verifyLogListSignature(json, signature, publicKey)` verifies the detached `log_list.sig` over the raw log-list bytes against a caller-pinned signer key (RSASSA-PKCS1-v1.5/SHA-256 and an EC P-256 arm, with forgeable-key defenses failing closed), cross-checked against `openssl dgst`. `fetchLogList(opts)` turns that chain into a live client: it GETs the `log_list.json` and its detached `log_list.sig` over `pki.transport`, verifies the detached signature over the raw fetched bytes against a caller-pinned distributor key before parsing, so an unverified document is never parsed, read, cached, or surfaced, then ingests the same bytes through `parseLogList` and returns the trusted-log set plus the surfaced `version` and `timestamp`. Above the per-SCT layer, `verifySctList` renders a certificate-level verdict over every SCT a certificate carries (§3.3), reporting how many distinct trusted logs verified an SCT (so a duplicated SCT cannot inflate the count) and from how many distinct trusted operators against a caller's CT policy (`minScts` and `minOperators`, both defaulting to the RFC floor of one), where a policy shortfall is a verdict and a per-SCT failure is a recorded row rather than a thrown call; `x509CertEntry` reconstructs the precertificate log entry from a final certificate by removing the SCT-list extension from its TBSCertificate as byte surgery, so its embedded SCTs verify end to end (§3.2). The toolkit bakes in no vendor URL or key, TLS trust is explicit with `rejectUnauthorized` always on, each response is size-capped before the trust chain, and the transport is injectable so the whole path is testable offline — `parseSctList`, `reconstructSignedData`, `verifySct`, `encodeSctList`, `signSct`, `parseLogList`, `verifySctWithLogList`, `verifySctList`, `x509CertEntry`, `verifyLogListSignature`, `fetchLogList` |
285
285
  | `pki.merkle` | Merkle-tree proof verification (RFC 6962 / RFC 9162). `leafHash`, `nodeHash`, and `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, which is the append-only guarantee; each is constant-time-compared to a trusted checkpoint root. Fail-closed on bad geometry, sync hashing, transport-free — `leafHash`, `nodeHash`, `emptyRootHash`, `verifyInclusion`, `verifyConsistency` |
286
- | `pki.trust` | Mozilla and CCADB trust-store ingestion. `parseCertdata` reads the NSS `certdata.txt` object stream and `parseCcadbCsv` the CCADB CSV export, both into one constraint-carrying anchor shape: the per-purpose trust bits, where 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 and serial rather than adjacency and are cross-checked against the parsed DER, so metadata cannot attach to the wrong root. `anchor()` hands an entry to `pki.path.validate({ trustAnchors, checkPurpose })`, and `anchor(entry, { nameConstraints })` attaches the namespace a root program trusts a root for when that is narrower than the root certificate states, a `{ permitted, excluded }` pair of `{ tag, base }` subtrees that `validate` seeds as the RFC 5280 §6.1.1(h)(i) initial value, intersecting with each certificate's own constraints. `parseTrustAnchorList` reads the IETF's own interchange format for the same thing, an RFC 5914 `TrustAnchorList`, into the same entries, so a list a root program publishes drives `validate` without being mapped by hand. An RFC 5914 anchor's constraints are applied as RFC 5937 §3.2 states, which is that the stricter of the anchor's value and the caller's always wins: its name constraints intersect the caller's permitted subtrees and union its excluded ones, its policy set intersects `userInitialPolicySet`, each of its policy flags may turn the matching `initial*` option on and never off, and its path length bounds the path. `pki.trustanchor.build` writes the list. Offline, fail-closed, bounded — `parseCertdata`, `parseCcadbCsv`, `parseTrustAnchorList`, `anchor` |
286
+ | `pki.trust` | Mozilla and CCADB trust-store ingestion. `parseCertdata` reads the NSS `certdata.txt` object stream and `parseCcadbCsv` the CCADB CSV export, both into one constraint-carrying anchor shape: the per-purpose trust bits, where 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 and serial rather than adjacency and are cross-checked against the parsed DER, so metadata cannot attach to the wrong root. `anchor()` hands an entry to `pki.path.validate({ trustAnchors, checkPurpose })`, and `anchor(entry, { nameConstraints })` attaches the namespace a root program trusts a root for when that is narrower than the root certificate states, a `{ permitted, excluded }` pair of `{ tag, base }` subtrees that `validate` seeds as the RFC 5280 §6.1.1(h)(i) initial value, intersecting with each certificate's own constraints. `parseTrustAnchorList` reads the IETF's own interchange format for the same thing, an RFC 5914 `TrustAnchorList`, into the same entries, so a list a root program publishes drives `validate` without being mapped by hand. An RFC 5914 anchor's constraints are applied as RFC 5937 §3.2 states, which is that the stricter of the anchor's value and the caller's always wins: its name constraints intersect the caller's permitted subtrees and union its excluded ones, its policy set intersects `userInitialPolicySet`, each of its policy flags may turn the matching `initial*` option on and never off, and its path length bounds the path. `pki.trustanchor.build` writes the list. `systemAnchors` reads the host's own CA bundle into the same entries and names which set it read, `"ca-bundle"` with the path or `"node-bundled"` for the Mozilla set compiled into Node, since the two are not the same trust. A host whose roots are not in a readable PEM file is refused with the command that exports them, and a block that is not a certificate or a certificate the strict reader refuses is listed in `skipped` with its reason. Offline, fail-closed, bounded — `parseCertdata`, `parseCcadbCsv`, `parseTrustAnchorList`, `systemAnchors`, `anchor` |
287
287
  | `pki.shbs` | Stateful hash-based signature verification: HSS/LMS (RFC 8554), carried in X.509 by RFC 9802 and in CMS by RFC 9708, profiled by NIST SP 800-208 for CNSA 2.0 firmware signing. `verify` checks an HSS signature, where every level must pass, and `verifyLms` a single-tree LMS, over the raw public-key and signature blobs the parsers already surface. Pure public-input SHA-256 and SHAKE256 hashing, a data-driven typecode registry, and bounds-before-slice reads; a malformed blob throws a typed `ShbsError` while a well-formed but wrong signature returns `false`. Verification only by design, since stateful signing needs atomic one-time-key state that belongs in an HSM. `pki.path.validate` verifies an HSS-signed certificate on its own, so a chain reaching one needs no separate call: the signature covers the whole tbsCertificate rather than a digest of it (RFC 9802 §7.1), and a CertificateList naming the algorithm takes the same route. It is proven against the self-signed certificate RFC 9802 Appendix A publishes — `verify`, `verifyLms` |
288
288
  | `pki.hpke` | Hybrid Public Key Encryption (RFC 9180), the encrypt-to-a-public-key primitive behind TLS ECH, MLS, and OHTTP. `setupS` and `setupR` establish a sender or recipient context (KEM encapsulation plus the HKDF key schedule); the context's `seal` and `open` AEAD-encrypt with a sequence-counter nonce, and `export` derives further secrets; the module-level `seal` and `open` are single-shot wrappers. DHKEM (P-256, P-521, X25519, X448) in all four modes, proven against the RFC 9180 Appendix A vectors, and the post-quantum ML-KEM-512/768/1024 KEMs (IANA 0x0040 to 0x0042) in the base and psk modes, proven against the draft-ietf-hpke-pq Appendix A vectors; HKDF-SHA256/SHA384/SHA512 by AES-GCM / ChaCha20Poly1305 / export-only. DHKEM(P-384) has no vector under an HKDF key schedule and fails closed. Pure composition over `node:crypto`; the PQ/T hybrid KEMs are a registry data-row extension once the CFRG combiner draft stabilizes — `suites`, `setupS`, `setupR`, `seal`, `open` |
289
289
  | `pki.kem` | Composite ML-KEM key establishment (draft-ietf-lamps-pq-composite-kem), a post-quantum ML-KEM hybridized with a traditional RSA-OAEP, ECDH, X25519, or X448 so the established secret holds if either component is later broken. `encapsulate` turns a recipient's composite `SubjectPublicKeyInfo` into a 256-bit shared secret and a ciphertext; `decapsulate` recovers the same secret from the ciphertext and the composite PKCS#8 private key. Each component KEM runs independently and the two secrets are mixed through a SHA3-256 combiner that binds the traditional ciphertext, the traditional public key, and a per-algorithm label. The twelve algorithms pair ML-KEM-768 and ML-KEM-1024 with RSA-OAEP 2048/3072/4096, ECDH over P-256/P-384/P-521 and brainpoolP256r1/P384r1, X25519, and X448, each verified against the draft Appendix G known-answer vectors; a malformed key or ciphertext, an unsupported algorithm, or a component decapsulation failure throws a typed `KemError` — `encapsulate`, `decapsulate` |
package/lib/trust.js CHANGED
@@ -47,6 +47,8 @@ var _strSlice = intrinsic.uncurry(String.prototype.slice);
47
47
  var x509 = require("./schema-x509");
48
48
  var pkix = require("./schema-pkix");
49
49
  var oid = require("./oid");
50
+ var nodeFs = require("fs");
51
+ var nodeTls = require("tls");
50
52
  var trustanchorSchema = require("./schema-trustanchor");
51
53
 
52
54
  var TrustError = errors.TrustError;
@@ -1130,9 +1132,317 @@ function _entryFromAnchor(a, i) {
1130
1132
  return entry;
1131
1133
  }
1132
1134
 
1135
+ /** @internal The bundle paths a Unix host keeps its CA set in, in the order a reader should try
1136
+ * them. Each is a concatenated PEM file the distribution maintains. macOS and Windows keep theirs
1137
+ * in a binary keychain and a registry-backed store, so neither has a path here. */
1138
+ var _BUNDLE_PATHS = [
1139
+ "/etc/ssl/certs/ca-certificates.crt",
1140
+ "/etc/pki/tls/certs/ca-bundle.crt",
1141
+ "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",
1142
+ "/etc/ssl/ca-bundle.pem",
1143
+ "/etc/ssl/cert.pem",
1144
+ "/usr/local/share/certs/ca-root-nss.crt",
1145
+ ];
1146
+
1147
+ var _SYSTEM_ANCHOR_OPTS = intrinsic.assign(intrinsic.create(null), {
1148
+ path: 1, allowNodeBundle: 1, maxBytes: 1,
1149
+ });
1150
+
1151
+ /** @internal What an operator runs to produce a bundle this verb can read, per platform. Named in
1152
+ * the refusal, because the alternative an operator reaches for otherwise is Node's compiled-in
1153
+ * set, which is not what their host trusts. */
1154
+ function _exportHint() {
1155
+ if (process.platform === "darwin") {
1156
+ return "export the host's roots with: security find-certificate -a -p " +
1157
+ "/System/Library/Keychains/SystemRootCertificates.keychain > roots.pem";
1158
+ }
1159
+ if (process.platform === "win32") {
1160
+ return "export the host's roots with: certutil -store ROOT";
1161
+ }
1162
+ return "point opts.path at the CA bundle this host keeps";
1163
+ }
1164
+
1165
+ /**
1166
+ * @primitive pki.trust.discoversBundles
1167
+ * @signature pki.trust.discoversBundles(platform) -> boolean
1168
+ * @since 0.8.15
1169
+ * @status stable
1170
+ * @spec RFC 5280
1171
+ * @defends trust-store-misattribution (CWE-345)
1172
+ * @related pki.trust.systemAnchors
1173
+ *
1174
+ * Whether `systemAnchors` searches for a CA bundle on this platform. A Unix host's bundle IS the
1175
+ * set its TLS stack trusts. macOS keeps its trust decisions in Keychain and Windows in a
1176
+ * registry-backed store, and on macOS `/etc/ssl/cert.pem` can exist as OpenSSL's own copy, which
1177
+ * is not the host's trust and omits both locally installed roots and Keychain overrides. Reporting
1178
+ * that file as the host's anchors is the misattribution this verb exists to prevent, so those
1179
+ * platforms are refused rather than searched. `opts.path` still reads an exported bundle anywhere.
1180
+ *
1181
+ * @example
1182
+ * pki.trust.discoversBundles("linux"); // -> true
1183
+ * pki.trust.discoversBundles("darwin"); // -> false
1184
+ */
1185
+ function discoversBundles(platform) {
1186
+ if (typeof platform !== "string" || platform.length === 0) {
1187
+ throw E("trust/bad-input", "discoversBundles takes a process.platform string");
1188
+ }
1189
+ return platform !== "darwin" && platform !== "win32";
1190
+ }
1191
+
1192
+ /** @internal Reads at most `maxBytes + 1` so the cap bounds what is ALLOCATED rather than being
1193
+ * checked after an unbounded read: a file far above the cap must not reach memory first. */
1194
+ function _readBundleText(chosen, maxBytes) {
1195
+ var fd = null, raw, read;
1196
+ try {
1197
+ fd = nodeFs.openSync(chosen, "r");
1198
+ var buf = intrinsic.bufferAlloc(maxBytes + 1);
1199
+ /** @internal readSync may return fewer bytes than asked for, so the prefix of one call is not
1200
+ * the file. Taking it as the whole bundle would drop the roots below the break with nothing
1201
+ * reported, and would let a file above the cap pass the check underneath it. */
1202
+ read = 0;
1203
+ for (;;) {
1204
+ var got = nodeFs.readSync(fd, buf, read, (maxBytes + 1) - read, read);
1205
+ if (got === 0) break;
1206
+ read += got;
1207
+ if (read >= maxBytes + 1) break;
1208
+ }
1209
+ raw = buf.subarray(0, read);
1210
+ } catch (e) {
1211
+ throw E("trust/no-readable-store", "no CA bundle could be read at " + guard.text.showValue(chosen) +
1212
+ "; " + _exportHint() + ", or pass opts.allowNodeBundle to use the Mozilla set compiled into " +
1213
+ "Node, which is NOT what this host trusts", e);
1214
+ } finally {
1215
+ if (fd !== null) {
1216
+ try { nodeFs.closeSync(fd); }
1217
+ // allow:swallow-unverified the descriptor is released on a best-effort basis
1218
+ catch (_e) { void 0; }
1219
+ }
1220
+ }
1221
+ if (read > maxBytes) {
1222
+ throw E("trust/too-large", "the CA bundle at " + guard.text.showValue(chosen) +
1223
+ " is larger than the " + maxBytes + "-byte cap this read admits");
1224
+ }
1225
+ return raw;
1226
+ }
1227
+
1228
+ /**
1229
+ * @primitive pki.trust.systemAnchors
1230
+ * @signature pki.trust.systemAnchors(opts?) -> { anchors, source, path, skipped }
1231
+ * @since 0.8.15
1232
+ * @status stable
1233
+ * @spec RFC 7468 (PEM), RFC 5280
1234
+ * @defends trust-store-misattribution (CWE-345), trust-store-parser-DoS (CWE-770)
1235
+ * @related pki.trust.parseCertdata, pki.trust.parseCcadbCsv, pki.trust.anchor, pki.path.validate
1236
+ *
1237
+ * Read the host's CA bundle into the trust anchors `pki.path.validate` consumes. `source` names
1238
+ * which set was read: `"ca-bundle"` for a file on this host, with `path` naming it, or
1239
+ * `"node-bundled"` for the Mozilla set compiled into Node, whose `path` is `null`. A block the
1240
+ * bundle carries that is not a certificate is listed in `skipped` with the label that named it,
1241
+ * rather than dropped.
1242
+ *
1243
+ * A host whose roots are not in a readable PEM file is refused, and the refusal names the command
1244
+ * that exports them. macOS keeps its roots in a binary keychain and Windows in a registry-backed
1245
+ * store, and reading either means running another program, which this toolkit does not do. Falling
1246
+ * back to Node's set instead would report anchors the host does not use, so that takes the
1247
+ * explicit `opts.allowNodeBundle`.
1248
+ *
1249
+ * @opts
1250
+ * - `path` -- read this file instead of searching the per-platform list.
1251
+ * - `allowNodeBundle` -- return Node's compiled-in set when no bundle can be read.
1252
+ * - `maxBytes` -- cap the bundle read (default `C.LIMITS.TRUST_MAX_BYTES`).
1253
+ *
1254
+ * @example
1255
+ * // Read the host's bundle where there is one, and say so when there is not. Check `source`
1256
+ * // before trusting the answer: "node-bundled" is Node's compiled-in set, not this machine's.
1257
+ * var store = pki.trust.systemAnchors({ allowNodeBundle: true });
1258
+ * console.log(store.source); // -> "ca-bundle" on a Unix host, else "node-bundled"
1259
+ * console.log(store.anchors.length > 0); // -> true
1260
+ * console.log(store.skipped.length >= 0); // -> true (entries named, never dropped silently)
1261
+ */
1262
+ function systemAnchors(opts) {
1263
+ opts = opts || {};
1264
+ guard.identifier.assertPlainRecord(opts, E, "trust/bad-input", "systemAnchors opts");
1265
+ guard.identifier.assertKnownKeys(opts, _SYSTEM_ANCHOR_OPTS, E, "trust/bad-input",
1266
+ "unknown systemAnchors option ");
1267
+ var maxBytes = guard.limits.cap(opts.maxBytes, "systemAnchors: opts.maxBytes",
1268
+ LIMITS.TRUST_MAX_BYTES, { E: E, code: "trust/bad-input", min: 1, max: LIMITS.TRUST_MAX_BYTES });
1269
+
1270
+ var chosen = null;
1271
+ if (opts.path !== undefined) {
1272
+ if (typeof opts.path !== "string" || opts.path.length === 0) {
1273
+ throw E("trust/bad-input", "systemAnchors: opts.path must be a non-empty string");
1274
+ }
1275
+ chosen = opts.path;
1276
+ }
1277
+
1278
+ var text = null;
1279
+ if (chosen !== null) {
1280
+ /** @internal `allowNodeBundle` says what to do when there is NO readable bundle, so an absent
1281
+ * file falls back to Node's set under it. A bundle that EXISTS and breaks the cap does not:
1282
+ * the caller asked about that file, and a resource refusal must never quietly swap the trust
1283
+ * set a validation runs against for a different one. */
1284
+ try { text = _readBundleText(chosen, maxBytes); }
1285
+ catch (e) {
1286
+ if (opts.allowNodeBundle !== true || !e || e.code !== "trust/no-readable-store") throw e;
1287
+ text = null;
1288
+ chosen = null;
1289
+ }
1290
+ } else if (discoversBundles(process.platform)) {
1291
+ for (var i = 0; i < _BUNDLE_PATHS.length && text === null; i++) {
1292
+ /** @internal Readability is established by READING, not by a stat: a candidate that exists
1293
+ * but cannot be opened must not stop the search and strand a readable one below it. */
1294
+ try { text = _readBundleText(_BUNDLE_PATHS[i], maxBytes); chosen = _BUNDLE_PATHS[i]; }
1295
+ catch (e) {
1296
+ if (e && e.code === "trust/too-large") throw e;
1297
+ // allow:swallow-unverified a candidate this host cannot read is not this host's bundle
1298
+ text = null;
1299
+ }
1300
+ }
1301
+ }
1302
+
1303
+ if (text === null) {
1304
+ if (opts.allowNodeBundle !== true) {
1305
+ throw E("trust/no-readable-store", "this host keeps no CA bundle this toolkit can read" +
1306
+ (chosen === null ? "" : " at " + guard.text.showValue(chosen)) + "; " + _exportHint() +
1307
+ ", or pass opts.allowNodeBundle to use the Mozilla set compiled into Node, which is NOT " +
1308
+ "what this host trusts");
1309
+ }
1310
+ return _anchorsFromPems(nodeTls.rootCertificates, "node-bundled", null);
1311
+ }
1312
+ return _anchorsFromBundle(text, chosen);
1313
+ }
1314
+
1315
+ /** @internal A line that reads as a BEGIN boundary but does not start one, because RFC 7468 sec. 2
1316
+ * puts the boundary at the start of a line and this one is indented or trailed. The decoder is
1317
+ * right to pass over it as explanatory text, and in a trust bundle that is an anchor the operator
1318
+ * believes they installed and does not have. Reported with the offset, never a throw: the file is
1319
+ * readable and the other roots in it are usable. */
1320
+ /** @internal The bytes of "-----BEGIN ", held as codes because this directory scans by character
1321
+ * code rather than by pattern. */
1322
+ var _BEGIN_MARK = [0x2d, 0x2d, 0x2d, 0x2d, 0x2d, 0x42, 0x45, 0x47, 0x49, 0x4e, 0x20];
1323
+
1324
+ function _reportUnusableBoundaries(bytes, rows, skipped) {
1325
+ var used = intrinsic.create(null);
1326
+ for (var r = 0; r < rows.length; r++) used[rows[r].offset] = true;
1327
+ var lineStart = 0;
1328
+ for (var i = 0; i <= bytes.length; i++) {
1329
+ if (i !== bytes.length && bytes[i] !== 0x0a) continue;
1330
+ var at = lineStart;
1331
+ while (at < i && (bytes[at] === 0x20 || bytes[at] === 0x09)) at += 1;
1332
+ var indented = at > lineStart;
1333
+ var matches = indented && at + _BEGIN_MARK.length <= i;
1334
+ for (var m = 0; matches && m < _BEGIN_MARK.length; m++) {
1335
+ if (bytes[at + m] !== _BEGIN_MARK[m]) matches = false;
1336
+ }
1337
+ if (matches && used[at] !== true) {
1338
+ guard.list.append(skipped, {
1339
+ index: null, offset: at, label: null,
1340
+ reason: "pem/unusable-boundary: a BEGIN boundary at byte " + at + " does not start its " +
1341
+ "line, so RFC 7468 sec. 2 reads it as text and the object it opens is not decoded",
1342
+ });
1343
+ }
1344
+ lineStart = i + 1;
1345
+ }
1346
+ }
1347
+
1348
+ /** @internal Every certificate block becomes an anchor and every other block is reported, because
1349
+ * a bundle an operator assembled by hand is exactly where a key or a CRL ends up, and a reader
1350
+ * that drops one silently leaves them believing it was read. */
1351
+ function _anchorsFromBundle(text, chosen) {
1352
+ var rows;
1353
+ try { rows = pkix.pemDecodeBundle(text, {}, errors.PemError); }
1354
+ catch (e) {
1355
+ if (e && e.code === "pem/no-block") {
1356
+ throw E("trust/no-anchors", "the CA bundle at " + guard.text.showValue(chosen) +
1357
+ " carries no PEM block, so it names no trust anchor", e);
1358
+ }
1359
+ throw E("trust/bad-bundle", "the CA bundle at " + guard.text.showValue(chosen) +
1360
+ " is not readable as PEM: " + guard.text.describeThrown(e), e);
1361
+ }
1362
+ var anchors = [], skipped = [];
1363
+ _reportUnusableBoundaries(text, rows, skipped);
1364
+ for (var i = 0; i < rows.length; i++) {
1365
+ var row = rows[i];
1366
+ if (row.label === "TRUSTED CERTIFICATE") {
1367
+ /** @internal OpenSSL's TRUSTED CERTIFICATE is a Certificate followed by an X509_CERT_AUX
1368
+ * carrying the purposes the operator trusted it FOR. Reading the leading certificate and
1369
+ * dropping the aux would hand back a root restricted to one purpose as an unrestricted
1370
+ * anchor, which is a widening, so the block is reported until the aux is read. */
1371
+ guard.list.append(skipped, {
1372
+ index: row.index, offset: row.offset, label: row.label,
1373
+ reason: "trust/aux-not-read: an OpenSSL trusted certificate carries the purposes it is " +
1374
+ "trusted for in a trailing X509_CERT_AUX this reader does not decode, and ingesting it " +
1375
+ "without them would widen the trust it states; convert it with " +
1376
+ "`openssl x509 -in <file> -out <file>.pem` and pass the result",
1377
+ });
1378
+ continue;
1379
+ }
1380
+ if (row.label !== "CERTIFICATE") {
1381
+ guard.list.append(skipped, {
1382
+ index: row.index, offset: row.offset, label: row.label,
1383
+ reason: "trust/not-a-certificate-block: a " + guard.text.showValue(row.label) +
1384
+ " block names no trust anchor",
1385
+ });
1386
+ continue;
1387
+ }
1388
+ var cert = null, why = null;
1389
+ try { cert = x509.parse(row.der); }
1390
+ catch (e) { why = (e && e.code ? e.code : "trust/not-a-certificate") + ": " + guard.text.describeThrown(e); }
1391
+ if (cert === null) {
1392
+ /** @internal Reported, not thrown. A host CA bundle is distribution-managed and real ones
1393
+ * carry certificates this toolkit refuses: the Mozilla set Node ships has a root encoding
1394
+ * pre-2050 dates as GeneralizedTime against the RFC 5280 sec. 4.1.2.5 MUST. Refusing the
1395
+ * whole file would leave the operator with no anchors over a root they do not control,
1396
+ * and relaxing the reader to admit it would weaken every other caller. The entry is named
1397
+ * with the reason instead, so a missing anchor is visible rather than silent. */
1398
+ guard.list.append(skipped, { index: row.index, offset: row.offset, label: row.label, reason: why });
1399
+ continue;
1400
+ }
1401
+ guard.list.append(anchors, _mkAnchor(cert, {
1402
+ distrustAfter: {}, purposes: null, label: null, mozillaCaPolicy: false,
1403
+ }));
1404
+ }
1405
+ if (anchors.length === 0) {
1406
+ throw E("trust/no-anchors", "the CA bundle at " + guard.text.showValue(chosen) +
1407
+ " carries no certificate, so it names no trust anchor");
1408
+ }
1409
+ return _systemResult(anchors, "ca-bundle", chosen, skipped);
1410
+ }
1411
+
1412
+ function _anchorsFromPems(pems, source, chosen) {
1413
+ var anchors = [], skipped = [];
1414
+ for (var i = 0; i < pems.length; i++) {
1415
+ var cert = null, why = null;
1416
+ try { cert = x509.parse(x509.pemDecode(pems[i], "CERTIFICATE")); }
1417
+ catch (e) { why = (e && e.code ? e.code : "trust/not-a-certificate") + ": " + guard.text.describeThrown(e); }
1418
+ if (cert === null) {
1419
+ guard.list.append(skipped, { index: i, offset: null, label: "CERTIFICATE", reason: why });
1420
+ continue;
1421
+ }
1422
+ guard.list.append(anchors, _mkAnchor(cert, {
1423
+ distrustAfter: {}, purposes: null, label: null, mozillaCaPolicy: false,
1424
+ }));
1425
+ }
1426
+ if (anchors.length === 0) throw E("trust/no-anchors", "the " + source + " set carries no certificate");
1427
+ return _systemResult(anchors, source, chosen, skipped);
1428
+ }
1429
+
1430
+ /** @internal Defined rather than assigned, and a fresh list each call: the caller owns what it is
1431
+ * handed, so emptying it must not change what the next call returns. */
1432
+ function _systemResult(anchors, source, chosen, skipped) {
1433
+ var out = intrinsic.create(null);
1434
+ guard.verdict.set(out, "anchors", anchors);
1435
+ guard.verdict.set(out, "source", source);
1436
+ guard.verdict.set(out, "path", chosen);
1437
+ guard.verdict.set(out, "skipped", skipped);
1438
+ return out;
1439
+ }
1440
+
1133
1441
  module.exports = {
1134
1442
  parseCertdata: parseCertdata,
1135
1443
  parseCcadbCsv: parseCcadbCsv,
1136
1444
  parseTrustAnchorList: parseTrustAnchorList,
1445
+ systemAnchors: systemAnchors,
1446
+ discoversBundles: discoversBundles,
1137
1447
  anchor: anchor,
1138
1448
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.8.14",
3
+ "version": "0.8.15",
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:bdd8bb52-b998-41cd-a57e-6c673739a7a6",
5
+ "serialNumber": "urn:uuid:fd715296-3209-4fc3-85ee-b6af7fbfdd1c",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-09-24T03:02:34.975Z",
8
+ "timestamp": "2026-09-24T03:48:38.286Z",
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.8.14",
22
+ "bom-ref": "@blamejs/pki@0.8.15",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.8.14",
25
+ "version": "0.8.15",
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.8.14",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.8.15",
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.8.14",
57
+ "ref": "@blamejs/pki@0.8.15",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]