@blamejs/pki 0.8.14 → 0.8.16

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,40 @@ 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.16 — 2026-09-24
8
+
9
+ A certification request is linted before it is issued, against the rows its certificate will be held to.
10
+
11
+ ### Added
12
+
13
+ - `pki.lint.csr(pem | der | parsed, opts?)` lints a PKCS#10 certification request and returns the `LintReport` shape the other lint verbs return. It never throws on the data path: bytes that are not a well-formed request produce a single `fatal` finding `lint/unparseable` carrying the strict parser's own code.
14
+ - The `rfc2986` profile covers what the request asks for: an empty subject that names no identity, an empty subject whose subjectAltName is not critical (RFC 5280 section 4.1.2.6), a request for an extension the issuer determines rather than takes from a request, a request asking for a CA certificate, a request signed under a broken digest, and a challengePassword traveling inside the request. The broken digests are named outright rather than read out of the algorithm name, because `shaWithRSAEncryption` is the original SHA, now SHA-0, and its name carries no digest to read.
15
+ - `opts.profile: "cabf-tls"` adds the CA/Browser Forum rows: a commonName no subjectAltName covers, a malformed requested dNSName, a request naming no subjectAltName, and a key below the TLS minimum. They run only when named, because a request carries no extKeyUsage and nothing in its bytes says it is for TLS. Each is the certificate profile's own row, applied to what the request asks for.
16
+ - A request carrying more than one extensionRequest attribute is reported, and each is linted as the request it states. Two parse where they sort in DER order, and a reader asking for an extension by name takes the first match, so a basicConstraints of cA FALSE in one attribute would otherwise hide a cA TRUE in the next and the answer would be decided by how the bytes happened to sort.
17
+ - A requested extension whose value does not decode under its own syntax is reported, by the RFC 5280 section 4.2 row the certificate profile already carries. It counted as a passing check before, because the shared decoder returns null for a value it cannot read and every row asking for an extension by name reads that null as absence.
18
+ - The weak-digest row reads an RSASSA-PSS request's digest from its parameters rather than its algorithm name. RFC 4055 section 3.1 makes the hashAlgorithm field default to SHA-1, so absent parameters and an empty parameter sequence both name SHA-1: the encoding that appears to say nothing names the weakest digest.
19
+ - `pki.lint.rules("rfc2986")` lists the new rows with their citations, and `pki.lint.profiles()` enumerates `rfc2986` alongside the existing names.
20
+ - `pki.lint.rules(profile, artifact)` takes an optional second argument, one of `"certificate"`, `"csr"`, `"crl"` or `"ocsp"`. `"cabf-tls"` names a set for both a certificate and a request and the two are not the same rows, so without it a reader asking about the request was given the certificate-only extKeyUsage and validity rows and none of the structural ones. Calls passing one argument are unchanged. Each id now appears once in the listing, including where two artifacts run the same rule.
21
+
22
+ ### Changed
23
+
24
+ - `md2WithRSAEncryption`, `md4WithRSAEncryption-pkcs1`, `md5WithRSAEncryption`, `sha1WithRSAEncryption`, `ecdsaWithSHA1` and `dsaWithSha1` resolve through `pki.oid`, so an artifact signed under one reads back with a name instead of a bare dotted string. The weak-digest row reads that name, so an algorithm the registry could not name passed it silently. Nothing signs or verifies from that registry: the signing and verifying paths carry their own tables and neither has a row for these, so naming one does not make it usable. A request or certificate carrying one still fails to verify.
25
+ - Every name in the OID registry resolves back to the OID it names, which is now checked. The registry is two-way and its directions did not agree about a repeated name: the first `name -> OID` registration was kept while `OID -> name` took the last, so adding a name a family already held would silently repoint every lookup of it and leave the original OID still answering with that name. X.501 and X.520 both standardize `clearance`, at 2.5.1.5.55 and 2.5.4.55, and that pair is recorded as the one intended duplicate. PKCS#1's MD4 signature is registered as `md4WithRSAEncryption-pkcs1` for the same reason: the plain name already belongs to the OIW arc's 1.3.14.3.2.4.
26
+
27
+ ## v0.8.15 — 2026-09-24
28
+
29
+ The host's own CA bundle becomes trust anchors, and says which set you got.
30
+
31
+ ### Added
32
+
33
+ - `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.
34
+ - 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.
35
+ - `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.
36
+ - 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.
37
+ - 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.
38
+ - 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.
39
+ - `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.
40
+
7
41
  ## v0.8.14 — 2026-09-24
8
42
 
9
43
  The 0.8.14 tarball is the one to install for everything 0.8.13 added.
package/README.md CHANGED
@@ -283,14 +283,14 @@ 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` |
290
290
  | `pki.sigstore` | Offline verifier for a Sigstore bundle, the artifact `npm publish --provenance` produces and the registry serves. `verifyBundle` composes five fail-closed legs against caller-pinned trust (Fulcio CA roots and 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. It reuses the X.509 parser, the RFC 5280 path validator, and the Merkle verifier; the net-new codecs are the DSSE PAE byte-builder and a fail-closed JSON reader. `verified: true` says the artifact was signed and logged, not that a party you trust signed it — Fulcio issues to anyone who completes an OIDC flow, so who signed is decided only by `opts.identity`, and `identityChecked` reports which of its fields were compared. An identity policy naming no field, or a field name that is not one of the three, is refused rather than satisfied — either would accept every signer while reading as a policy in force. A sixth leg is opt-in: pinning the certificate-transparency logs in `opts.ctLogs` checks the receipt Fulcio embeds in the certificate it issues (RFC 6962 §3.2), verified over the certificate as it stood before the receipt was added and under the key of the log that issued it, so the verdict says the signing certificate was public when it was issued rather than handed out quietly. At least one receipt must verify against a pinned log, the verdict reports `sctChecked` and `validScts`, and an empty log set is refused rather than read as a policy that checks nothing. Both content arms are read: a DSSE-wrapped in-toto attestation, and a message signature over an artifact's own bytes, which `cosign sign-blob` produces. For that arm `opts.artifact` supplies the bytes, and they are held to the hash the Rekor `hashedrekord` entry records before the signature is checked over them; the `messageDigest` the bundle carries is covered by no signature, so it is compared against the computed digest and never used in its place. `contentType` names the arm and the verdict has one shape for both — `pae`, `parseBundle`, `verifyBundle` |
291
291
  | `pki.inspect` | Human-readable inspection, the pure-JS equivalent of `openssl x509/crl/req/cms -text`. `certificate(pem \| der \| parsed)` renders an OpenSSL-style report: version, serial, signature algorithm, issuer and subject distinguished names, validity, public-key details (curve or modulus size plus the raw point or modulus), every decoded extension with its critical flag, and the signature. `crl`, `csr`, and `cms` render the other formats the same way — a CRL like `openssl crl -text`, a CSR like `openssl req -text`, and a CMS message like `openssl cms -cmsout -print`, with a stable summary for a non-SignedData ContentInfo — and `any(input)` detects the format and routes to the right report. Built over the strict parsers and the two-way OID registry with one set of field renderers, it names extension and algorithm OIDs an OpenSSL build shows only as raw bytes. No OpenSSL dependency, and the format is stable and OpenSSL-familiar rather than pinned to one OpenSSL version. A certificate policy's user notice renders as text, both its explicit text and a notice reference with the notice numbers that identify it, rather than hex, and a malformed part falls back to a hex dump rather than throwing — `certificate`, `crl`, `csr`, `cms`, `any` |
292
292
  | `pki.webauthn` | WebAuthn and passkey verification, both halves: offline trust evaluation of a W3C WebAuthn (Level 3) registration, and signature verification of the assertion every login returns. `parseAttestationObject(bytes)` decodes the CBOR attestation object, authenticatorData, and COSE credential key over the strict `pki.cbor` codec; `parseAuthenticatorData(bytes)` reads the bare form an assertion carries through the same parser; `parseClientData(bytes, opts)` decodes the `clientDataJSON` no signature check looks inside, through the shared JSON guard since these are attacker-chosen bytes, returning the challenge decoded so a caller compares bytes rather than spellings, and checking the ceremony type, challenge, and origin when the relying party supplies what it issued. `verifyAssertion(input)` verifies an assertion signature over `authenticatorData \|\| SHA-256(clientDataJSON)` — raw bytes, no COSE_Sign1, an ES256 signature in ASN.1 DER — and applies the §7.2 step 21 counter rule when a stored `previousSignCount` is given, so a counter that fails to advance is refused as a cloned authenticator. `verify(attestationObject, clientDataHash, opts)` checks the attestation-statement signature and each format's structural bindings for packed, tpm, android-key, apple, fido-u2f, and none: the x5c leaf key, the apple nonce, the tpm `certInfo` Name and `extraData` over the `pubArea`, the android `KeyDescription`, and the fido-u2f `verificationData`. It binds the credential public key to each attestation, through the signed authenticatorData for packed and fido-u2f or a cert or `pubArea`-key equality check for android-key, apple, and tpm, and enforces each leaf's certificate requirements. The credential-key check covers the full WebAuthn COSE algorithm set — ES256/384/512, RS256/384/512, PS256/384/512, 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. The verdict field is `attestationVerified`, and `signatureVerified` for an assertion, rather than a bare `verified`, because a sound statement is a different claim from an acceptable ceremony: an attestation naming another origin's RP ID with user presence clear is perfectly sound and must not be registered. Pass `expectedRpId`, `requireUserPresence`, `requireUserVerification`, or `allowedAlgorithms` and those are checked, with `bindingChecked` reporting which ran, so a check that passed can be told from one that never happened. Pass `opts.clientDataJSON` instead of the digest and the client data is read here too: the ceremony type is checked unconditionally, since a login response replayed into a registration is what that rule stops, and the challenge, origin, and top-level origin are checked against what the relying party issued, with `clientData.checked` reporting which comparisons ran. A registration verdict also carries the `credentialId`, `credentialPublicKey`, and initial `signCount` a later login needs. A credential key declaring COSE algorithm `-65535` (RSASSA-PKCS1-v1_5 with SHA-1) is refused unless `allowedAlgorithms` names it, since every signature that credential ever makes would use SHA-1. Anchoring the trust path has two routes: `opts.metadata` resolves the roots the authenticator's own model registered, and `opts.rootCertificates` pins roots directly, which is what anchors the formats FIDO MDS does not cover, Apple's authenticators and the Google hardware-attestation roots among them. `metadata` governs when both are given, and `anchoredTo` names every route that anchored the path, joined with `+` when more than one did: `"metadata"`, `"rootCertificates"`, and `"safetyNetRoots"` for the android-safetynet chain, which anchors through the roots that format requires whether or not either other route was asked for. It is `null` only when nothing anchored the path. `verifyMetadataBlob(blob, opts)` reads a FIDO Metadata Service (MDS v3) BLOB, the signed catalogue of registered authenticator models, verifying its JWS and chaining its signer to an operator-supplied FIDO root before the payload is parsed, with sequence-number rollback and `nextUpdate` freshness checks. Passing the result as `opts.metadata` to `verify` resolves the authenticator's registered attestation roots from its identifier and requires the trust path to fully validate to one of them, refusing an unlisted or revoked model. `metadataAnchors(entry, opts)` applies the same status gate for a caller anchoring the path themselves: a model the catalogue has disqualified registers no anchors. Both of the catalogue's key spaces are covered: an aaguid, and the attestation-certificate key identifiers a U2F authenticator is listed under instead. No FIDO root is bundled, there is no trust-on-first-use, and retrieving the BLOB is out of scope. Fail-closed with typed `webauthn/*` errors — `parseAttestationObject`, `verify`, `verifyMetadataBlob`, `metadataFor`, `metadataAnchors` |
293
- | `pki.lint` | Certificate linting, the zlint or 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, the post-quantum certificate profiles, and a representative CA/Browser Forum TLS BR subset: serial sign and size, validity ordering and the SC081v3 reducing validity schedule, keyCertSign coherence, extension criticality (basicConstraints, nameConstraints, policyConstraints and inhibitAnyPolicy must be critical, and keyUsage should be), nameConstraints CA-scope, unknown critical extensions, empty-subject SAN, SKI and AKI presence including the end-entity subjectKeyIdentifier, the six extensions a conforming CA must mark non-critical (reported by clause rather than as unrecognized) and the two it should (issuerAltName, cRLDistributionPoints), a policy mapping to or from anyPolicy, a unique identifier a conforming CA must not generate, a critical subjectAltName beside a non-empty subject, a critical extKeyUsage naming anyExtendedKeyUsage, a userNotice noticeRef, SAN required and CN-in-SAN, dNSName syntax, serverAuth EKU, weak keys, the post-quantum key-usage rules binding a certificate's keyUsage bits to its subject key type (ML-KEM under RFC 9935 §5, ML-DSA under RFC 9881 §5, and SLH-DSA under RFC 9909 §6 across both its pure and prehash identifiers) together with the ML-KEM encapsulation-key encoding and size, and the §4.2.1.4 certificate-policy user-notice rules (a VisibleString or BMPString explicitText, a notice past 200 characters, an empty notice, control characters, and a non-NFC UTF8String notice, each at the strength the clause states). Alone among these entries the data path never throws: hostile bytes return a `fatal` `lint/unparseable` finding carrying the strict parser's code, so a whole directory lints without a try/catch, and only config-time misuse throws a typed `LintError` — `certificate`, `crl` (the RFC 5280 §5 revocation-list profile: nextUpdate presence, update ordering, cRLNumber presence and its 20-octet ceiling, authorityKeyIdentifier presence, the criticality each profiled CRL and entry extension must carry, freshestCRL in a delta CRL, and unrecognized critical extensions), `ocsp` (the RFC 6960 response profile: an empty signature, each SingleResponse's update ordering and its relation to producedAt, an empty `certs` field, the extended revoked definition and the fixed shape of a revoked answer for a non-issued certificate, the list each extension belongs in, the criticality and value syntax of the CRL entry extensions a SingleResponse may carry, unrecognized critical extensions, the reasons unspecified and removeFromCRL, and two answers for one CertID; the RFC 5019 lightweight rows run on request), `rules`, `profiles` |
293
+ | `pki.lint` | Certificate linting, the zlint or 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, the post-quantum certificate profiles, and a representative CA/Browser Forum TLS BR subset: serial sign and size, validity ordering and the SC081v3 reducing validity schedule, keyCertSign coherence, extension criticality (basicConstraints, nameConstraints, policyConstraints and inhibitAnyPolicy must be critical, and keyUsage should be), nameConstraints CA-scope, unknown critical extensions, empty-subject SAN, SKI and AKI presence including the end-entity subjectKeyIdentifier, the six extensions a conforming CA must mark non-critical (reported by clause rather than as unrecognized) and the two it should (issuerAltName, cRLDistributionPoints), a policy mapping to or from anyPolicy, a unique identifier a conforming CA must not generate, a critical subjectAltName beside a non-empty subject, a critical extKeyUsage naming anyExtendedKeyUsage, a userNotice noticeRef, SAN required and CN-in-SAN, dNSName syntax, serverAuth EKU, weak keys, the post-quantum key-usage rules binding a certificate's keyUsage bits to its subject key type (ML-KEM under RFC 9935 §5, ML-DSA under RFC 9881 §5, and SLH-DSA under RFC 9909 §6 across both its pure and prehash identifiers) together with the ML-KEM encapsulation-key encoding and size, and the §4.2.1.4 certificate-policy user-notice rules (a VisibleString or BMPString explicitText, a notice past 200 characters, an empty notice, control characters, and a non-NFC UTF8String notice, each at the strength the clause states). Alone among these entries the data path never throws: hostile bytes return a `fatal` `lint/unparseable` finding carrying the strict parser's code, so a whole directory lints without a try/catch, and only config-time misuse throws a typed `LintError` — `certificate`, `crl` (the RFC 5280 §5 revocation-list profile: nextUpdate presence, update ordering, cRLNumber presence and its 20-octet ceiling, authorityKeyIdentifier presence, the criticality each profiled CRL and entry extension must carry, freshestCRL in a delta CRL, and unrecognized critical extensions), `ocsp` (the RFC 6960 response profile: an empty signature, each SingleResponse's update ordering and its relation to producedAt, an empty `certs` field, the extended revoked definition and the fixed shape of a revoked answer for a non-issued certificate, the list each extension belongs in, the criticality and value syntax of the CRL entry extensions a SingleResponse may carry, unrecognized critical extensions, the reasons unspecified and removeFromCRL, and two answers for one CertID; the RFC 5019 lightweight rows run on request), `csr` (the artifact a CA reads before it issues: an empty subject naming no identity, an empty subject whose subjectAltName is not critical, a request for an extension the issuer determines, a request asking for a CA certificate, a broken signing digest, and a challengePassword traveling with the request; `opts.profile: "cabf-tls"` adds the certificate profile's own commonName-in-SAN, dNSName syntax, SAN-required and key-strength rows, applied to what the request asks for, and they run only when named because a request carries no extKeyUsage. It does not check the request's signature, which is a cryptographic operation `pki.csr.verify` owns), `rules`, `profiles` |
294
294
  | `pki.C` / `pki.constants` | Version-stable constants: the functional scale helpers `C.TIME.*` and `C.BYTES.*`, the codec `LIMITS`, and `version` |
295
295
  | `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`, and `LintError`, each carrying a stable `code` in `domain/reason` form |
296
296
  | `pki` CLI | `pki version`, `pki oid <dotted\|name>`, `pki parse <cert>`, `pki inspect <cert>`, `pki lint <cert\|crl\|ocsp-response>`, `pki convert <file> --to der\|pem`, `pki verify <cert>... --anchor <cert>`, `pki sign <file> --cert <c> --key <k>` |
package/lib/lint.js CHANGED
@@ -34,6 +34,7 @@ var guard = require("./guard-all");
34
34
  var oid = require("./oid");
35
35
  var x509 = require("./schema-x509");
36
36
  var crlSchema = require("./schema-crl");
37
+ var csrSchema = require("./schema-csr");
37
38
  var ocspSchema = require("./schema-ocsp");
38
39
  var pkix = require("./schema-pkix");
39
40
  var schema = require("./schema-engine");
@@ -1774,6 +1775,163 @@ var RFC5019_OCSP_RULES = [
1774
1775
  var OCSP_PROFILES = Object.assign(Object.create(null), { "rfc6960": RFC6960_OCSP_RULES, "rfc5019": RFC5019_OCSP_RULES });
1775
1776
  var ALL_OCSP_RULES = RFC6960_OCSP_RULES.concat(RFC5019_OCSP_RULES);
1776
1777
 
1778
+ /** @internal The extensions an issuer determines, which a request may carry and a CA does not take
1779
+ * from one. subjectKeyIdentifier and authorityKeyIdentifier are computed from the keys, and the
1780
+ * three pointers name services the issuer operates. A request for any of them is not a fault in
1781
+ * the request, so these are reported at notice: the CA simply does not honor them. */
1782
+ var CA_DETERMINED_EXTENSIONS = [
1783
+ "authorityKeyIdentifier", "subjectKeyIdentifier", "authorityInfoAccess",
1784
+ "cRLDistributionPoints", "freshestCRL",
1785
+ ];
1786
+
1787
+ /** @internal The signature algorithms a request must not be signed under. A certification request
1788
+ * is signed by the requester's own key, and the signature is the only evidence the requester
1789
+ * holds it, so a collision-broken digest removes the one thing the structure proves. */
1790
+ var WEAK_CSR_SIG_DIGESTS = ["md2", "md4", "md5", "sha1"];
1791
+
1792
+ /** @internal Named outright, because reading the digest out of the algorithm's NAME misses the one
1793
+ * whose name does not carry it: `shaWithRSAEncryption` is the original SHA, now SHA-0, and it
1794
+ * spells neither "sha1" nor any other substring above. The set is resolved through the registry
1795
+ * rather than written as OIDs, so a name that stops resolving fails the round-trip gate in
1796
+ * oid.test.js rather than silently dropping out of this set. */
1797
+ var WEAK_CSR_SIG_OIDS = Object.create(null);
1798
+ ["shaWithRSAEncryption",
1799
+ "md2WithRSAEncryption", "md4WithRSAEncryption", "md4WithRSAEncryption-pkcs1",
1800
+ "md5WithRSAEncryption", "sha1WithRSAEncryption",
1801
+ "md4WithRSA", "md5WithRSA",
1802
+ "md2WithRSASignature", "md5WithRSASignature", "sha1WithRSASignature",
1803
+ "ecdsaWithSHA1", "dsaWithSha1",
1804
+ ].forEach(function (n) { var d = oid.byName(n); if (d) WEAK_CSR_SIG_OIDS[d] = n; });
1805
+
1806
+ /** @internal RSASSA-PSS carries its digest in the parameters rather than in the algorithm name, so
1807
+ * the name alone never reads as weak. RFC 4055 sec. 3.1 makes the hashAlgorithm `[0]` field
1808
+ * DEFAULT sha1, which means ABSENT parameters and an empty parameter SEQUENCE both name SHA-1:
1809
+ * the shape that looks like it says nothing is the one that says the weakest thing. */
1810
+ function _pssDigestName(sigAlg) {
1811
+ if (!sigAlg || sigAlg.oid !== oid.byName("rsassaPss")) return null;
1812
+ var params = sigAlg.parameters;
1813
+ if (params === null || params === undefined) return "sha1";
1814
+ var node;
1815
+ try { node = asn1.decode(params); }
1816
+ // allow:swallow-unverified parameters that do not decode are reported by the value-syntax path
1817
+ catch (_e) { return null; }
1818
+ if (!node || node.tagNumber !== asn1.TAGS.SEQUENCE || !node.children) return null;
1819
+ for (var i = 0; i < node.children.length; i++) {
1820
+ var c = node.children[i];
1821
+ if (c.tagClass !== "context" || c.tagNumber !== 0) continue;
1822
+ var inner = c.children && c.children[0];
1823
+ if (!inner || !inner.children || !inner.children[0]) return null;
1824
+ var dotted;
1825
+ try { dotted = asn1.read.oid(inner.children[0]); }
1826
+ // allow:swallow-unverified a hashAlgorithm that does not read is not a digest this names
1827
+ catch (_e2) { return null; }
1828
+ return oid.name(dotted) || dotted;
1829
+ }
1830
+ return "sha1";
1831
+ }
1832
+
1833
+ var RFC2986_CSR_RULES = [
1834
+ {
1835
+ id: "lint/rfc2986/subject-empty-no-identity", severity: "error", source: "rfc2986", citation: "RFC 5280 sec. 4.1.2.6",
1836
+ /** @internal Section 4.1.2.6 admits an empty subject only where the subjectAltName carries the
1837
+ * identity instead. A request with neither names nothing to certify, so there is no name for
1838
+ * the issuer to put in the certificate. */
1839
+ message: "a request with an empty subject must request a subjectAltName, or it names nothing to certify",
1840
+ check: function (csr, ctx) {
1841
+ if (_subjectIsNonEmpty(csr)) return null;
1842
+ var san = ctx.decode("subjectAltName");
1843
+ return (san && san.value && san.value.names && san.value.names.length) ? null : true;
1844
+ },
1845
+ },
1846
+ {
1847
+ id: "lint/rfc2986/san-not-critical-empty-subject", severity: "error", source: "rfc2986", citation: "RFC 5280 sec. 4.1.2.6 / 4.2.1.6",
1848
+ /** @internal "If the subject field contains an empty sequence, then the issuing CA MUST
1849
+ * include a subjectAltName extension that is marked as critical." A request whose only
1850
+ * identity is a non-critical SAN asks for a certificate the profile refuses. */
1851
+ message: "where the subject is empty the requested subjectAltName must be critical",
1852
+ check: function (csr, ctx) {
1853
+ if (_subjectIsNonEmpty(csr)) return null;
1854
+ var san = ctx.decode("subjectAltName");
1855
+ if (!san || !san.value || !san.value.names || !san.value.names.length) return null;
1856
+ return san.critical === true ? null : true;
1857
+ },
1858
+ },
1859
+ {
1860
+ id: "lint/rfc2986/ca-determined-extension-requested", severity: "notice", source: "rfc2986", citation: "RFC 5280 sec. 4.2.1.1 / 4.2.1.2 / 4.2.2",
1861
+ message: "the request asks for an extension the issuer determines, which a CA does not take from a request",
1862
+ check: function (csr, ctx) {
1863
+ var out = [];
1864
+ CA_DETERMINED_EXTENSIONS.forEach(function (name) {
1865
+ if (ctx.raw(name)) out.push({ context: { extension: name } });
1866
+ });
1867
+ return out.length ? out : null;
1868
+ },
1869
+ },
1870
+ {
1871
+ id: "lint/rfc2986/basic-constraints-ca-requested", severity: "warn", source: "rfc2986", citation: "RFC 5280 sec. 4.2.1.9",
1872
+ /** @internal A subscriber request asking for cA TRUE is the request that turns a subscriber
1873
+ * into an issuer if it is honored, so it is named rather than passed over. It is a warning
1874
+ * and not an error because a CA legitimately issues intermediates from a request. */
1875
+ message: "the request asks for a CA certificate (basicConstraints cA TRUE)",
1876
+ check: function (csr, ctx) {
1877
+ var bc = ctx.decode("basicConstraints");
1878
+ return (bc && bc.value && bc.value.cA === true) ? { context: { pathLenConstraint: bc.value.pathLenConstraint } } : null;
1879
+ },
1880
+ },
1881
+ {
1882
+ id: "lint/rfc2986/weak-signature-algorithm", severity: "error", source: "rfc2986", citation: "RFC 2986 sec. 4.2",
1883
+ message: "the request is signed under a digest no longer fit to carry a proof of possession",
1884
+ check: function (csr) {
1885
+ var alg = csr.signatureAlgorithm;
1886
+ if (alg && WEAK_CSR_SIG_OIDS[alg.oid]) {
1887
+ return { context: { signatureAlgorithm: WEAK_CSR_SIG_OIDS[alg.oid], from: "algorithm" } };
1888
+ }
1889
+ var pss = _pssDigestName(alg);
1890
+ var named = pss !== null ? pss : (alg && alg.name);
1891
+ if (typeof named !== "string") return null;
1892
+ var lower = named.toLowerCase();
1893
+ for (var i = 0; i < WEAK_CSR_SIG_DIGESTS.length; i++) {
1894
+ if (lower.indexOf(WEAK_CSR_SIG_DIGESTS[i]) !== -1) {
1895
+ return { context: { signatureAlgorithm: (alg && alg.name) || (alg && alg.oid),
1896
+ digest: WEAK_CSR_SIG_DIGESTS[i], from: pss !== null ? "parameters" : "algorithm" } };
1897
+ }
1898
+ }
1899
+ return null;
1900
+ },
1901
+ },
1902
+ {
1903
+ id: "lint/rfc2986/extension-request-ambiguous", severity: "error", source: "rfc2986", citation: "RFC 2985 sec. 5.4.2 / X.501 sec. 8.2",
1904
+ /** @internal An attribute type appears once in a SET OF Attributes, and extensionRequest is
1905
+ * SINGLE VALUE besides. Two of them parse when they are in DER order, and which one a reader
1906
+ * takes then depends on how the bytes happened to sort, so one request reads two ways. Every
1907
+ * one of them is linted below, and this row says the request did not ask once. */
1908
+ message: "the request carries more than one extensionRequest attribute, so what it asks for depends on which is read",
1909
+ check: function (csr) {
1910
+ var n = _csrAttributesOf(csr, "extensionRequest").length;
1911
+ return n > 1 ? { context: { count: n } } : null;
1912
+ },
1913
+ },
1914
+ {
1915
+ id: "lint/rfc2986/challenge-password-present", severity: "notice", source: "rfc2986", citation: "RFC 2985 sec. 5.4.1",
1916
+ /** @internal The attribute travels inside the request, so wherever the request is stored or
1917
+ * logged the password is too. Reported so an operator knows it is in the artifact, not
1918
+ * because the structure is wrong. */
1919
+ message: "the request carries a challengePassword, which travels and is stored with it",
1920
+ check: function (csr) { return _csrHasAttribute(csr, "challengePassword") ? true : null; },
1921
+ },
1922
+ ];
1923
+
1924
+ /** @internal The certificate rows that read only the subject, the public key and the requested
1925
+ * subjectAltName, which a request carries too. They are REUSED rather than restated so the
1926
+ * pre-issuance answer cannot drift from the post-issuance one: the row, its severity and its
1927
+ * citation are the certificate profile's own. */
1928
+ var CABF_TLS_CSR_RULE_IDS = [
1929
+ "lint/cabf-tls/san-missing",
1930
+ "lint/cabf-tls/cn-not-in-san",
1931
+ "lint/cabf-tls/dnsname-bad-syntax",
1932
+ "lint/cabf-tls/weak-key",
1933
+ ];
1934
+
1777
1935
  var PROFILES = Object.assign(Object.create(null), {
1778
1936
  "rfc5280": RFC5280_RULES,
1779
1937
  "rfc9881": RFC9881_RULES,
@@ -1783,6 +1941,133 @@ var PROFILES = Object.assign(Object.create(null), {
1783
1941
  });
1784
1942
  var ALL_RULES = RFC5280_RULES.concat(RFC9881_RULES).concat(RFC9909_RULES).concat(RFC9935_RULES).concat(CABF_TLS_RULES);
1785
1943
 
1944
+ var CABF_TLS_CSR_RULES = CABF_TLS_RULES.filter(function (r) {
1945
+ return CABF_TLS_CSR_RULE_IDS.indexOf(r.id) !== -1;
1946
+ });
1947
+ /** @internal RFC 5280 sec. 4.2 fixes each extension's syntax wherever it is carried, and a
1948
+ * requested extension whose value does not decode is the same fault in a request as in a
1949
+ * certificate. The certificate's own row reads `extensions` and the request view supplies it, so
1950
+ * the row is reused rather than restated. Without it a malformed requested value counted as a
1951
+ * passing check, because the shared decoder returns null and every row that asks by name reads
1952
+ * that null as absence. */
1953
+ var RFC5280_CSR_RULES = RFC5280_RULES.filter(function (r) {
1954
+ return r.id === "lint/rfc5280/extension-undecodable";
1955
+ });
1956
+ var CSR_STRUCTURAL_RULES = RFC2986_CSR_RULES.concat(RFC5280_CSR_RULES);
1957
+ var CSR_PROFILES = Object.assign(Object.create(null), {
1958
+ "rfc2986": CSR_STRUCTURAL_RULES,
1959
+ "cabf-tls": CSR_STRUCTURAL_RULES.concat(CABF_TLS_CSR_RULES),
1960
+ });
1961
+ var ALL_CSR_RULES = CSR_STRUCTURAL_RULES;
1962
+
1963
+ function _subjectIsNonEmpty(csr) {
1964
+ var rdns = csr.subject && csr.subject.rdns;
1965
+ return !!(Array.isArray(rdns) && rdns.length);
1966
+ }
1967
+
1968
+ function _csrAttributesOf(csr, name) {
1969
+ var dotted = oid.byName(name);
1970
+ var list = csr.attributes || [];
1971
+ var out = [];
1972
+ for (var i = 0; i < list.length; i++) { if (list[i].type === dotted) out.push(list[i]); }
1973
+ return out;
1974
+ }
1975
+ function _csrHasAttribute(csr, name) { return _csrAttributesOf(csr, name).length > 0; }
1976
+
1977
+ /** @internal A request read the way the certificate rows read a certificate: the subject and the
1978
+ * public key are the request's own, and `extensions` is what the extensionRequest attribute asked
1979
+ * for. Shaped this way so `_findRaw`, `_decodeOrNull` and the reused CABF rows run unchanged
1980
+ * against a request rather than each restating how to reach a requested extension. */
1981
+ function _csrViewWith(csr, exts) {
1982
+ var view = Object.create(null);
1983
+ view.subject = csr.subject;
1984
+ view.subjectPublicKeyInfo = csr.subjectPublicKeyInfo;
1985
+ view.signatureAlgorithm = csr.signatureAlgorithm;
1986
+ view.attributes = csr.attributes;
1987
+ view.extensions = exts;
1988
+ return view;
1989
+ }
1990
+
1991
+ /** @internal One view per extensionRequest attribute, rather than one view over all of them
1992
+ * flattened. A rule that asks for an extension BY NAME reads the first match, so flattening lets
1993
+ * a basicConstraints of cA FALSE in one attribute hide a cA TRUE in the next: the value that
1994
+ * decides the answer would be the one that happened to sort first. Each attribute is linted as
1995
+ * the request it states, and the findings are merged. A request carrying one attribute, which is
1996
+ * every conforming one, produces exactly one view and the same answer as before. */
1997
+ function _csrViews(csr) {
1998
+ var attrs = _csrAttributesOf(csr, "extensionRequest");
1999
+ if (!attrs.length) return [_csrViewWith(csr, [])];
2000
+ return attrs.map(function (attr) {
2001
+ return _csrViewWith(csr, Array.isArray(attr.extensions) ? attr.extensions : []);
2002
+ });
2003
+ }
2004
+
2005
+ /** @internal The same rule firing on two views of one request is one finding, since the rows that
2006
+ * do not read an extension see the identical request each time. */
2007
+ function _mergeCsrReports(reports) {
2008
+ var findings = [], ran = [], seen = Object.create(null), ranSeen = Object.create(null);
2009
+ reports.forEach(function (r) {
2010
+ r.findings.forEach(function (f) {
2011
+ var key = f.id + "\u0000" + JSON.stringify(f.context === undefined ? null : f.context);
2012
+ if (seen[key]) return;
2013
+ seen[key] = true;
2014
+ findings.push(f);
2015
+ });
2016
+ r.ran.forEach(function (id) { if (!ranSeen[id]) { ranSeen[id] = true; ran.push(id); } });
2017
+ });
2018
+ var counts = Object.assign(Object.create(null), { fatal: 0, error: 0, warn: 0, notice: 0, pass: 0, na: 0, ne: 0 });
2019
+ var failed = Object.create(null), failedCount = 0;
2020
+ findings.forEach(function (f) {
2021
+ counts[f.severity] = (counts[f.severity] || 0) + 1;
2022
+ if (!failed[f.id]) { failed[f.id] = true; failedCount += 1; }
2023
+ });
2024
+ /** @internal A rule that reports twice is still ONE rule that did not pass, which is what
2025
+ * `_runLints` counts, so the distinct failing ids come off the executed ones. Subtracting the
2026
+ * finding count instead understates `pass` the moment any rule reports more than once. */
2027
+ counts.pass = ran.length - failedCount > 0 ? ran.length - failedCount : 0;
2028
+ return { findings: findings, counts: counts, ran: ran };
2029
+ }
2030
+
2031
+ /** @internal A request carries no extKeyUsage, so nothing in its bytes says it is for TLS. The
2032
+ * caller names that profile or the TLS rows do not run, which is why `isTlsServerCert` here is
2033
+ * the caller's statement rather than something read off the artifact. */
2034
+ function _makeCsrCtx(view, profile) {
2035
+ var explicitTls = profile === "cabf-tls";
2036
+ return {
2037
+ profile: profile,
2038
+ explicitTlsProfile: explicitTls,
2039
+ isTlsServerCert: explicitTls,
2040
+ decode: function (name) { return _decodeOrNull(view, name); },
2041
+ raw: function (name) { return _findRaw(view, name); },
2042
+ };
2043
+ }
2044
+
2045
+ /** @internal The kind-checked door the other lint verbs use, rather than a property test of our
2046
+ * own: a parsed certificate carries a subjectPublicKeyInfo too, so a shape test admits one as a
2047
+ * request and then reads its extensions from an extensionRequest attribute it does not have,
2048
+ * reporting on a request that was never made. */
2049
+ function _derivedCsr(o) {
2050
+ if (!o || typeof o !== "object" || guard.bytes.isByteSource(o)) return null;
2051
+ // allow:swallow-unverified an object the guard refuses is not a parsed request, and the byte paths below answer for it
2052
+ try { return guard.parsed.acceptDerived(o, "csr", csrSchema.parse, _cfg, "lint/bad-input", "the certification request"); }
2053
+ catch (_e) { return null; }
2054
+ }
2055
+
2056
+ function _ingestCsr(input) {
2057
+ var derived = _derivedCsr(input);
2058
+ if (derived) return { csr: derived };
2059
+ var der;
2060
+ if (Buffer.isBuffer(input)) der = input;
2061
+ else if (typeof input === "string") {
2062
+ try { der = csrSchema.pemDecode(input); }
2063
+ catch (e) { return { fatal: { id: "lint/unparseable", severity: "fatal", source: "engine", citation: "pki.lint", message: "input is not a decodable PEM/DER certification request", context: { code: e.code } } }; }
2064
+ } else {
2065
+ throw _cfg("lint/bad-input", "pki.lint.csr input must be a parsed certification request, a DER Buffer, or a PEM string");
2066
+ }
2067
+ try { return { csr: csrSchema.parse(der) }; }
2068
+ catch (e) { return { fatal: { id: "lint/unparseable", severity: "fatal", source: "engine", citation: "RFC 2986", message: "input is not a well-formed PKCS#10 certification request", context: { code: e.code } } }; }
2069
+ }
2070
+
1786
2071
  function _selectRules(profile) {
1787
2072
  if (profile == null || profile === "all" || profile === "default") return ALL_RULES;
1788
2073
  if (!PROFILES[profile]) {
@@ -1929,6 +2214,87 @@ function _ingestCrl(input) {
1929
2214
  * report.worst; // -> "error", for the missing AKI
1930
2215
  * report.findings.map(function (f) { return f.id; }); // -> ["lint/rfc5280-crl/aki-missing"]
1931
2216
  */
2217
+ /**
2218
+ * @primitive pki.lint.csr
2219
+ * @signature pki.lint.csr(pem | der | parsed, opts?) -> LintReport
2220
+ * @since 0.8.16
2221
+ * @status stable
2222
+ * @spec RFC 2986, RFC 2985 sec. 5.4, RFC 5280 sec. 4.1.2.6, CA/Browser Forum TLS BR
2223
+ * @related pki.lint.certificate, pki.csr.verify, pki.schema.csr.parse, pki.csr.sign
2224
+ *
2225
+ * Lint a PKCS#10 certification request, which is the artifact a CA reads before it issues. `input`
2226
+ * is a PEM string, a DER `Buffer`, or an already-parsed `pki.schema.csr` object. It returns the
2227
+ * same `LintReport` shape `pki.lint.certificate` returns, and it never throws on the data path:
2228
+ * bytes that are not a well-formed request produce a single `fatal` finding `lint/unparseable`
2229
+ * carrying the strict parser's own code.
2230
+ *
2231
+ * The rows cover what the request asks for: an empty subject and whether a subjectAltName carries
2232
+ * the identity in its place, the extensions an issuer determines rather than takes from a request,
2233
+ * a request asking for a CA certificate, the digest the request is signed under, and a
2234
+ * challengePassword traveling inside it.
2235
+ *
2236
+ * This does not check the request's signature. That signature is the requester's proof that it
2237
+ * holds the private key, verifying it is a cryptographic operation rather than a profile question,
2238
+ * and `pki.csr.verify` is the verb for it. A CA runs both.
2239
+ *
2240
+ * The CA/Browser Forum rows run only under `opts.profile: "cabf-tls"`. A request carries no
2241
+ * extKeyUsage, so nothing in its bytes says it is for TLS, and the caller states that. Those rows
2242
+ * are the certificate profile's own, applied to what the request asks for, so the answer before
2243
+ * issuance is the answer after it.
2244
+ *
2245
+ * Five shapes never appear here because the strict parser refuses them first and a rule for them
2246
+ * could not fire. A version other than 0 (RFC 2986 sec. 4.1), a challengePassword or
2247
+ * extensionRequest attribute carrying more than one value, an extensionRequest carrying no
2248
+ * extensions (RFC 2985 sec. 5.4), the same extension OID twice inside one, and attributes out of
2249
+ * DER order each arrive as `lint/unparseable` carrying the parser's own code. A SECOND
2250
+ * extensionRequest attribute does parse, where the two sort in DER order, and is reported by
2251
+ * `lint/rfc2986/extension-request-ambiguous`; every one of them is read, so nothing a later
2252
+ * attribute asks for escapes the rows below.
2253
+ *
2254
+ * @opts profile `"rfc2986"` for the structural rows alone, or `"cabf-tls"` to add the TLS rows
2255
+ * (default runs the structural rows). A certificate, CRL or OCSP profile name is
2256
+ * refused rather than run against a request.
2257
+ * @opts severity Suppress findings below this floor (default `"notice"`). `counts` and `worst`
2258
+ * always reflect the complete, unfiltered result.
2259
+ * @example
2260
+ * var pair = await pki.key.generate("Ed25519");
2261
+ * var der = await pki.csr.sign({ subject: [{ commonName: "example.com" }],
2262
+ * subjectPublicKey: await pki.key.export(pair.publicKey) },
2263
+ * { key: await pki.key.export(pair.privateKey) });
2264
+ * var report = pki.lint.csr(der, { profile: "cabf-tls" });
2265
+ * report.findings.map(function (f) { return f.id; }); // -> ["lint/cabf-tls/san-missing"]
2266
+ */
2267
+ function csr(input, opts) {
2268
+ opts = guard.identifier.optionsObject(opts, _cfg, "lint/bad-input", "pki.lint options");
2269
+ guard.identifier.assertKnownKeys(opts, _CRL_OPTS, _cfg, "lint/bad-input",
2270
+ "pki.lint.csr has an unknown option: ");
2271
+ if (opts.severity != null && VALID_SEVERITY.indexOf(opts.severity) === -1) {
2272
+ throw _cfg("lint/bad-severity", "unknown severity threshold \"" + opts.severity + "\" (known: " + VALID_SEVERITY.join(", ") + ")");
2273
+ }
2274
+ var profile = opts.profile;
2275
+ var rules_;
2276
+ if (profile == null || profile === "all" || profile === "default") rules_ = ALL_CSR_RULES;
2277
+ else if (CSR_PROFILES[profile]) rules_ = CSR_PROFILES[profile];
2278
+ else if (CRL_PROFILES[profile]) throw _cfg("lint/unknown-profile", "\"" + profile + "\" is a CRL profile; lint a CRL with pki.lint.crl");
2279
+ else if (OCSP_PROFILES[profile]) throw _cfg("lint/unknown-profile", "\"" + profile + "\" is an OCSP profile; lint a response with pki.lint.ocsp");
2280
+ else if (PROFILES[profile]) throw _cfg("lint/unknown-profile", "\"" + profile + "\" is a certificate profile; lint a certificate with pki.lint.certificate");
2281
+ else throw _cfg("lint/unknown-profile", "unknown certification-request lint profile \"" + profile + "\" (known: " + Object.keys(CSR_PROFILES).join(", ") + ")");
2282
+
2283
+ var ingested = _ingestCsr(input);
2284
+ var report;
2285
+ if (ingested.fatal) {
2286
+ report = { findings: [ingested.fatal], ran: [],
2287
+ counts: Object.assign(Object.create(null), { fatal: 1, error: 0, warn: 0, notice: 0, pass: 0, na: 0, ne: 0 }) };
2288
+ } else {
2289
+ report = _mergeCsrReports(_csrViews(ingested.csr).map(function (view) {
2290
+ return _runLints(rules_, view, _makeCsrCtx(view, profile));
2291
+ }));
2292
+ }
2293
+ report.worst = _worst(report.findings);
2294
+ report.findings = _applyThreshold(report, opts.severity);
2295
+ return report;
2296
+ }
2297
+
1932
2298
  function crl(input, opts) {
1933
2299
  opts = guard.identifier.optionsObject(opts, _cfg, "lint/bad-input", "pki.lint options");
1934
2300
  guard.identifier.assertKnownKeys(opts, _CRL_OPTS, _cfg, "lint/bad-input",
@@ -2074,23 +2440,67 @@ function ocsp(input, opts) {
2074
2440
 
2075
2441
  /**
2076
2442
  * @primitive pki.lint.rules
2077
- * @signature pki.lint.rules(profile?) -> [{id, severity, source, citation}]
2443
+ * @signature pki.lint.rules(profile?, artifact?) -> [{id, severity, source, citation}]
2078
2444
  * @since 0.2.10
2079
2445
  * @status stable
2080
2446
  * @spec RFC 5280, CA/Browser Forum TLS BR
2081
2447
  *
2082
2448
  * Enumerate the rule registry (all rules, or one profile's). Each entry exposes its
2083
2449
  * stable `id`, `severity`, `source`, and spec-clause `citation` for documentation and
2084
- * corpus tooling.
2450
+ * corpus tooling. Each id appears once, including where two artifacts run the same rule.
2451
+ *
2452
+ * `artifact` says which verb's reading of a profile name to return, and is needed because
2453
+ * `"cabf-tls"` names a set for both `pki.lint.certificate` and `pki.lint.csr` and the two are not
2454
+ * the same rows: a request has no extKeyUsage and no validity period, and carries structural rows
2455
+ * a certificate does not. It is `"certificate"`, `"csr"`, `"crl"` or `"ocsp"`, and defaults to the
2456
+ * artifact that first claims the name, which is the certificate for `"cabf-tls"`.
2457
+ *
2085
2458
  * @example
2086
- * pki.lint.rules("rfc5280").length; // -> a positive count
2459
+ * pki.lint.rules("rfc5280").length; // -> a positive count
2460
+ * pki.lint.rules("cabf-tls", "csr").length; // -> the rows pki.lint.csr runs under it
2087
2461
  */
2088
- function rules(profile) {
2089
- var set = profile == null
2090
- ? ALL_RULES.concat(ALL_CRL_RULES).concat(ALL_OCSP_RULES)
2091
- : (PROFILES[profile] || CRL_PROFILES[profile] || OCSP_PROFILES[profile]
2092
- || (function () { throw _cfg("lint/unknown-profile", "unknown lint profile \"" + profile + "\""); })());
2093
- return set.map(function (r) { return { id: r.id, severity: r.severity, source: r.source, citation: r.citation }; });
2462
+ /** @internal Prototype-less, because `artifact` is the caller's string and a computed lookup on a
2463
+ * literal would answer for "constructor" and the rest of Object.prototype. */
2464
+ var BY_ARTIFACT = Object.assign(Object.create(null), {
2465
+ certificate: PROFILES, csr: CSR_PROFILES, crl: CRL_PROFILES, ocsp: OCSP_PROFILES,
2466
+ });
2467
+
2468
+ function rules(profile, artifact) {
2469
+ if (artifact != null && !Object.prototype.hasOwnProperty.call(BY_ARTIFACT, artifact)) {
2470
+ throw _cfg("lint/bad-input", "unknown lint artifact \"" + artifact +
2471
+ "\" (known: " + Object.keys(BY_ARTIFACT).join(", ") + ")");
2472
+ }
2473
+ var set;
2474
+ if (profile == null && artifact != null) {
2475
+ /** @internal An artifact without a profile asks what that VERB can run, which is the union of
2476
+ * its own profiles. Returning the whole registry would answer with a certificate's validity
2477
+ * rows and the CRL and OCSP sets, none of which a request or a response is ever held to. */
2478
+ set = [];
2479
+ Object.keys(BY_ARTIFACT[artifact]).forEach(function (name) {
2480
+ BY_ARTIFACT[artifact][name].forEach(function (r) { set.push(r); });
2481
+ });
2482
+ } else if (profile == null) {
2483
+ set = ALL_RULES.concat(ALL_CRL_RULES).concat(ALL_OCSP_RULES).concat(ALL_CSR_RULES);
2484
+ } else if (artifact != null) {
2485
+ set = BY_ARTIFACT[artifact][profile];
2486
+ if (!set) {
2487
+ throw _cfg("lint/unknown-profile", "\"" + profile + "\" is not a " + artifact +
2488
+ " lint profile (known: " + Object.keys(BY_ARTIFACT[artifact]).join(", ") + ")");
2489
+ }
2490
+ } else {
2491
+ set = PROFILES[profile] || CRL_PROFILES[profile] || OCSP_PROFILES[profile] || CSR_PROFILES[profile];
2492
+ if (!set) throw _cfg("lint/unknown-profile", "unknown lint profile \"" + profile + "\"");
2493
+ }
2494
+ /** @internal One row per id: a rule reused across artifacts, like the RFC 5280 sec. 4.2
2495
+ * extension-syntax row a request and a certificate both run, is one rule with one id, and
2496
+ * tooling that counts or registers from this list would otherwise process it twice. */
2497
+ var seen = Object.create(null), out = [];
2498
+ set.forEach(function (r) {
2499
+ if (seen[r.id]) return;
2500
+ seen[r.id] = true;
2501
+ out.push({ id: r.id, severity: r.severity, source: r.source, citation: r.citation });
2502
+ });
2503
+ return out;
2094
2504
  }
2095
2505
 
2096
2506
  /**
@@ -2104,10 +2514,18 @@ function rules(profile) {
2104
2514
  * @example
2105
2515
  * pki.lint.profiles(); // -> ["rfc5280", "rfc9881", "rfc9909", "rfc9935", "cabf-tls", "rfc5280-crl", "rfc6960", "rfc5019"]
2106
2516
  */
2107
- function profiles() { return Object.keys(PROFILES).concat(Object.keys(CRL_PROFILES)).concat(Object.keys(OCSP_PROFILES)); }
2517
+ /** @internal `cabf-tls` names the certificate profile in this list and is not repeated for the
2518
+ * request form: `pki.lint.csr` takes the same name and runs those rows against what a request
2519
+ * asks for, so one name means one set of requirements whichever artifact carries it. */
2520
+ function profiles() {
2521
+ var out = Object.keys(PROFILES).concat(Object.keys(CRL_PROFILES)).concat(Object.keys(OCSP_PROFILES));
2522
+ Object.keys(CSR_PROFILES).forEach(function (name) { if (out.indexOf(name) === -1) out.push(name); });
2523
+ return out;
2524
+ }
2108
2525
 
2109
2526
  module.exports = {
2110
2527
  certificate: certificate,
2528
+ csr: csr,
2111
2529
  crl: crl,
2112
2530
  ocsp: ocsp,
2113
2531
  rules: rules,
package/lib/oid.js CHANGED
@@ -165,8 +165,19 @@ var FAMILIES = Object.assign(Object.create(null), {
165
165
  buildConfigURI: 18, buildConfigDigest: 19, buildTrigger: 20,
166
166
  runInvocationURI: 21, sourceRepositoryVisibilityAtSigning: 22 } },
167
167
 
168
+ /** @internal md5WithRSAEncryption and sha1WithRSAEncryption are registered so the toolkit can
169
+ * NAME them, not so it can use them. Nothing signs or verifies from this registry: the signing
170
+ * and verifying paths carry their own name-keyed tables, and neither has a row for these. An
171
+ * unregistered OID reads back as an undefined name, which left a linter unable to say what a
172
+ * request from another implementation was signed under. */
168
173
  rsa: { base: [1, 2, 840, 113549, 1, 1], of: {
169
- rsaEncryption: 1, rsaesOaep: 7, mgf1: 8, pSpecified: 9, rsassaPss: 10,
174
+ /** @internal PKCS#1's MD4 signature carries the arc-qualified name because the OIW arc already
175
+ * holds the plain `md4WithRSAEncryption` for 1.3.14.3.2.4. The registry is two-way and a name
176
+ * registered twice repoints every lookup of it, so two standards using one name for two OIDs
177
+ * are told apart here rather than silently sharing an entry. */
178
+ rsaEncryption: 1, md2WithRSAEncryption: 2, "md4WithRSAEncryption-pkcs1": 3,
179
+ md5WithRSAEncryption: 4, sha1WithRSAEncryption: 5,
180
+ rsaesOaep: 7, mgf1: 8, pSpecified: 9, rsassaPss: 10,
170
181
  sha256WithRSAEncryption: 11,
171
182
  sha384WithRSAEncryption: 12, sha512WithRSAEncryption: 13 } },
172
183
 
@@ -271,8 +282,14 @@ var FAMILIES = Object.assign(Object.create(null), {
271
282
 
272
283
  ansiX962: { base: [1, 2, 840, 10045], of: {
273
284
  ecPublicKey: [2, 1], prime192v1: [3, 1, 1], prime256v1: [3, 1, 7],
285
+ ecdsaWithSHA1: [4, 1],
274
286
  ecdsaWithSHA256: [4, 3, 2], ecdsaWithSHA384: [4, 3, 3], ecdsaWithSHA512: [4, 3, 4] } },
275
287
 
288
+ /** @internal X9.57 DSA, registered for naming alone. The toolkit neither signs nor verifies DSA,
289
+ * and dsaWithSha1 is here so a request or certificate carrying it reads back with a name rather
290
+ * than a bare dotted string, which is what a linter needs to say what it refused. */
291
+ x957: { base: [1, 2, 840, 10040, 4], of: { dsa: 1, dsaWithSha1: 3 } },
292
+
276
293
  secg: { base: [1, 3, 132, 0], of: {
277
294
  sect163k1: 1, sect163r1: 2, secp256k1: 10, sect163r2: 15, sect283k1: 16, sect283r1: 17,
278
295
  sect233k1: 26, sect233r1: 27, secp192k1: 31, secp224k1: 32, secp224r1: 33,
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.16",
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:770a6573-9a63-4951-bf37-d9999705b708",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-09-24T03:02:34.975Z",
8
+ "timestamp": "2026-09-24T05:26:27.977Z",
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.16",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.8.14",
25
+ "version": "0.8.16",
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.16",
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.16",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]