@blamejs/pki 0.6.10 → 0.6.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/MIGRATING.md +20 -0
- package/README.md +2 -2
- package/bin/pki.js +1 -1
- package/lib/acme.js +82 -11
- package/lib/path-validate.js +64 -29
- package/lib/sigstore.js +1 -1
- package/lib/trust.js +1 -1
- package/lib/tsp-sign.js +14 -15
- package/lib/webauthn-mds.js +1 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ 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.6.11 — 2026-08-31
|
|
8
|
+
|
|
9
|
+
pki.path.validate and pki.tsp.verify now take the trust store under the option name trustAnchors, the spelling the other verify verbs already use, accepting one anchor or an array; and pki.acme.client gains opts.resignKeys, which re-signs a request under an alternative account-key algorithm when a CA reports badSignatureAlgorithm.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- pki.acme.client accepts opts.resignKeys, an ordered array of { alg, key } alternatives for the account key (RFC 8555 section 6.2). When a CA rejects the account's JWS algorithm with a badSignatureAlgorithm error and lists the algorithms it supports, the client re-signs the request once with the first listed alternative the CA supports and retries. Each key is a CryptoKey for the same account key material under that algorithm, so the account's registered public key still verifies the re-signed request. The caller's order selects the algorithm and the CA's list only filters it, so a spoofed badSignatureAlgorithm cannot force a weaker algorithm (RFC 8555 section 10). Without the option, or when no listed algorithm is a caller alternative, the error surfaces unchanged.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- pki.path.validate and pki.tsp.verify read the trust anchor from opts.trustAnchors, the same option the other verify verbs take. It accepts a single anchor tuple or root certificate, or a non-empty array of them; when several are supplied, pki.path.validate selects the anchor that issued the path's top certificate, and a one-element array reproduces the previous single-anchor result exactly. The former singular opts.trustAnchor is removed and is refused as an unknown option rather than silently ignored, so a stale caller gets a named error instead of an unanchored valid result. To upgrade, pass { trustAnchors: anchor } (or { trustAnchors: [a, b] }) where you passed { trustAnchor: anchor }.
|
|
18
|
+
|
|
7
19
|
## v0.6.10 — 2026-08-31
|
|
8
20
|
|
|
9
21
|
pki.est computes a response body's length from a single read of res.body, shared by one helper across every verb, so a caller-supplied transport whose res.body getter changes between reads cannot desync the type check from the measured length.
|
package/MIGRATING.md
CHANGED
|
@@ -14,6 +14,26 @@ The toolkit has no `deprecate()`-marked surface awaiting removal.
|
|
|
14
14
|
|
|
15
15
|
Listed newest-first.
|
|
16
16
|
|
|
17
|
+
### v0.6.11 — `pki.path.validate(path, opts) and pki.tsp.verify(token, data, opts)`
|
|
18
|
+
|
|
19
|
+
The trust anchor option is now `trustAnchors` (a single anchor or an array); the former singular `trustAnchor` is removed and refused by name.
|
|
20
|
+
|
|
21
|
+
Both verbs read the trust anchor from `opts.trustAnchors`, the spelling pki.path.build,
|
|
22
|
+
pki.cms.verify, pki.cmp.verify, and pki.smime.verify already use. It accepts a single anchor
|
|
23
|
+
tuple or root certificate, or a non-empty array of them.
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
var res = await pki.path.validate(path, { time: t, trustAnchor: anchor }); // removed
|
|
27
|
+
|
|
28
|
+
var res = await pki.path.validate(path, { time: t, trustAnchors: anchor }); // one anchor
|
|
29
|
+
var res = await pki.path.validate(path, { time: t, trustAnchors: [rootA, rootB] }); // several
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
With several anchors, pki.path.validate selects the one that issued the path's top certificate,
|
|
33
|
+
and a one-element array reproduces the previous single-anchor result. The removed `trustAnchor`
|
|
34
|
+
is refused as an unknown option (`path/bad-input` or `tsp/bad-input`) naming it, rather than read
|
|
35
|
+
as absent, so a request that was silently unanchored now fails closed with a named error.
|
|
36
|
+
|
|
17
37
|
### v0.5.24 — `pki.acme.client(...).downloadCertificate(url, opts)`
|
|
18
38
|
|
|
19
39
|
The verb needs to be told which certificate the download is allowed to be, and refuses with acme/binding-required when it is not.
|
package/README.md
CHANGED
|
@@ -225,7 +225,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
|
|
|
225
225
|
| `pki.scep` | SCEP pkiMessage codec (RFC 8894), the client side of Simple Certificate Enrollment. `build(spec)` assembles a `PKCSReq` or `RenewalReq` request: it verifies the PKCS#10 certification request's own proof-of-possession, encrypts it to the CA as the inner EnvelopedData (`pkcsPKIEnvelope`, AES-128-CBC), and signs that under the transaction attributes RFC 8894 §3.2 defines, a messageType, a caller-unique transactionId, and a 16-byte senderNonce. The recipient CA certificate must assert `keyEncipherment`. `parse(bytes, opts?)` reads a `PKCSReq`, `RenewalReq`, or `CertRep` back (the `CertPoll`, `GetCert`, and `GetCRL` client queries are refused): it verifies the outer signature first and refuses a message whose signature does not check, so the transaction attributes it returns come only from a verified signer, never alongside a false verdict. Because a valid signature only proves self-consistency with the embedded certificate, pass `opts.signerCert` to authenticate a CA response against the CA certificate you hold (a public-key mismatch is refused, `signerAuthenticated` reports the result); a caller that omits it gets a crypto-only verdict and must authenticate the surfaced `signerCert` itself. It maps the messageType, pkiStatus, failInfo, and the nonces from their RFC 8894 enumerants and refuses an unknown one; given `recipientKey` it decrypts the `pkcsPKIEnvelope` to the messageData, and given `expectedSenderNonce` it refuses a response whose recipientNonce does not echo it. It reads a CA's CertRep in full. `getCACaps(baseUrl, opts?)`, `getCACert(baseUrl, opts?)`, `enroll(baseUrl, opts)`, and `renew(baseUrl, opts)` carry that codec to a live CA over HTTP (RFC 8894 §4) through an injectable transport (`pki.transport.https` by default): `getCACaps` reports the CA's advertised capabilities and never lowers the client's algorithm choice on the unauthenticated response, `getCACert` pins the CA certificate to an out-of-band `expectedFingerprint` a returned certificate must match, and `enroll` and `renew` POST a PKCSReq or RenewalReq, authenticate the CertRep against the CA certificate and its nonce echo, select the issued certificate by public-key match, throw on a FAILURE with the CA's failInfo, and return a PENDING to retry. Issuing a CertRep, `GetNextCACert`, and automatic polling are not yet built — `build`, `parse`, `getCACaps`, `getCACert`, `enroll`, `renew` |
|
|
226
226
|
| `pki.transport` | The shared, fail-closed `node:https` transport the enrollment clients drive. `pki.transport.https(defaults)` returns a `transport(request) → { status, headers, body, tls }`, where `tls` carries the negotiated `protocol`, `cipher`, and raw `peerCertificate`. This is the toolkit's only socket choke point: an explicit trust anchor (or an opt-in to the system store) is required, `rejectUnauthorized` is always on, TLS is floored at 1.2, the response body is capped while it streams, and a stalled socket times out. The EST, ACME, and CMP clients reuse it verbatim. If you inject your own transport, return `tls` too: `pki.est.serverkeygen` asserts the negotiated cipher can protect the delivered private key, and a transport that reports no cipher is trusted rather than refused, so omitting the field silently skips that check — `https` |
|
|
227
227
|
| `pki.jose` | Flattened JWS (RFC 7515) and JWK thumbprints (RFC 7638). `sign` and `verify` run a Flattened JWS against declarative profiles (ACME outer, EAB inner, keyChange inner) that carry the required and forbidden header rules as data. `base64url` is the strict RFC 4648 §5 codec, rejecting padding, non-alphabet characters, and non-canonical trailing bits. `parseJson` is a bounded reader that refuses duplicate members at any depth. `thumbprint` is the RFC 7638 / 8037 / 9964 canonical digest. The algorithm registry binds each `alg` to its key type (ES/RS/PS/EdDSA/ML-DSA), leaving no code path for `alg:none`, an RS256→HS256 key confusion, or an all-zero ECDSA signature; `assertPublicJwk` refuses a JWK carrying private material, so an exported private key is never published. `opts.key` names the key a message must be signed under and governs: where the profile also permits an embedded header `jwk`, the two must be the same key — compared as RFC 7638 thumbprints, so member order cannot make equal keys differ — and a disagreement is refused rather than resolved in the message's favor. `keySource` reports which key answered, since a signature checked against a key you named is a different claim from one checked against the key the message carried — `sign`, `verify`, `base64url`, `parseJson`, `thumbprint`, `assertPublicJwk` |
|
|
228
|
-
| `pki.acme` | ACME (RFC 8555 / 8737 / 8738 / 9773). `client(directoryUrl, opts)` is a stateful client driving a live CA directory over `pki.transport`: `newAccount`, `newOrder`, `newAuthz`, `getOrder`, `getAuthorization`, `getChallenge`, `respondToChallenge`, `finalize`, `pollOrder`, `pollAuthorization`, and `downloadCertificate` walk the issuance flow, with `newAuthz` pre-authorizing a single identifier (§7.4.1) and `downloadCertificate` binding the issued certificate to the order — its key, its identifiers, or both; at least one is required by default and the result reports `boundToKey` / `boundToIdentifiers` — before choosing among alternate chains (`Link rel="alternate"`, §7.4.2, via a `selectChain` predicate bounded by `maxAlternates`). `revokeCert` (account-key or certificate-key signed), `keyChange`, `updateAccount` (change the account contacts, §7.3.2), `listOrders` (the account's orders list, following the §7.1.2.1 `Link rel="next"` pagination bounded by a page cap and confined to the orders origin), `deactivateAccount`, `deactivateAuthorization`, `renewalInfo` (ARI), and `renewalWindow` (the RFC 9773 §4.2/4.3 renewal decision) complete the lifecycle. Every URL is HTTPS only, an explicit trust anchor is required, each request carries a fresh single-use nonce with a bounded badNonce retry, reads are POST-as-GET, polling is bounded and sleeps on a Retry-After via an injectable sleeper capped by a poll count and a total-wait budget, and every response body is size-capped. The transport is injectable via `opts.transport`. Over the message layer it composes resource-object validators (closed status enums, conditional-required fields, unknown fields ignored), the three §7.1.6 state machines, the request builders (newAccount with External Account Binding, newOrder with `replaces`, finalize with a CSR identifier-set match and account-key-reuse rejection, challenge responses, deactivation, revokeCert in both key modes, the keyChange nested JWS, POST-as-GET), the http-01 / dns-01 / tls-alpn-01 challenge computations, the dns and ip identifier validators, and the ARI certID with serial sign-padding preserved — `client`, `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
|
|
228
|
+
| `pki.acme` | ACME (RFC 8555 / 8737 / 8738 / 9773). `client(directoryUrl, opts)` is a stateful client driving a live CA directory over `pki.transport`: `newAccount`, `newOrder`, `newAuthz`, `getOrder`, `getAuthorization`, `getChallenge`, `respondToChallenge`, `finalize`, `pollOrder`, `pollAuthorization`, and `downloadCertificate` walk the issuance flow, with `newAuthz` pre-authorizing a single identifier (§7.4.1) and `downloadCertificate` binding the issued certificate to the order — its key, its identifiers, or both; at least one is required by default and the result reports `boundToKey` / `boundToIdentifiers` — before choosing among alternate chains (`Link rel="alternate"`, §7.4.2, via a `selectChain` predicate bounded by `maxAlternates`). `revokeCert` (account-key or certificate-key signed), `keyChange`, `updateAccount` (change the account contacts, §7.3.2), `listOrders` (the account's orders list, following the §7.1.2.1 `Link rel="next"` pagination bounded by a page cap and confined to the orders origin), `deactivateAccount`, `deactivateAuthorization`, `renewalInfo` (ARI), and `renewalWindow` (the RFC 9773 §4.2/4.3 renewal decision) complete the lifecycle. Every URL is HTTPS only, an explicit trust anchor is required, each request carries a fresh single-use nonce with a bounded badNonce retry (and, when `opts.resignKeys` supplies caller-approved alternative account-key algorithms, a bounded RFC 8555 §6.2 badSignatureAlgorithm re-sign that the CA's advertised list can only filter, never widen, so it cannot force a weaker algorithm), reads are POST-as-GET, polling is bounded and sleeps on a Retry-After via an injectable sleeper capped by a poll count and a total-wait budget, and every response body is size-capped. The transport is injectable via `opts.transport`. Over the message layer it composes resource-object validators (closed status enums, conditional-required fields, unknown fields ignored), the three §7.1.6 state machines, the request builders (newAccount with External Account Binding, newOrder with `replaces`, finalize with a CSR identifier-set match and account-key-reuse rejection, challenge responses, deactivation, revokeCert in both key modes, the keyChange nested JWS, POST-as-GET), the http-01 / dns-01 / tls-alpn-01 challenge computations, the dns and ip identifier validators, and the ARI certID with serial sign-padding preserved — `client`, `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
|
|
229
229
|
| `pki.schema.smime` | S/MIME ESS signed-attribute values (RFC 5035 / RFC 8551). `parseSigningCertificate` and `parseSigningCertificateV2` bind a signature to its signing certificate (cert hash, hash algorithm, issuer `GeneralNames` and serial); `parseSmimeCapabilities` decodes the ordered capability list; `decodeAttribute` dispatches a CMS attribute by OID, enforcing the single-value rule and deferring on unknown types. A companion decoder for CMS signed attributes rather than an auto-routed format — `parseSigningCertificate`, `parseSigningCertificateV2`, `parseSmimeCapabilities`, `decodeAttribute` |
|
|
230
230
|
| `pki.cmc` | Build and interpret CMC messages (RFC 5272). `build(spec, signer)` assembles a Full PKI Request across all three request arms (PKCS#10, CRMF, other) and signs it through `pki.cms.sign` under `id-cct-PKIData`. A PKCS#10 (`tcr`) request is verified against its own proof-of-possession before signing, so a request whose self-signature does not verify under its subject public key is refused rather than signed into a message a CA would reject. Body-part identifiers are unique across the whole message and never the reserved 0; a caller's clash is refused rather than renumbered, since a control may already reference it. An Identity Proof V2 witness is computed over the `reqSequence` bytes exactly as emitted (§6.2.1 step 1) rather than a re-serialization, a POP Link Witness is emitted only alongside the POP Link Random control §6.3.1.1 requires beside it, and a renewal carries neither Identification nor Identity Proof in either version. `verify(response, sent)` takes what the CA returned plus the state the client retained and reduces it to one terminal outcome: `issued`, `pending`, `confirm-required`, `pop-required`, or `rejected`. It binds the exchange first — Transaction Identifier, the Sender and Recipient Nonce echo compared in constant time and by full value so a truncation cannot match, and the Data Return echo — with each check applying only if the client sent that half, and, once sent, an absent or differing echo being a refusal, which is the replay defense. `bodyPartIDs` extends the same rule to what the response is about: a status reporting on a body part the request never sent is refused, which the transaction and nonce cannot catch, because a server can echo both correctly while answering about a different message. Several status controls are permitted and the worst governs, so a failure cannot hide behind an earlier success; the absence of any status control is success, per §6.1.2. The carrier's signature must verify (§3.2.1.3.4): a conforming SignedData carries its own signer certificate, so the ordinary build-then-verify flow needs nothing extra and the verdict reports `signatureVerified: true`. Where the signer is found nowhere the posture is fail-closed with a named opt-out — supply `certs` with the responder's certificate, or `allowUnverified: true`, in which case the verdict reports `signatureVerified: false`. Doing neither is refused, the opt-out never excuses a signature that is present and wrong, and a carrier with no signer at all is refused outright. The response's own `cmsSequence` and `otherMsgs` come back raw, since a request whose only arm was the other-message form has no certificate to return and §4.1 puts its answer there. Nothing is trusted: issued certificates are read from the CMS certificate bag (§4.2) and surfaced raw for `pki.path.validate`, and a Publish Trust Anchors control is surfaced with `trusted: false` rather than added to a store — `build`, `verify` |
|
|
231
231
|
| `pki.schema.cmc` | Decode CMC messages (RFC 5272 as updated by RFC 6402): a Full PKI Request (`PKIData`) or Full PKI Response (`PKIResponse`) riding inside a CMS SignedData, reached by the encapsulated content type (`id-cct-PKIData` / `id-cct-PKIResponse`). Controls are surfaced in wire order with their values raw, so an unrecognized one is data rather than a fault. Tagged requests decode across all three arms. The status verdicts (`CMCStatusInfo` v1 and `CMCStatusInfoV2`) are collected as an ordered list, and the RFC 6402 two-module `OtherStatusInfo` ambiguity — `pendInfo` and `extendedFailInfo` are both untagged SEQUENCEs in the 1988 module, told apart only by their first element — is resolved by inspection and refused when it cannot be told apart. Body-part identity is unique across the whole message rather than per sequence, 0 is reserved as the reference to the enclosing PKIData, and the `reqSequence` bytes are surfaced exactly as they appeared so an Identity Proof witness is computed over the wire bytes. A companion decoder for CMS content rather than an auto-routed format — `parse`, `parsePkiData`, `parsePkiResponse` |
|
|
@@ -245,7 +245,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
|
|
|
245
245
|
| `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 or under SHA-2, plus an optional RFC 9654 nonce and an optional requestor signature. `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. `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 (`signerCerts`, so a key carried under an expired certificate beside its renewal is not hidden behind ordering) with the first one's decoded subject, the full embedded certificate bag (`certs`, including intermediates for path building), and the request's CertIDs; an unsigned request is reported with `signed: false` rather than refused. Transport-free — `buildRequest`, `sign`, `buildErrorResponse`, `verify`, `verifyRequest` |
|
|
246
246
|
| `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` |
|
|
247
247
|
| `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` |
|
|
248
|
-
| `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({
|
|
248
|
+
| `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 })`. Offline, fail-closed, bounded — `parseCertdata`, `parseCcadbCsv`, `anchor` |
|
|
249
249
|
| `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 — `verify`, `verifyLms` |
|
|
250
250
|
| `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) by HKDF-SHA256/SHA512 by AES-GCM / ChaCha20Poly1305 / export-only, across all four modes, proven against the RFC 9180 Appendix A vectors. DHKEM(P-384) and HKDF-SHA384 are RFC-registered but Appendix A ships no vector for them, so they fail closed until an authoritative KAT exists. Pure composition over `node:crypto`; ML-KEM and X-Wing are a registry data-row extension pending stable drafts — `suites`, `setupS`, `setupR`, `seal`, `open` |
|
|
251
251
|
| `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/bin/pki.js
CHANGED
|
@@ -190,7 +190,7 @@ function cmdVerify(args) {
|
|
|
190
190
|
var spki = anchor.subjectPublicKeyInfo;
|
|
191
191
|
return pki.path.validate(certs, {
|
|
192
192
|
time: time,
|
|
193
|
-
|
|
193
|
+
trustAnchors: { name: anchor.subject, publicKey: spki.bytes, algorithm: spki.algorithm.oid, parameters: spki.algorithm.parameters },
|
|
194
194
|
}).then(function (res) {
|
|
195
195
|
process.stdout.write(res.valid ? "valid\n" : "invalid\n");
|
|
196
196
|
if (!res.valid) {
|
package/lib/acme.js
CHANGED
|
@@ -43,7 +43,8 @@ var csr = require("./schema-csr");
|
|
|
43
43
|
var pkix = require("./schema-pkix");
|
|
44
44
|
var constants = require("./constants");
|
|
45
45
|
var rfc3339 = require("./rfc3339");
|
|
46
|
-
var
|
|
46
|
+
var wcEngine = require("./webcrypto");
|
|
47
|
+
var webcrypto = wcEngine.webcrypto;
|
|
47
48
|
var subtle = webcrypto.subtle;
|
|
48
49
|
var frameworkError = require("./framework-error");
|
|
49
50
|
var httpTransport = require("./http-transport");
|
|
@@ -2259,6 +2260,7 @@ function _parseLinkNext(headers, base) { var m = _parseLinkRelation(headers, bas
|
|
|
2259
2260
|
* - `tls` -- { anchors, useSystemStore, cert, key, minVersion, servername, checkServerIdentity } for the default transport.
|
|
2260
2261
|
* - `timeout` / `maxResponseBytes` / `maxRedirects` -- transport budgets; `maxNonceRetries` -- badNonce retry cap (default 1).
|
|
2261
2262
|
* - `maxPolls` / `maxTotalWait` / `sleep` -- poll-loop budgets + an injectable sleeper; `clock` -- an injectable receipt clock (default Date.now) for a Retry-After HTTP-date.
|
|
2263
|
+
* - `resignKeys` -- optional ordered `[{ alg, key }]` enabling the RFC 8555 sec. 6.2 badSignatureAlgorithm re-sign. When a CA rejects the account `alg` and advertises the algs it supports, the first entry whose `alg` the CA advertised re-signs the request once and retries. Each `key` is a CryptoKey for the SAME account key material under that `alg`: an RSA account key signs RS256 and PS256 from one key, and its registered public JWK verifies either. The caller's order selects and the CA's list only filters, so a spoofed badSignatureAlgorithm cannot force a weaker alg (sec. 10). Absent, or with no advertised match, the badSignatureAlgorithm surfaces unchanged.
|
|
2262
2264
|
* - `newAuthz(identifier)` -- pre-authorize a single identifier (RFC 8555 sec. 7.4.1) -> { authorization, url }.
|
|
2263
2265
|
* - `downloadCertificate(url, { expectedSpki, identifiers, requireBinding, selectChain, maxAlternates })` -- bind the issued certificate to this order, then pick among RFC 8555 sec. 7.4.2 alternate chains. `expectedSpki` (the DER SubjectPublicKeyInfo this order's CSR asked to have certified) and `identifiers` (the order's own identifier array) are what the returned end-entity certificate is checked against: a different key is `acme/certificate-key-mismatch`, a different identifier set `acme/certificate-identifier-mismatch`. Only `dns` and `ip` identifiers map to a name a certificate carries, so an order identifier of another registered type, a certificate `subjectAltName` that is neither a dNSName nor an iPAddress, and a subject common name that is neither a dns name nor a canonical IP address, are each refused as `acme/unsupported-identifier-type` rather than dropped from the comparison. The certificate's alternative names are its identity; its common name is read only where it asserts none. At least one is required (`acme/binding-required`) unless `requireBinding: false`, which waives the requirement to supply material and never the check on material that is supplied. The result reports `boundToKey` / `boundToIdentifiers`. `selectChain({certificate, chain, certificates})` returns the first truthy candidate (primary first, then bounded `Link rel="alternate"` chains, confined to the download's own origin); the result adds `alternates` (the resolved alternate URLs).
|
|
2264
2266
|
* - `renewalWindow(certDer, { random, clock, replaced, previous })` -- the RFC 9773 ARI renewal decision: composes `renewalInfo`, selects a uniform-random instant in the suggested window -> { suggestedWindow, selectedTime, renewNow, retryAfterSeconds, explanationURL }. Pass a prior result back as `previous` to REUSE its selectedTime while the CA's window is unchanged (RFC 9773 sec. 4.2), so repeated refreshes keep one stable renewal instant.
|
|
@@ -2272,7 +2274,7 @@ function _parseLinkNext(headers, base) { var m = _parseLinkRelation(headers, bas
|
|
|
2272
2274
|
// defaulted -- a mistyped `maxRedirects` or `transport` would otherwise change the client's behavior with
|
|
2273
2275
|
// no error. The per-request options each returned method takes are checked in those methods, not here.
|
|
2274
2276
|
var _CLIENT_OPTS = {
|
|
2275
|
-
accountKey: 1, accountJwk: 1, alg: 1, transport: 1, tls: 1, timeout: 1, maxResponseBytes: 1,
|
|
2277
|
+
accountKey: 1, accountJwk: 1, alg: 1, resignKeys: 1, transport: 1, tls: 1, timeout: 1, maxResponseBytes: 1,
|
|
2276
2278
|
maxRedirects: 1, maxNonceRetries: 1, maxPolls: 1, maxTotalWait: 1, sleep: 1, clock: 1,
|
|
2277
2279
|
};
|
|
2278
2280
|
// The per-request options each client method reads. A near-miss spelling in a method's own bag is
|
|
@@ -2299,6 +2301,49 @@ function _gateOpts(bag, whitelist, name) {
|
|
|
2299
2301
|
guard.identifier.assertKnownKeys(bag, whitelist, E, "acme/bad-input", "unknown " + name + " option ");
|
|
2300
2302
|
return bag;
|
|
2301
2303
|
}
|
|
2304
|
+
// Validate + snapshot opts.resignKeys (config-time, fail closed). Absent -> null (the feature is OFF and
|
|
2305
|
+
// a badSignatureAlgorithm surfaces to the caller unchanged). Present -> a non-empty array of { alg, key }
|
|
2306
|
+
// where alg is a JWS alg string and key is a CryptoKey; each pair is read ONCE into a frozen copy so a
|
|
2307
|
+
// caller getter cannot rewrite it between this check and the re-sign, and the caller's order is preserved.
|
|
2308
|
+
function _resignKeysOf(raw) {
|
|
2309
|
+
if (raw == null) return null;
|
|
2310
|
+
if (!_isArrayI(raw) || raw.length === 0) throw E("acme/bad-input", "resignKeys, when set, must be a non-empty array of { alg, key } (RFC 8555 sec. 6.2)");
|
|
2311
|
+
var out = [];
|
|
2312
|
+
for (var i = 0; i < raw.length; i++) {
|
|
2313
|
+
var e = raw[i];
|
|
2314
|
+
if (!_isObject(e)) throw E("acme/bad-input", "each resignKeys entry must be { alg: string, key: CryptoKey } (RFC 8555 sec. 6.2); entry " + i + " is not");
|
|
2315
|
+
// Read each field ONCE into the frozen snapshot: a caller getter cannot then answer the validation
|
|
2316
|
+
// and the re-sign with different values (the alg checked is the alg signed, the key checked is signed).
|
|
2317
|
+
var a = e.alg, k = e.key;
|
|
2318
|
+
if (!_isString(a) || !wcEngine.isCryptoKeyLike(k)) throw E("acme/bad-input", "each resignKeys entry must be { alg: string, key: CryptoKey } (RFC 8555 sec. 6.2); entry " + i + " is not");
|
|
2319
|
+
out[i] = intrinsic.freeze({ alg: a, key: k }); // index-write, not a live-prototype .push
|
|
2320
|
+
}
|
|
2321
|
+
return intrinsic.freeze(out);
|
|
2322
|
+
}
|
|
2323
|
+
// Pick the re-sign entry to use for a badSignatureAlgorithm: the FIRST caller-listed entry whose alg the
|
|
2324
|
+
// server advertised as supported. Caller order selects and the server's array only filters, so a bogus
|
|
2325
|
+
// badSignatureAlgorithm cannot steer the client to the server's weakest alg (RFC 8555 sec. 10). Returns
|
|
2326
|
+
// null when no caller entry matches (the badSignatureAlgorithm then surfaces, fail closed).
|
|
2327
|
+
function _chooseResign(resignKeys, serverAlgs) {
|
|
2328
|
+
if (!resignKeys || !_isArrayI(serverAlgs)) return null;
|
|
2329
|
+
for (var i = 0; i < resignKeys.length; i++) {
|
|
2330
|
+
for (var j = 0; j < serverAlgs.length; j++) {
|
|
2331
|
+
if (serverAlgs[j] === resignKeys[i].alg) return resignKeys[i];
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
return null;
|
|
2335
|
+
}
|
|
2336
|
+
// A copy of a public JWK with its optional `alg` member set to the alg actually being signed. A
|
|
2337
|
+
// WebCrypto-exported RSA JWK carries the key's native alg (e.g. RS256), so embedding it unchanged under a
|
|
2338
|
+
// re-sign alg (PS256) would advertise a JWK whose declared alg contradicts the JWS header, which a strict
|
|
2339
|
+
// ACME server may reject. Own members are copied through captured intrinsics (no live prototype read).
|
|
2340
|
+
function _jwkForAlg(jwk, alg) {
|
|
2341
|
+
var out = {};
|
|
2342
|
+
var keys = intrinsic.ownKeys(jwk);
|
|
2343
|
+
for (var i = 0; i < keys.length; i++) { out[keys[i]] = jwk[keys[i]]; }
|
|
2344
|
+
out.alg = alg;
|
|
2345
|
+
return out;
|
|
2346
|
+
}
|
|
2302
2347
|
function client(directoryUrl, opts) {
|
|
2303
2348
|
opts = guard.identifier.optionsObject(opts, E, "acme/bad-input", "pki.acme.client options");
|
|
2304
2349
|
guard.identifier.assertKnownKeys(opts, _CLIENT_OPTS, E, "acme/bad-input", "unknown pki.acme.client option ");
|
|
@@ -2308,6 +2353,12 @@ function client(directoryUrl, opts) {
|
|
|
2308
2353
|
jose.assertPublicJwk(opts.accountJwk);
|
|
2309
2354
|
var dirUrl = _clientUrl(directoryUrl);
|
|
2310
2355
|
var accountKey = opts.accountKey, accountJwk = opts.accountJwk, alg = opts.alg;
|
|
2356
|
+
// Optional badSignatureAlgorithm re-sign material (RFC 8555 sec. 6.2). Each entry pairs an alternative
|
|
2357
|
+
// JWS alg with a CryptoKey for the SAME account key material under that alg (an RSA public JWK is
|
|
2358
|
+
// padding-agnostic, so the registered account key still verifies a re-signed request). Snapshotted here
|
|
2359
|
+
// so a caller getter cannot rewrite the pairs between validation and use, and ordered by CALLER
|
|
2360
|
+
// preference: on a badSignatureAlgorithm the caller's order selects, never the server's (sec. 10).
|
|
2361
|
+
var resignKeys = _resignKeysOf(opts.resignKeys);
|
|
2311
2362
|
|
|
2312
2363
|
var transport = opts.transport;
|
|
2313
2364
|
if (!transport) {
|
|
@@ -2472,11 +2523,12 @@ function client(directoryUrl, opts) {
|
|
|
2472
2523
|
// with the error response's fresh nonce (RFC 8555 sec. 6.5 -- _takeNonce's LIFO pool surfaces it).
|
|
2473
2524
|
// `accept` is the media type the request advertises (default application/json). This is the single
|
|
2474
2525
|
// home of the badNonce retry, so every signed POST -- account-key or certificate-key -- inherits it.
|
|
2475
|
-
function _postJws(url, makeJws, accept, okStatuses) {
|
|
2526
|
+
function _postJws(url, makeJws, accept, okStatuses, allowResign) {
|
|
2476
2527
|
var ok = okStatuses || [200];
|
|
2477
2528
|
var attempt = 0;
|
|
2478
|
-
|
|
2479
|
-
|
|
2529
|
+
var resignAttempt = 0;
|
|
2530
|
+
function once(override) {
|
|
2531
|
+
return _takeNonce().then(function (nonce) { return makeJws(nonce, override); }).then(function (jws) {
|
|
2480
2532
|
return _send("POST", url, { "content-type": "application/jose+json", "accept": accept || "application/json" }, JSON.stringify(jws));
|
|
2481
2533
|
}).then(function (res) {
|
|
2482
2534
|
// An application/problem+json body is an ERROR regardless of the HTTP status -- a CA / proxy
|
|
@@ -2486,7 +2538,17 @@ function client(directoryUrl, opts) {
|
|
|
2486
2538
|
var prob = null;
|
|
2487
2539
|
try { prob = jose.parseJson(_jsonInput(res)); }
|
|
2488
2540
|
catch (_e) { /* allow:swallow-unverified a non-JSON problem body is not a badNonce; prob stays null, falling through to the typed acme/server-problem throw */ }
|
|
2489
|
-
|
|
2541
|
+
// A badNonce retry keeps the CURRENT signing override (a re-sign is not undone by a following
|
|
2542
|
+
// badNonce), so it re-signs with the same alg the request already switched to.
|
|
2543
|
+
if (prob && typeof prob.type === "string" && _endsWith(prob.type, ":badNonce") && attempt < maxNonceRetries) { attempt++; return once(override); }
|
|
2544
|
+
// A badSignatureAlgorithm (RFC 8555 sec. 6.2): if the caller supplied resignKeys and one of its
|
|
2545
|
+
// algs is in the server's advertised `algorithms`, re-sign ONCE with that caller-preferred key.
|
|
2546
|
+
// The caller's order selects (never the server's), so a bogus badSignatureAlgorithm cannot force
|
|
2547
|
+
// a downgrade (sec. 10); no match, or no resignKeys, falls through to the fail-closed throw.
|
|
2548
|
+
if (allowResign && resignKeys && prob && typeof prob.type === "string" && _endsWith(prob.type, ":badSignatureAlgorithm") && resignAttempt < 1) {
|
|
2549
|
+
var pick = _chooseResign(resignKeys, prob.algorithms);
|
|
2550
|
+
if (pick) { resignAttempt++; return once(pick); }
|
|
2551
|
+
}
|
|
2490
2552
|
}
|
|
2491
2553
|
throw _serverProblem(res);
|
|
2492
2554
|
}
|
|
@@ -2501,14 +2563,18 @@ function client(directoryUrl, opts) {
|
|
|
2501
2563
|
}
|
|
2502
2564
|
// Build an account-key JWS (kid or jwk mode) with a fresh nonce and POST it through the retrying _postJws.
|
|
2503
2565
|
function _post(url, builder, extra, mode, accept, okStatuses) {
|
|
2504
|
-
return _postJws(url, function (nonce) {
|
|
2566
|
+
return _postJws(url, function (nonce, override) {
|
|
2505
2567
|
// The client-owned JWS session fields (signing key, alg, single-use nonce, protected url) are applied
|
|
2506
2568
|
// AFTER the caller's payload options, so a payload carrying key/alg/nonce/url cannot override them and
|
|
2507
|
-
// desynchronize the protected header from the actual request (or reuse a nonce).
|
|
2508
|
-
|
|
2509
|
-
|
|
2569
|
+
// desynchronize the protected header from the actual request (or reuse a nonce). On a badSignatureAlgorithm
|
|
2570
|
+
// re-sign `override` carries the caller's chosen { alg, key } (the SAME account material under another alg);
|
|
2571
|
+
// the embedded jwk stays accountJwk, which still verifies it (an RSA public JWK is padding-agnostic).
|
|
2572
|
+
var base = Object.assign({}, extra || {}, { key: override ? override.key : accountKey, alg: override ? override.alg : alg, nonce: nonce, url: url });
|
|
2573
|
+
// On a re-sign the embedded jwk's declared alg must follow the alg being signed, not the account
|
|
2574
|
+
// key's native one, so the published JWK and the JWS header agree (jwk mode / newAccount).
|
|
2575
|
+
if (mode === "jwk") base.jwk = override ? _jwkForAlg(accountJwk, override.alg) : accountJwk; else base.kid = _requireKid();
|
|
2510
2576
|
return builder(base);
|
|
2511
|
-
}, accept, okStatuses);
|
|
2577
|
+
}, accept, okStatuses, true);
|
|
2512
2578
|
}
|
|
2513
2579
|
function _postAsGet(url) { return _post(url, postAsGet, null, "kid"); }
|
|
2514
2580
|
|
|
@@ -2863,6 +2929,11 @@ function client(directoryUrl, opts) {
|
|
|
2863
2929
|
// sync -- a bodyless or malformed account body must NOT leave the client on the old key while the
|
|
2864
2930
|
// server holds the new one. The account object is optional + informational: parse it best-effort.
|
|
2865
2931
|
accountKey = o.newKey; accountJwk = o.newJwk; alg = o.newAlg;
|
|
2932
|
+
// The resignKeys alternatives are CryptoKeys for the OLD account key material, so they no longer
|
|
2933
|
+
// match the rotated account key. Drop them: re-signing with a stale-key alternative under the new
|
|
2934
|
+
// kid would be rejected by the CA. With them cleared, a later badSignatureAlgorithm surfaces (the
|
|
2935
|
+
// conformant default) rather than retrying with obsolete key material.
|
|
2936
|
+
resignKeys = null;
|
|
2866
2937
|
var acct = null;
|
|
2867
2938
|
if (_bodyText(res).trim() !== "") {
|
|
2868
2939
|
try { acct = validate("account", _json(res)); }
|
package/lib/path-validate.js
CHANGED
|
@@ -1214,8 +1214,10 @@ function validateCriticalExtensionStructure(cert) {
|
|
|
1214
1214
|
* Validate an ordered certification `path` (anchor->target) against a trust
|
|
1215
1215
|
* anchor per RFC 5280 6.1. `path` is an array of `pki.schema.x509.parse`
|
|
1216
1216
|
* objects (or DER/PEM the function parses); `opts` carries `time` (the
|
|
1217
|
-
* always-on window check), `
|
|
1218
|
-
* parameters? }
|
|
1217
|
+
* always-on window check), `trustAnchors` (a single { name, publicKey,
|
|
1218
|
+
* algorithm, parameters? } anchor or a non-empty array of them; with several,
|
|
1219
|
+
* the one that issued the path terminus is selected), the 6.1.1 user-initial
|
|
1220
|
+
* inputs (`initialExplicitPolicy`,
|
|
1219
1221
|
* `initialAnyPolicyInhibit`, `initialPolicyMappingInhibit`,
|
|
1220
1222
|
* `userInitialPolicySet`, and `initialPermittedSubtrees` /
|
|
1221
1223
|
* `initialExcludedSubtrees` -- arrays of `{ tag, base }` where `tag` is the
|
|
@@ -1263,16 +1265,14 @@ function validateCriticalExtensionStructure(cert) {
|
|
|
1263
1265
|
* var res = await pki.path.validate([cert], {
|
|
1264
1266
|
* time: new Date("2020-01-01T00:00:00Z"),
|
|
1265
1267
|
* // the anchor's own key algorithm, not the algorithm its issuer signed it with
|
|
1266
|
-
*
|
|
1267
|
-
* algorithm: cert.subjectPublicKeyInfo.algorithm.oid },
|
|
1268
|
+
* trustAnchors: [{ name: cert.issuer, publicKey: cert.subjectPublicKeyInfo.bytes,
|
|
1269
|
+
* algorithm: cert.subjectPublicKeyInfo.algorithm.oid }],
|
|
1268
1270
|
* });
|
|
1269
1271
|
* res.valid; // boolean; res.results[0].checks carries the per-check codes
|
|
1270
1272
|
*/
|
|
1271
|
-
// Every option pki.path.validate reads. The anchor option
|
|
1272
|
-
//
|
|
1273
|
-
//
|
|
1274
|
-
// before this it bought them no anchoring and no error, just "a trustAnchor is required" about
|
|
1275
|
-
// an option they believed they had passed.
|
|
1273
|
+
// Every option pki.path.validate reads. The anchor option is `trustAnchors` (plural), the same
|
|
1274
|
+
// spelling pki.path.build / pki.cms.verify / pki.cmp.verify use, and it takes a single anchor or a
|
|
1275
|
+
// non-empty array of them. A one-element array reproduces the single-anchor behavior exactly.
|
|
1276
1276
|
// Derived from three sources, because no one of them is complete: the `opts.*` reads in this
|
|
1277
1277
|
// function, the `params.*` reads in the RFC 5280 6.1.1 state it is passed down to, and the
|
|
1278
1278
|
// documented prose above, which is the only place `requiredEku` and the three initial-policy
|
|
@@ -1282,7 +1282,7 @@ var _VALIDATE_OPTS = {
|
|
|
1282
1282
|
checkPurpose: 1, ctLogList: 1, ctPolicy: 1, historicalMode: 1, initialAnyPolicyInhibit: 1,
|
|
1283
1283
|
initialExcludedSubtrees: 1, initialExplicitPolicy: 1, initialPermittedSubtrees: 1,
|
|
1284
1284
|
initialPolicyMappingInhibit: 1, maxPathCerts: 1, maxPolicyNodes: 1, requireRevocation: 1,
|
|
1285
|
-
requiredEku: 1, revocationChecker: 1, softFail: 1, time: 1,
|
|
1285
|
+
requiredEku: 1, revocationChecker: 1, softFail: 1, time: 1, trustAnchors: 1,
|
|
1286
1286
|
userInitialPolicySet: 1, verifier: 1
|
|
1287
1287
|
};
|
|
1288
1288
|
|
|
@@ -1328,8 +1328,7 @@ async function ctGateCheck(leaf, certs, anchor, ctLogList, ctPolicy, n, at) {
|
|
|
1328
1328
|
async function validate(path, opts) {
|
|
1329
1329
|
opts = guard.identifier.optionsObject(opts, E, "path/bad-input", "validate: opts");
|
|
1330
1330
|
guard.identifier.assertKnownKeys(opts, _VALIDATE_OPTS, E, "path/bad-input",
|
|
1331
|
-
"pki.path.validate has an unknown option
|
|
1332
|
-
"pki.path.build takes `trustAnchors`. The unknown option was: ");
|
|
1331
|
+
"pki.path.validate has an unknown option (the anchor option is `trustAnchors`, a single anchor or an array). The unknown option was: ");
|
|
1333
1332
|
// A CT policy without a log-list cannot be enforced -- refuse it at entry rather than silently
|
|
1334
1333
|
// skipping the CT gate and returning a valid path the caller believes was CT-checked (fail-closed).
|
|
1335
1334
|
if (opts.ctPolicy != null && opts.ctLogList == null) throw E("path/bad-input", "validate: opts.ctPolicy requires opts.ctLogList (the trusted CT log-list to verify the certificate's SCTs against)");
|
|
@@ -1358,11 +1357,52 @@ async function validate(path, opts) {
|
|
|
1358
1357
|
var certs = path.map(function (c, ci) { return coerceCert(c, "validate: path[" + ci + "]"); });
|
|
1359
1358
|
var n = certs.length;
|
|
1360
1359
|
if (n < 1) throw E("path/empty-path", "validate: the certification path is empty");
|
|
1361
|
-
|
|
1362
|
-
//
|
|
1363
|
-
//
|
|
1364
|
-
//
|
|
1365
|
-
|
|
1360
|
+
// trustAnchors is a single anchor OR a non-empty array of them. Normalize each at the door: a parsed
|
|
1361
|
+
// root CERTIFICATE becomes a { name, publicKey, algorithm } tuple, and a mis-shaped tuple fails closed
|
|
1362
|
+
// with path/bad-input rather than seeding an undefined working key a self-describing SPKI could still
|
|
1363
|
+
// validate against (a soft-verdict fail-open). With more than one anchor, select the one whose subject
|
|
1364
|
+
// issued the top path cert (certs[0], anchor->target order); a one-element list reproduces the
|
|
1365
|
+
// single-anchor behavior exactly, and no match falls to the first so the name-chaining check below
|
|
1366
|
+
// fails closed (an untrusted chain is never validated against a non-matching root).
|
|
1367
|
+
var rawAnchors = opts.trustAnchors;
|
|
1368
|
+
var anchorList = intrinsic.isArray(rawAnchors) ? rawAnchors : [rawAnchors];
|
|
1369
|
+
if (rawAnchors == null || anchorList.length === 0) throw E("path/bad-input", "validate: trustAnchors is required (a single anchor or a non-empty array of anchors)");
|
|
1370
|
+
// With more than one anchor, validate the SAME path against each and accept if ANY yields a valid path
|
|
1371
|
+
// (the trust-store "does this chain lead to any of my roots" question). Two anchors can share a subject
|
|
1372
|
+
// DN with different keys (a rotated or cross-signed root), so choosing by name alone would reject a
|
|
1373
|
+
// valid chain when the wrong same-name anchor is tried first: try each, order-independent for validity.
|
|
1374
|
+
// Each recursion carries exactly one anchor, so it runs the single-anchor body below (no further fan-out).
|
|
1375
|
+
if (anchorList.length > 1) {
|
|
1376
|
+
// Normalize the COMPLETE array up front: toAnchor validates each tuple exactly as the single-anchor
|
|
1377
|
+
// path does, so a malformed anchor is refused (path/bad-input) regardless of its position, never
|
|
1378
|
+
// silently accepted because a valid anchor happened to be tried first.
|
|
1379
|
+
var tuples = [];
|
|
1380
|
+
for (var ti = 0; ti < anchorList.length; ti++) { tuples[ti] = toAnchor(anchorList[ti], "validate"); }
|
|
1381
|
+
// One anchor per recursion, so each runs the single-anchor body below (no further fan-out).
|
|
1382
|
+
var singleOpts = function (a) {
|
|
1383
|
+
var keys = intrinsic.ownKeys(opts);
|
|
1384
|
+
var o = {};
|
|
1385
|
+
for (var ki = 0; ki < keys.length; ki++) { o[keys[ki]] = opts[keys[ki]]; }
|
|
1386
|
+
o.trustAnchors = a;
|
|
1387
|
+
return o;
|
|
1388
|
+
};
|
|
1389
|
+
// Only an anchor whose subject issued certs[0] can complete this path, so try each such candidate and
|
|
1390
|
+
// skip unrelated roots: the cost does not scale with a whole trust store. Same-subject anchors with
|
|
1391
|
+
// different keys (a rotated or cross-signed root) ALL match the terminus and are each tried, so validity
|
|
1392
|
+
// is order-independent.
|
|
1393
|
+
var lastMatched = null, anyMatched = false;
|
|
1394
|
+
for (var ai = 0; ai < tuples.length; ai++) {
|
|
1395
|
+
if (!dnEqual(tuples[ai].name.rdns, certs[0].issuer.rdns)) continue;
|
|
1396
|
+
anyMatched = true;
|
|
1397
|
+
var oneRes = await validate(path, singleOpts(tuples[ai]));
|
|
1398
|
+
if (oneRes && oneRes.valid) return oneRes;
|
|
1399
|
+
lastMatched = oneRes;
|
|
1400
|
+
}
|
|
1401
|
+
// No supplied anchor's subject matched the path terminus: the chain leads to none of them. Run one to
|
|
1402
|
+
// produce the fail-closed valid:false verdict (the name-chaining check reports it) rather than null.
|
|
1403
|
+
return anyMatched ? lastMatched : validate(path, singleOpts(tuples[0]));
|
|
1404
|
+
}
|
|
1405
|
+
var anchor = toAnchor(anchorList[0], "validate");
|
|
1366
1406
|
// The validity-window check is always on (6.1.3(a)(2)); a missing/invalid
|
|
1367
1407
|
// check date must fail closed, never silently disable it.
|
|
1368
1408
|
guard.time.assertValid(opts.time, E, "path/bad-input", "validate: opts.time (the always-on validity-window check date)");
|
|
@@ -3214,8 +3254,8 @@ function toAnchor(entry, verb) {
|
|
|
3214
3254
|
* @status stable
|
|
3215
3255
|
* @spec RFC 5280 sec. 6.1.1
|
|
3216
3256
|
*
|
|
3217
|
-
* Turn a parsed certificate into the trust-anchor tuple `pki.path.validate`
|
|
3218
|
-
*
|
|
3257
|
+
* Turn a parsed certificate into the trust-anchor tuple `pki.path.validate` and `pki.path.build` consume
|
|
3258
|
+
* as `opts.trustAnchors` (a single anchor or an array), so a root can be pinned directly instead of
|
|
3219
3259
|
* hand-building `{ name, publicKey, algorithm }`. The subject becomes the anchor name and its
|
|
3220
3260
|
* SubjectPublicKeyInfo the anchor key and algorithm. A value that is neither a parsed certificate nor a
|
|
3221
3261
|
* ready anchor tuple is refused with `path/bad-input`. `validate` and `build` already normalize a
|
|
@@ -3226,7 +3266,7 @@ function toAnchor(entry, verb) {
|
|
|
3226
3266
|
* var rootDer = await pki.x509.sign({ subject: "Root CA", subjectPublicKey: await pki.key.export(pair.publicKey),
|
|
3227
3267
|
* notBefore: new Date(0), notAfter: new Date("2999-01-01") }, { key: pair.privateKey });
|
|
3228
3268
|
* var anchor = pki.path.anchorFromCert(pki.schema.x509.parse(rootDer));
|
|
3229
|
-
* // anchor is { name, publicKey, algorithm, ... } -- pass it as opts.
|
|
3269
|
+
* // anchor is { name, publicKey, algorithm, ... } -- pass it as opts.trustAnchors to pki.path.validate
|
|
3230
3270
|
*/
|
|
3231
3271
|
function anchorFromCert(cert) { return toAnchor(cert, "anchorFromCert"); }
|
|
3232
3272
|
|
|
@@ -3489,11 +3529,8 @@ async function _fetchAiaIssuers(current, aia) {
|
|
|
3489
3529
|
// pki.path.build takes its own options AND forwards every pki.path.validate option to each
|
|
3490
3530
|
// internal validate() call. The documented contract above says so, and validateOpts() copies
|
|
3491
3531
|
// them wholesale. So the accepted set is the union, derived from _VALIDATE_OPTS. Retyping it
|
|
3492
|
-
// would let the two lists drift the first time validate gains an option.
|
|
3493
|
-
//
|
|
3494
|
-
// `trustAnchor` is removed from the union on purpose: build assigns it per candidate anchor,
|
|
3495
|
-
// so a caller passing the singular would have it overwritten and silently ignored. Refusing it
|
|
3496
|
-
// is what makes the singular/plural message below actionable rather than trivia.
|
|
3532
|
+
// would let the two lists drift the first time validate gains an option. Both verbs now spell the
|
|
3533
|
+
// anchor option `trustAnchors`, so the union carries one key and build applies its own per-candidate.
|
|
3497
3534
|
var _BUILD_OPTS = (function () {
|
|
3498
3535
|
var o = {
|
|
3499
3536
|
aiaTimeout: 1, candidates: 1, fetchAia: 1, intermediates: 1, maxAiaFetches: 1,
|
|
@@ -3501,15 +3538,13 @@ var _BUILD_OPTS = (function () {
|
|
|
3501
3538
|
maxResponseBytes: 1, time: 1, tls: 1, transport: 1, trustAnchors: 1, validate: 1
|
|
3502
3539
|
};
|
|
3503
3540
|
Object.keys(_VALIDATE_OPTS).forEach(function (k) { o[k] = 1; });
|
|
3504
|
-
delete o.trustAnchor;
|
|
3505
3541
|
return o;
|
|
3506
3542
|
})();
|
|
3507
3543
|
|
|
3508
3544
|
async function build(leaf, opts) {
|
|
3509
3545
|
opts = guard.identifier.optionsObject(opts, E, "path/bad-input", "build: opts");
|
|
3510
3546
|
guard.identifier.assertKnownKeys(opts, _BUILD_OPTS, E, "path/bad-input",
|
|
3511
|
-
"pki.path.build has an unknown option
|
|
3512
|
-
"pki.path.validate takes `trustAnchor`. The unknown option was: ");
|
|
3547
|
+
"pki.path.build has an unknown option (the anchor option is `trustAnchors`). The unknown option was: ");
|
|
3513
3548
|
var leafCert;
|
|
3514
3549
|
try { leafCert = coerceCert(leaf); }
|
|
3515
3550
|
catch (e) { throw E("path/bad-input", "build: the leaf certificate did not parse", e); }
|
|
@@ -3635,7 +3670,7 @@ async function build(leaf, opts) {
|
|
|
3635
3670
|
// apply the method rule a second time and drop a function-valued option all over again;
|
|
3636
3671
|
// forwarding less than was validated is worse than refusing the bag.
|
|
3637
3672
|
Object.keys(forwarded).forEach(function (f) { vo[f] = forwarded[f]; });
|
|
3638
|
-
vo.
|
|
3673
|
+
vo.trustAnchors = anchor; // one anchor per candidate; validate accepts a single anchor or an array
|
|
3639
3674
|
return vo;
|
|
3640
3675
|
}
|
|
3641
3676
|
|
package/lib/sigstore.js
CHANGED
|
@@ -477,7 +477,7 @@ async function _verifyChain(leaf, chainDers, fulcioRoots, timeMs) {
|
|
|
477
477
|
var res = await pathValidate.validate(path, {
|
|
478
478
|
time: new Date(timeMs),
|
|
479
479
|
historicalMode: true,
|
|
480
|
-
|
|
480
|
+
trustAnchors: { name: anchor.subject, publicKey: spki.bytes, algorithm: spki.algorithm.oid, parameters: spki.algorithm.parameters },
|
|
481
481
|
requiredEku: ["codeSigning"],
|
|
482
482
|
});
|
|
483
483
|
if (res.valid) return;
|
package/lib/trust.js
CHANGED
|
@@ -875,7 +875,7 @@ function parseCcadbCsv(text) {
|
|
|
875
875
|
* var entry = pki.trust.parseCcadbCsv(csv).anchors[0];
|
|
876
876
|
* var anchor = pki.trust.anchor(entry, { purpose: "serverAuth" });
|
|
877
877
|
* await pki.path.validate([pki.schema.x509.parse(der)],
|
|
878
|
-
* { time: new Date("2026-06-01T00:00:00Z"),
|
|
878
|
+
* { time: new Date("2026-06-01T00:00:00Z"), trustAnchors: anchor, checkPurpose: "serverAuth" });
|
|
879
879
|
*/
|
|
880
880
|
// `purpose` is the only option, and it gates the authorization itself, so a near-miss spelling must not
|
|
881
881
|
// pass silently: a caller who wrote the plural `purposes` (or carried a sibling's word) would otherwise get
|
package/lib/tsp-sign.js
CHANGED
|
@@ -697,13 +697,13 @@ function _buildTsaChains(leaf, pool) {
|
|
|
697
697
|
* serialNumberHex, policy, nonce, tsa, tstInfo, signer, code?, reason? }`. `valid` is true only
|
|
698
698
|
* when the CMS signature, the imprint match, the eContentType, the ESSCertID(V2) binding, the
|
|
699
699
|
* RFC 3161 sec. 2.3 critical single-`timeStamping` extendedKeyUsage rule, the requested nonce (when
|
|
700
|
-
* supplied), and, when
|
|
700
|
+
* supplied), and, when `trustAnchors` is supplied, the full out-of-path TSA-cert path
|
|
701
701
|
* validation all pass. A conformance / trust failure of a well-formed token is a
|
|
702
702
|
* `{ valid:false, code }` verdict; malformed or config input throws a typed `TspError`.
|
|
703
703
|
*
|
|
704
704
|
* `trusted` is the second claim and is kept apart from the first. `valid` says the token's
|
|
705
705
|
* signature and structural bindings hold; `trusted` says the timestamp authority chained to an
|
|
706
|
-
* anchor this caller named. Without `
|
|
706
|
+
* anchor this caller named. Without `trustAnchors` there is nothing to chain to and `trusted` is
|
|
707
707
|
* `false`: every branch, refusing and accepting alike, carries the field with a definite
|
|
708
708
|
* value. A timestamp is archived precisely to be re-read years later, and one boolean
|
|
709
709
|
* cannot answer both questions then.
|
|
@@ -714,8 +714,8 @@ function _buildTsaChains(leaf, pool) {
|
|
|
714
714
|
* un-revoked. It is `false` whenever no path ran at all. `anchorConstraints` carries whatever the anchor
|
|
715
715
|
* itself constrained, from `pki.path.validate`.
|
|
716
716
|
*
|
|
717
|
-
* @opts
|
|
718
|
-
* TSA certificate chain
|
|
717
|
+
* @opts trustAnchors Anchor `{ name, publicKey, algorithm }`, or a non-empty array of them. Runs
|
|
718
|
+
* `pki.path.validate` on the TSA certificate chain from the token's certificates
|
|
719
719
|
* (validity at genTime, requiredEku timeStamping, and revocation when a
|
|
720
720
|
* `revocationChecker` is supplied, with `revocationChecked` reporting which),
|
|
721
721
|
* so a TSA under an intermediate CA validates, not only one directly under
|
|
@@ -739,11 +739,11 @@ function _buildTsaChains(leaf, pool) {
|
|
|
739
739
|
* var imprint = { hashAlgorithm: "sha256", hashedMessage: sha256Digest };
|
|
740
740
|
* var token = await pki.tsp.sign(imprint, { cert: signerCertDer, key: signerKeyPkcs8 }, { policy: "1.3.6.1.4.1.1", serialNumber: 1 });
|
|
741
741
|
* var res = await pki.tsp.verify(token, Buffer.from("hello"), {});
|
|
742
|
-
* res.valid; // boolean; pass opts.
|
|
742
|
+
* res.valid; // boolean; pass opts.trustAnchors to also chain the TSA cert to a root
|
|
743
743
|
* res.genTime; // Date, read from the verified eContent
|
|
744
744
|
*/
|
|
745
745
|
// Every option pki.tsp.verify reads. Adding one here is the only way to make it accepted.
|
|
746
|
-
var _VERIFY_OPTS = { certs: 1,
|
|
746
|
+
var _VERIFY_OPTS = { certs: 1, trustAnchors: 1, nonce: 1, reqPolicy: 1, revocationChecker: 1 };
|
|
747
747
|
|
|
748
748
|
// Does any extension carry the critical flag? The answer IS the unknown-critical-extension gate, so
|
|
749
749
|
// it is decided by comparison instead of by a `some`. See guard-list.
|
|
@@ -754,13 +754,12 @@ function _anyCritical(extensions) {
|
|
|
754
754
|
async function verify(token, data, opts) {
|
|
755
755
|
opts = opts || {};
|
|
756
756
|
if (typeof opts !== "object" || Buffer.isBuffer(opts)) throw _err("tsp/bad-input", "pki.tsp.verify options must be an object");
|
|
757
|
-
// An unrecognized option is refused rather than ignored.
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
//
|
|
761
|
-
// nothing to notice it by. Naming the difference at the boundary is the only place it is cheap.
|
|
757
|
+
// An unrecognized option is refused rather than ignored. The anchor option is `trustAnchors` (an
|
|
758
|
+
// anchor tuple, or a non-empty array of them), the same spelling pki.cms.verify / pki.cmp.verify use.
|
|
759
|
+
// A caller who mistyped it would otherwise get no anchoring and no error: the TSA certificate
|
|
760
|
+
// unchained, `valid: true`, and nothing to notice it by. Naming it at the boundary is cheap.
|
|
762
761
|
guard.identifier.assertKnownKeys(opts, _VERIFY_OPTS, _err, "tsp/bad-input",
|
|
763
|
-
"pki.tsp.verify has an unknown option (
|
|
762
|
+
"pki.tsp.verify has an unknown option (the anchor option is `trustAnchors`) ");
|
|
764
763
|
if (opts.certs != null && (!Array.isArray(opts.certs) || !guard.list.allMatch(opts.certs, function (c) { return guard.bytes.isByteSource(c); }))) {
|
|
765
764
|
throw _err("tsp/bad-input", "pki.tsp.verify opts.certs must be an array of DER certificate Buffers"); // a bad element is a caller error, never silently dropped
|
|
766
765
|
}
|
|
@@ -827,7 +826,7 @@ async function verify(token, data, opts) {
|
|
|
827
826
|
if (!_tsaMatchesCert(tst.tsa.bytes, sc)) return fail("tsp/tsa-mismatch");
|
|
828
827
|
}
|
|
829
828
|
// M18 -- full out-of-path TSA-cert validation (issuer-sig, validity at genTime, unknown-critical,
|
|
830
|
-
// key-param inheritance, requiredEku, optional revocation), only when
|
|
829
|
+
// key-param inheritance, requiredEku, optional revocation), only when trustAnchors is supplied.
|
|
831
830
|
// The path is ordered from the token's embedded certificates (leaf + any intermediates), so a TSA
|
|
832
831
|
// issued under an intermediate CA -- not just directly under the anchor -- validates.
|
|
833
832
|
// `trusted` is the SECOND claim, kept apart from `valid`. `valid` says the token's signature and
|
|
@@ -838,7 +837,7 @@ async function verify(token, data, opts) {
|
|
|
838
837
|
// nothing to chain to and `trusted` is false: a definite answer rather than a missing one, the
|
|
839
838
|
// same shape pki.cms.verify and pki.cmp.verify return.
|
|
840
839
|
var trusted = false;
|
|
841
|
-
if (opts.
|
|
840
|
+
if (opts.trustAnchors) {
|
|
842
841
|
var pathRes = null;
|
|
843
842
|
// tst.genTime floors to millisecond precision. When genTime carries sub-millisecond digits the true
|
|
844
843
|
// instant lies in (floor, floor+1ms], so fail closed on BOTH validity boundaries: require each
|
|
@@ -877,7 +876,7 @@ async function verify(token, data, opts) {
|
|
|
877
876
|
// revocation.
|
|
878
877
|
async function validateAt(chain, when) {
|
|
879
878
|
var res = await pathValidate.validate(chain, {
|
|
880
|
-
time: when,
|
|
879
|
+
time: when, trustAnchors: opts.trustAnchors, requiredEku: ["timeStamping"], checkPurpose: "timeStamping", revocationChecker: opts.revocationChecker,
|
|
881
880
|
});
|
|
882
881
|
// The most any attempt ESTABLISHED, kept across every attempt: backtracking tries several
|
|
883
882
|
// chains for one TSA certificate and a fractional genTime validates each at two endpoints,
|
package/lib/webauthn-mds.js
CHANGED
|
@@ -506,7 +506,7 @@ function _chainToAnchor(chain, anchors, at, what, code) {
|
|
|
506
506
|
// carries these forward as the working public key, and a certificate below the anchor may
|
|
507
507
|
// inherit its key parameters from them (the DSA-style parameter-inheritance case), so
|
|
508
508
|
// supplying the signature OID leaves such a key unreconstructable and refuses a valid chain.
|
|
509
|
-
|
|
509
|
+
trustAnchors: { name: anchor.subject, publicKey: anchor.subjectPublicKeyInfo.bytes,
|
|
510
510
|
algorithm: anchor.subjectPublicKeyInfo.algorithm.oid,
|
|
511
511
|
parameters: anchor.subjectPublicKeyInfo.algorithm.parameters },
|
|
512
512
|
}).then(function (r) { return !!(r && r.valid); }, function (e) { lastFault = e; return false; });
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:bfbe087f-30ed-484a-ba11-142da30522a8",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-08-
|
|
8
|
+
"timestamp": "2026-08-31T23:06:26.187Z",
|
|
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.6.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.6.11",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.6.
|
|
25
|
+
"version": "0.6.11",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/pki@0.6.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.6.11",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/pki@0.6.
|
|
57
|
+
"ref": "@blamejs/pki@0.6.11",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|