@blamejs/pki 0.5.0 → 0.5.2

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.5.2 — 2026-08-13
8
+
9
+ pki.cms.verify gains a trust seam: name the roots you accept, and the verdict says whether the signer chained to one.
10
+
11
+ ### Added
12
+
13
+ - pki.cms.verify(input, opts) accepts opts.trustAnchors -- the roots the caller accepts, as certificate DER or anchor tuples -- and returns trusted alongside valid, with a per-signer trusted on each entry of signers. The signer certificate is chained through the same path engine pki.path.validate uses, with the SignedData's own certificates offered as intermediates and never as anchors. Every signer must chain for the message to be trusted, the same rule the per-signer signature check already followed: reporting the whole as trusted because one signer anchored would let an unanchored signer ride out on another's chain. opts.time picks the instant the chain is judged at. Without anchors there is nothing to chain to and trusted is false -- a definite answer rather than a missing one, and the same shape pki.cmp.verify returns. Anchors that cannot be read are a caller's configuration mistake and throw, rather than being absorbed into trusted: false, which would report a verdict about the message for a check that never ran.
14
+ - pki.smime.verify forwards opts.trustAnchors and opts.time to the CMS verification beneath it and surfaces trusted in its own verdict. It documents itself as that verdict plus the MIME surface, so the seam had to reach it: building the options from scratch and passing only certs would leave a caller naming anchors with no way to have them applied. It asks for the emailProtection key purpose when anchoring, at both ends of the chain: requiredEku constrains the signer certificate, because a certificate restricted to serverAuth chains to its root perfectly well and is still the wrong key to have signed a message (RFC 8551 sec. 4.4.4), and checkPurpose selects the anchor's own trust metadata, because a root distributed with NSS trust bits can be marked untrusted for email while remaining a good TLS root -- and those bits, along with distrustAfter, are consulted only when a purpose is named. Asking one without the other checks one end of the chain and not the other. Pass requiredEku or checkPurpose to ask for something else.
15
+ - pki.webauthn accepts RSASSA-PSS credential keys at all three strengths: PS384 (-38) and PS512 (-39) join PS256, in registration and in assertion verification. They were previously refused at parse time on keys that are perfectly well-formed -- the same bytes accepted under -37 -- so a relying party holding credential rows written by another implementation had some it simply could not check, and could not tell which without scanning its own table. An algorithm this verifier does not implement now reports webauthn/unsupported-algorithm rather than webauthn/bad-cose-key: the key is not malformed, and only one of those two facts tells an operator that re-registering the credential cannot help.
16
+ - pki.webauthn.parseCoseKey(bytes) decodes a stored credential public key on its own, and pki.webauthn.verifyAssertion accepts credentialPublicKey as either that parsed object or its COSE bytes. The registration-to-login round trip had a gap in the middle: verify returns the key as an object, but the durable form is bytes -- the object carries Buffers, so a JSON round trip through a datastore returns {"type":"Buffer","data":[...]} rather than what went in, and existing credential rows already hold COSE bytes whoever wrote them. The only routes into the decoder parsed a containing structure, so recovering a stored key meant fabricating an authenticatorData that never existed. A registration verdict now also carries credentialPublicKeyBytes, the form to persist.
17
+
18
+ ### Changed
19
+
20
+ - A signer certificate whose keyUsage forbids signing is not trusted, however well it chains. RFC 5280 sec. 4.2.1.3 makes the extension binding when present, so a leaf asserting keyEncipherment alone must not verify a signature -- and path validation checks the CA's keyCertSign, not the target's own usage. The verb that knows a signature was made asks the question, the same format-local gate pki.cmp.verify applies, reading the value through the one strict decoder so a malformed keyUsage fails the gate rather than a hand-rolled bit test authorizing it. contentCommitment counts alongside digitalSignature. The signature is still reported sound; what changes is whether the certificate was permitted to have made it.
21
+ - pki.cms.verify and pki.tsp.verify refuse an unrecognized option instead of ignoring it. This is what kept the missing trust seam silent -- a caller writing trustAnchors before it existed, or trustAnchor now, got a verdict that looked anchored and was not. It matters most between these two verbs, because they spell the anchor option differently: pki.tsp.verify takes trustAnchor, singular, an anchor tuple, while pki.cms.verify and pki.cmp.verify take trustAnchors, plural, accepting certificate DER. Carrying the plural spelling to pki.tsp.verify previously meant no anchoring and no error -- an unchained TSA certificate under valid: true. The refusal names the difference.
22
+
23
+ ### Security
24
+
25
+ - Build and analysis pins move up: github/codeql-action to v4.37.6 across all six references, ossf/scorecard-action to v2.4.4, actions/setup-python to v7.0.0, the ClusterFuzzLite base-builder-javascript image to its current digest, and eslint to 10.8.1. Nothing here reaches the published tarball -- the package still declares no runtime dependencies -- and every action stays pinned by commit SHA with its version in a trailing comment.
26
+
27
+ ## v0.5.1 — 2026-08-12
28
+
29
+ Four verify and export paths stop answering a question other than the one they were asked: the key you supply governs, and a private key exports as one.
30
+
31
+ ### Changed
32
+
33
+ - pki.sigstore.verifyBundle reports identityChecked alongside verified: a boolean per identity field showing which were actually compared. verified: true says the artifact was signed and logged, not that a party you trust signed it -- Fulcio issues a certificate to anyone who completes an OIDC flow, so who signed is decided only by opts.identity, and the two claims were previously indistinguishable in the verdict. An opts.identity naming none of san, issuer or sourceRepositoryURI is now refused rather than satisfied: every comparison inside it was falsy, so it accepted every signer while reading as a policy in force. An unrecognized field name is refused for the same reason -- cosign spells this certificateIdentity, and swallowed it pinned nothing under a name the operator believed constrained the signer.
34
+
35
+ ### Fixed
36
+
37
+ - pki.webcrypto.subtle.exportKey("raw", privateKey) is refused with webcrypto/not-supported rather than answered with the public key. The W3C definition of raw covers public and secret keys; there is no raw private-key serialization for EC or OKP, and Node's own WebCrypto refuses it too. The consequence ran through wrapKey, which forwards the caller's format straight to exportKey: a private key wrapped as raw escrowed the PUBLIC key, and unwrapping it returned a handle announcing usages ["sign"] that cannot sign, with the private key gone and no error at any step. Use pkcs8 or jwk to serialize a private key; the public half still exports as raw.
38
+ - A post-quantum private key exported to a JWK re-imports as a private key. ML-DSA, ML-KEM and SLH-DSA JWKs are kty: "AKP" and carry the private half in priv, while the import tested only for the d an EC or OKP key uses -- so every PQC private JWK read as public. The re-imported key was type public yet still announced usages ["sign"], and extractable was forced true even where the caller asked for false: a key that could not sign, said it could, and ignored the extractability it was given. Round-tripping now preserves the half that signs.
39
+ - pki.jose.verify treats opts.key as the key the message must be signed under. Where the profile also permits an embedded header jwk -- acme-outer does -- the embedded key was preferred and the two were never compared, so the sender chose which key verified its own message and a caller supplying the account key it expected got no benefit from doing so. The two must now be the same key, compared as RFC 7638 thumbprints so member order cannot make equal keys differ, and a disagreement is refused with jose/key-mismatch. The verdict carries keySource, naming which key answered, because a signature checked against a key the caller named is a different claim from one checked against the key the message brought with it.
40
+
7
41
  ## v0.5.0 — 2026-08-12
8
42
 
9
43
  CMC -- Certificate Management over CMS -- ships end to end: build a Full PKI Request, carry it to a CA over EST, and read the response into one terminal outcome.
package/README.md CHANGED
@@ -222,7 +222,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
222
222
  | `pki.schema.csrattrs` | EST CSR Attributes (`CsrAttrs`, RFC 8951 §3.5 / RFC 9908) — the `AttrOrOID` items a server sends to shape an enrollment: bare OIDs, attributes with raw values, and decoded views of the RFC 9908 meaningful types (extension requests, EC and RSA key-type conventions, the certification-request-info template). Unknown types are surfaced raw; structure and the RFC 9908 semantic MUSTs are fail-closed — `parse` |
223
223
  | `pki.est` | Enrollment over Secure Transport (RFC 7030 / 8951 / 9908 / 7616). The client verbs `cacerts`, `simpleenroll`, `simplereenroll`, `serverkeygen`, `csrattrs`, and `fullcmc` drive the RFC 7030 flow over `pki.transport` (inject your own via `opts.transport`, or take the fail-closed default): https only, an explicit trust anchor required, same-origin redirects followed while a downgrade or loop is refused, a 202 Retry-After surfaced but never slept, HTTP Basic or Digest (RFC 7616, SHA-256 / SHA-512-256; MD5 and no-qop refused by default) answered only after the server is authenticated, and the issued certificate chosen by public-key match. `serverkeygen` requests a server-generated key, cleartext or an opaque CMS EnvelopedData, with encryption bound to the CSR's key-identifier attribute over a confidentiality-bearing cipher. `csrattrs` fetches the CA's RFC 9908 attributes policy. `fullcmc` (§4.3) carries a CMC Full PKI Request and reduces the CA's answer through `pki.cmc.verify` to one terminal outcome, refusing a response that fails to echo the transaction and nonce the request carried, or that covers a key the request never asked for; a certs-only reply to a bound request is refused rather than read as an issuance. Under the verbs sit the transport-agnostic codecs they compose: the RFC 8951 base64 transfer codec (blind to Content-Transfer-Encoding), the `multipart/mixed` splitter, the certs-only and serverkeygen response validators over CMS, the enroll-attribute builders, and the HTTP response classifier — `transferDecode`/`transferEncode`, `parseCertsOnly`, `splitMultipartMixed`, `parseServerKeygenResponse`, `findIssuedCert`, `classifyResponse`, `paths`, and the builders |
224
224
  | `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` |
225
- | `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 — `sign`, `verify`, `base64url`, `parseJson`, `thumbprint`, `assertPublicJwk` |
225
+ | `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 favour. `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` |
226
226
  | `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` 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`, `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` |
227
227
  | `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` |
228
228
  | `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`. 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 defence. `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` |
@@ -237,8 +237,8 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
237
237
  | `pki.crl` | CRL issuance and verification (RFC 5280 §5). `sign(spec, issuer, opts)` builds and signs a `CertificateList` from a `spec` of `thisUpdate` and `nextUpdate`, an optional `crlNumber`, a `revoked` array (each entry a `serialNumber` and `revocationDate` with an optional `reason` or `invalidityDate`), and an optional `extensions` object (authority key identifier, issuing distribution point, delta-CRL indicator, freshest CRL, authority information access) or an array of pre-encoded Extension DER, with an `issuer` of `{ cert, key }` or `{ name, publicKey, key }`. The signature algorithm is resolved from the issuer key, so RSA (PKCS#1 v1.5 or PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch. The version is derived from the extension set (v2 when any CRL or entry extension is present, else v1), the outer `signatureAlgorithm` matches `tbsCertList.signature`, an empty revocation list omits the field rather than emitting an empty SEQUENCE, `reasonCode` is an ENUMERATED and `invalidityDate` is always GeneralizedTime, per-extension criticality is fixed by the RFC, and the produced signature is verified under the issuer key before return. `verify(crl, issuer)` checks a CRL signature through the one path-validation signature engine, algorithm-confusion and EdDSA low-order gates included, and `isRevoked(crl, serialNumber)` looks a serial up. Returns DER, or a PEM `X509 CRL` with `opts.pem`; malformed input throws a typed `CrlError`. Parsing stays at `pki.schema.crl.parse` — `sign`, `verify`, `isRevoked` |
238
238
  | `pki.key` | Key-material lifecycle (RFC 5958 / RFC 8018). `encrypt(privateKey, password, opts)` wraps a PKCS#8 private key (DER, PEM, or an extractable `CryptoKey`) into an `EncryptedPrivateKeyInfo` under PBES2 (PBKDF2 with AES-CBC-Pad), where `opts` selects the `cipher` (`aes-256-cbc` default, `aes-192-cbc`, `aes-128-cbc`), the `prf` (`hmacWithSHA256` default, SHA-384/512, SHA-1), the `iterations` (default 600000), and the `salt`. The plaintext is validated as PKCS#8 before encryption, a default `prf` and `keyLength` are omitted so the parameters are byte-exact with OpenSSL, and the output is re-parsed before return. `decrypt(encrypted, password, opts)` recovers the inner `PrivateKeyInfo`, re-validated through `pki.schema.pkcs8.parse`: only PBES2 / PBKDF2 / AES-CBC is accepted (PBES1, PBMAC1, and scrypt are refused), the salt and iteration count are bounded before any derivation (`opts.maxIterations` lowers the cap), and a malformed parameter set or wrong-length IV is a distinct typed error. Because a MAC-less PBES2-CBC decrypt must not become a padding oracle (RFC 8018 §8), a wrong password and a valid-pad-but-not-a-key both surface the one uniform `key/decrypt-failed`. `export(key, opts)` and `import(input, opts)` move a private key as PKCS#8 or a public key as SubjectPublicKeyInfo. The key may come from the platform's WebCrypto or from a separately installed copy of this toolkit, and is exported through whichever holds its material; a non-extractable key, or one whose implementation keeps its material out of reach, is refused with that as the reason. Encoding is delegated to WebCrypto, so RSA carries an explicit NULL, EC a named curve, and Ed25519/Ed448/X25519/X448 omit parameters, and an ambiguous RSA or EC import requires `opts.algorithm`. `generate(algorithm, opts)` produces a key pair over RSA, ECDSA/ECDH, the Edwards and Montgomery curves, and the FIPS post-quantum ML-DSA and ML-KEM; `publicFromPrivate(privateKey)` derives the public key. Returns DER or PEM, with a typed `KeyError` on failure. Parsing stays at `pki.schema.pkcs8.parse` — `encrypt`, `decrypt`, `export`, `import`, `generate`, `publicFromPrivate` |
239
239
  | `pki.pkcs12` | PKCS#12 (.p12/.pfx) issuance and reading (RFC 7292 / RFC 9579). `build(spec, opts)` assembles a store from the OpenSSL-style `{ key, cert, ca?, friendlyName?, localKeyId? }` or the full `{ safeContents: [...] }`, where each element is a plaintext or PBES2-encrypted `SafeContents` of key, shroudedKey, cert, crl, secret, or nested `safeContents` bags. Keys and certs are validated before wrapping, and `friendlyName` (BMPString) and `localKeyId` are single-value. Integrity is a classic Appendix B HMAC (the default, for maximum interoperability) or an RFC 9579 PBMAC1 (`opts.mac.algorithm`) over SHA-256/384/512, with shrouded keys and cert safes encrypted under RFC 8018 PBES2 (AES-128/192/256-CBC). Every password is encoded the PKCS#12 way — BMPString+NULL for the classic MAC, UTF-8 for the PBES2 bags and PBMAC1 — which is what OpenSSL and NSS consume, so a file it emits opens in both, cross-checked bidirectionally. The MAC covers the exact AuthenticatedSafe byte range, a DEFAULT-1 `MacData.iterations` is rejected up front, and the store is re-parsed before return. `verifyMac(pfx, password, opts)` recomputes a classic or PBMAC1 MAC over `macedBytes` and constant-time-compares it, throwing on a MAC-less or public-key-integrity store. Public-key integrity (`opts.integrity.mode: "public-key"`) wraps the AuthenticatedSafe in a CMS SignedData instead of a MAC, signed by any `pki.cms.sign` signer and carrying no MacData (§4); privacy stays independent, so `password` still PBES2-encrypts the bags. Public-key privacy wraps a SafeContents as a CMS EnvelopedData (AES-CBC, `id-envelopedData`, never GCM) encrypted to recipient public keys through the `pki.cms.encrypt` recipient model, via per-safe `recipients` or the `opts.recipientCerts` convenience, restricted to certificate recipients (RSA-OAEP, ECDH, X25519, X448, ML-KEM) since a password or KEK recipient could not be reopened by `open`. All four integrity-by-privacy combinations are permitted (§3.1). `open(pfx, password, opts)` reads a store back: it verifies the MAC first, so a wrong password is the MAC verdict rather than a decrypt error, then PBES2-decrypts every privacy safe and shrouded key bag and returns `{ integrityMode, macVerified, signers, keys, certs, crls, secrets }` — keys as re-validated PKCS#8 DER, certs, CRLs and secrets as raw DER, all with `friendlyName` and `localKeyId`, nested safes recursively. A MAC-less store is refused unless `opts.allowUnauthenticated`. A public-key-integrity store is verified through its CMS SignedData signature first (`pkcs12/signature-invalid` on failure), with the signer surfaced in `signers` but never trust-chained, which remains the caller's `pki.path.validate` step. A legacy-PBE store's Appendix C 3DES and RC2 bags are decrypted, RC2 through an in-tree RFC 2268 cipher, so an `openssl pkcs12 -legacy` or NSS store opens; the legacy RC4 schemes are refused. An `id-envelopedData` safe is decrypted with `opts.recipientKey` after the integrity gate (`pkcs12/no-recipient-key` when absent), every recipient-side fault and every post-integrity decrypt failure collapsing to the uniform `pkcs12/decrypt-failed`, and `opts.keys: 'crypto'` imports each key to a `CryptoKey`. It reads what OpenSSL and NSS produce. Returns DER or a PEM `PKCS12`, with a typed `Pkcs12Error` on failure. Parsing stays at `pki.schema.pkcs12.parse` — `build`, `verifyMac`, `open` |
240
- | `pki.cms` | CMS signing, verification, encryption, and compression (RFC 5652). `sign(content, signers, opts)` produces a SignedData (§5), attached or detached, with one or many signers over RSA, RSASSA-PSS, ECDSA, EdDSA, the post-quantum ML-DSA-44/65/87 (RFC 9882) and SLH-DSA (all twelve FIPS 205 sets, RFC 9814), and composite ML-DSA pairing ML-DSA with a traditional RSA, ECDSA, or EdDSA key (accepted only when both components verify, draft-ietf-lamps-cms-composite-sigs). It builds the signed attributes (content-type, message-digest, signing-time) as canonical DER, signs the exact §5.4 preimage, and emits a DER `Buffer` or PEM. A signer may also be key-only — `{ key, spki, keyIdentifier }` with no certificate — which RFC 5272 §3.2 requires when a Full PKI Request is signed by the key of a certification request it carries: the signer identifier takes the subjectKeyIdentifier form carrying the identifier the request declares, the signature scheme resolves from the request's own public key, and no certificate is embedded. `verify(input, opts)` parses a SignedData over the strict `pki.schema.cms` codec, locates each SignerInfo's signer certificate by its issuerAndSerialNumber or subjectKeyIdentifier, and checks the signature over the exact §5.4 preimage: with signed attributes present it confirms the message-digest attribute equals the content digest and verifies over the DER re-encoding of the SignedAttributes (the on-wire `[0]` tag replaced by a universal SET OF), and otherwise directly over the content. It returns a per-signer verdict with the matched signer certificate; chaining that certificate to a trust anchor is the caller's `pki.path.validate` step. `countersign(cms, signers, opts)` adds a countersignature (§11.4) — a `SignerInfo` over the countersigned SignerInfo's signature value, any signer algorithm, nestable, with the primary bytes preserved so it still verifies — attached as the id-countersignature unsigned attribute; `verify` returns each countersignature's verdict under `signers[i].countersignatures` and every unsigned attribute — including an RFC 3161 timestamp token, attachable via `sign`'s `unsignedAttributes` — under `signers[i].unsignedAttrs`, surfaced unauthenticated. `encrypt(content, recipients, opts)` produces an EnvelopedData, AuthEnvelopedData (AES-GCM, the authenticated default), or EncryptedData, with recipients auto-dispatched off the certificate key to key transport (RSAES-OAEP; v1.5 is never emitted), key agreement (ephemeral-static ECDH over P-256/384/521 with the X9.63 KDF, and X25519/X448 with HKDF), symmetric key wrap, password (PBKDF2 with RFC 3211 PWRI-KEK), or the post-quantum ML-KEM KEMRecipientInfo (RFC 9629/9936), wrapping one fresh content key for every recipient. `decrypt(input, keyMaterial, opts)` recovers the content through the matching arm and returns it with an `authenticated` flag; every secret-dependent failure collapses to one uniform `cms/decrypt-failed` verdict (Bleichenbacher, EFAIL, and password-oracle freedom), and PKCS#1 v1.5 is decrypt-only under the RFC 3218 implicit-rejection countermeasure. Every key-establishment secret the toolkit allocates is wiped once used, on the failing path as well as the succeeding one: the KEM shared secret and its derived key-encryption key, the raw ECDH / X25519 / X448 agreement secret, a password-derived key-encryption key, and the content-encryption key itself, cleared once the message is complete since all recipients share it. Caller-supplied key material is never written to (best-effort; NIST SP 800-227 §4.2, RFC 9629 §7). `authenticate(content, recipients, opts)` produces an `id-ct-authData` (§9): cleartext content plus an HMAC-SHA-256/384/512 MAC, authenticated but not encrypted, with the fresh MAC key wrapped for every recipient through the same RecipientInfo model. The MAC covers the authenticated attributes (content-type and message-digest) re-tagged to the EXPLICIT SET OF (§9.2), or the content octets directly; `decrypt` recovers the MAC key, recomputes the MAC and independently the message-digest (§9.3), and releases the content only after both pass, with every secret-dependent failure collapsing to the uniform `cms/decrypt-failed`. `compress(content, opts)` and `decompress(input, opts)` produce and consume a CompressedData (RFC 3274; ZLIB, version 0, id-alg-zlibCompress); decompress bounds the uncompressed output at 16 MiB and stops before it is materialized, so a decompression bomb fails closed as `cms/decompress-too-large`. Compression is a size transform with no integrity or confidentiality (RFC 8551 §2.4.5). Fail-closed with typed `cms/*` errors — `sign`, `verify`, `countersign`, `encrypt`, `authenticate`, `decrypt`, `compress`, `decompress` |
241
- | `pki.smime` | S/MIME message assembly, verification, encryption, and compression over the CMS layer (RFC 8551). `sign(content, signers, opts)` wraps a MIME entity in either form: `multipart/signed`, where the content stays readable in any MUA and a detached CMS SignedData rides alongside as `application/pkcs7-signature` with a matching `micalg`, or `application/pkcs7-mime; smime-type=signed-data`, where the whole entity is a base64 CMS SignedData. The signed bytes are the entity's §3.1.1 canonical form with CRLF line endings, and `verify(message, opts)` unwraps both forms and recomputes over the same canonicalizer, so a transport that re-wraps line endings still verifies while a tampered part fails. `encrypt(content, recipients, opts)` envelopes a MIME entity as an opaque `application/pkcs7-mime` message and `decrypt(message, keyMaterial, opts)` opens one, as `smime-type=authEnveloped-data` (AES-GCM, confidentiality and integrity, the default) or `smime-type=enveloped-data` (AES-CBC, confidentiality only, so `decrypt` reports `authenticated: false`, the §3.3 no-integrity caveat). The `smime-type` is derived from the CMS body rather than the header, and decryption is fail-closed and oracle-free. The crypto is entirely `pki.cms.sign` / `verify` / `encrypt` / `decrypt`, so it is algorithm-agnostic: any RSA / RSASSA-PSS / ECDSA / EdDSA / ML-DSA / SLH-DSA signer and any RSA-OAEP / ECDH / X25519 / X448 / AES-KW / PBKDF2 / ML-KEM recipient carries through. As with `cms.verify`, `verify` returns the per-signer cryptographic verdict plus the recovered content, and chaining a signer to a trust anchor is the caller's `pki.path.validate` step. `compress(content, opts)` and `decompress(message, opts)` add the opaque `application/pkcs7-mime; smime-type=compressed-data; name=smime.p7z` frame (§3.6, RFC 3274), a size transform with no integrity or confidentiality (§2.4.5), bounded against a bomb; the recovered content, which may itself be signed or enveloped, is returned for the caller to re-verify. Header protection (RFC 9788): `sign` and `encrypt` take `opts.protectHeaders`, which inlines the caller's `opts.headers` on the Cryptographic Payload root (its Content-Type gaining `hp="clear"` when signed or `hp="cipher"` when encrypted) so the CMS signature or encryption covers them, defeating a transport that rewrites or reads Subject, From, and the rest. `verify` and `decrypt` surface the authenticated inner set as `protectedHeaders` plus `headerProtection { present, mode, fromMismatch, confidential, legacy }`, so a tampered outer header cannot alter it and `fromMismatch` flags an outer From that disagrees. Encryption applies a Header Confidentiality Policy: the default `hcp_baseline` obscures the outer Subject to `[...]` and removes Comments and Keywords, so the real values live only in the ciphertext, and `decrypt` recovers them. Every emitted header routes through a fail-closed injection guard that rejects a CR, LF, or NUL value and a non-ftext name, and a malformed or contradictory `hp` wrap fails closed as `smime/bad-header-protection` rather than silently downgrading. The CMS crypto is unchanged. Inbound legacy RFC 8551 header protection is recognized opt-in: `verify` and `decrypt` with `opts.legacyHeaderProtection` detect a legacy `message/rfc822`-wrapped payload by the RFC 9788 §4.10.1 four-condition identification and surface the inner headers under `headerProtection.legacy = { headers, mode, fromMismatch, confidential }`, where `headers` is an ordered `[{ name, value }]` array retaining legally repeated fields such as `Received`. Those never appear in `protectedHeaders` and never set `present: true`. Because a legacy message is structurally indistinguishable from an ordinary forwarded `message/rfc822`, this is an explicit heuristic (§4.10.2, "no strong end-to-end guarantees"): a caller keying trust off `present` or `protectedHeaders` is never misled, and only one that explicitly reads `headerProtection.legacy.headers` and cross-checks `legacy.fromMismatch` consumes it. It is off by default, and a nested crypto layer, an inner `hp=`, a non-`message/rfc822` payload, or a duplicate Content-Type reports `legacy: null`. Bidirectionally interoperable with `openssl smime` and `openssl cms`. Fail-closed with typed `smime/*` errors — `sign`, `verify`, `encrypt`, `decrypt`, `compress`, `decompress` |
240
+ | `pki.cms` | CMS signing, verification, encryption, and compression (RFC 5652). `sign(content, signers, opts)` produces a SignedData (§5), attached or detached, with one or many signers over RSA, RSASSA-PSS, ECDSA, EdDSA, the post-quantum ML-DSA-44/65/87 (RFC 9882) and SLH-DSA (all twelve FIPS 205 sets, RFC 9814), and composite ML-DSA pairing ML-DSA with a traditional RSA, ECDSA, or EdDSA key (accepted only when both components verify, draft-ietf-lamps-cms-composite-sigs). It builds the signed attributes (content-type, message-digest, signing-time) as canonical DER, signs the exact §5.4 preimage, and emits a DER `Buffer` or PEM. A signer may also be key-only — `{ key, spki, keyIdentifier }` with no certificate — which RFC 5272 §3.2 requires when a Full PKI Request is signed by the key of a certification request it carries: the signer identifier takes the subjectKeyIdentifier form carrying the identifier the request declares, the signature scheme resolves from the request's own public key, and no certificate is embedded. `verify(input, opts)` parses a SignedData over the strict `pki.schema.cms` codec, locates each SignerInfo's signer certificate by its issuerAndSerialNumber or subjectKeyIdentifier, and checks the signature over the exact §5.4 preimage: with signed attributes present it confirms the message-digest attribute equals the content digest and verifies over the DER re-encoding of the SignedAttributes (the on-wire `[0]` tag replaced by a universal SET OF), and otherwise directly over the content. It returns a per-signer verdict with the matched signer certificate. `valid` and `trusted` are separate claims and neither implies the other: a SignedData carries its own certificates, so `valid` says the signature is sound under one of them and nothing about who signed, while `trusted` says every signer chained to a root named in `opts.trustAnchors`, validated through the same RFC 5280 path engine `pki.path.validate` uses. Without anchors there is nothing to chain to and `trusted` is `false`; anchors that cannot be read throw, rather than reading as untrusted. An unrecognized option is refused rather than ignored. `countersign(cms, signers, opts)` adds a countersignature (§11.4) — a `SignerInfo` over the countersigned SignerInfo's signature value, any signer algorithm, nestable, with the primary bytes preserved so it still verifies — attached as the id-countersignature unsigned attribute; `verify` returns each countersignature's verdict under `signers[i].countersignatures` and every unsigned attribute — including an RFC 3161 timestamp token, attachable via `sign`'s `unsignedAttributes` — under `signers[i].unsignedAttrs`, surfaced unauthenticated. `encrypt(content, recipients, opts)` produces an EnvelopedData, AuthEnvelopedData (AES-GCM, the authenticated default), or EncryptedData, with recipients auto-dispatched off the certificate key to key transport (RSAES-OAEP; v1.5 is never emitted), key agreement (ephemeral-static ECDH over P-256/384/521 with the X9.63 KDF, and X25519/X448 with HKDF), symmetric key wrap, password (PBKDF2 with RFC 3211 PWRI-KEK), or the post-quantum ML-KEM KEMRecipientInfo (RFC 9629/9936), wrapping one fresh content key for every recipient. `decrypt(input, keyMaterial, opts)` recovers the content through the matching arm and returns it with an `authenticated` flag; every secret-dependent failure collapses to one uniform `cms/decrypt-failed` verdict (Bleichenbacher, EFAIL, and password-oracle freedom), and PKCS#1 v1.5 is decrypt-only under the RFC 3218 implicit-rejection countermeasure. Every key-establishment secret the toolkit allocates is wiped once used, on the failing path as well as the succeeding one: the KEM shared secret and its derived key-encryption key, the raw ECDH / X25519 / X448 agreement secret, a password-derived key-encryption key, and the content-encryption key itself, cleared once the message is complete since all recipients share it. Caller-supplied key material is never written to (best-effort; NIST SP 800-227 §4.2, RFC 9629 §7). `authenticate(content, recipients, opts)` produces an `id-ct-authData` (§9): cleartext content plus an HMAC-SHA-256/384/512 MAC, authenticated but not encrypted, with the fresh MAC key wrapped for every recipient through the same RecipientInfo model. The MAC covers the authenticated attributes (content-type and message-digest) re-tagged to the EXPLICIT SET OF (§9.2), or the content octets directly; `decrypt` recovers the MAC key, recomputes the MAC and independently the message-digest (§9.3), and releases the content only after both pass, with every secret-dependent failure collapsing to the uniform `cms/decrypt-failed`. `compress(content, opts)` and `decompress(input, opts)` produce and consume a CompressedData (RFC 3274; ZLIB, version 0, id-alg-zlibCompress); decompress bounds the uncompressed output at 16 MiB and stops before it is materialized, so a decompression bomb fails closed as `cms/decompress-too-large`. Compression is a size transform with no integrity or confidentiality (RFC 8551 §2.4.5). Fail-closed with typed `cms/*` errors — `sign`, `verify`, `countersign`, `encrypt`, `authenticate`, `decrypt`, `compress`, `decompress` |
241
+ | `pki.smime` | S/MIME message assembly, verification, encryption, and compression over the CMS layer (RFC 8551). `sign(content, signers, opts)` wraps a MIME entity in either form: `multipart/signed`, where the content stays readable in any MUA and a detached CMS SignedData rides alongside as `application/pkcs7-signature` with a matching `micalg`, or `application/pkcs7-mime; smime-type=signed-data`, where the whole entity is a base64 CMS SignedData. The signed bytes are the entity's §3.1.1 canonical form with CRLF line endings, and `verify(message, opts)` unwraps both forms and recomputes over the same canonicalizer, so a transport that re-wraps line endings still verifies while a tampered part fails. `encrypt(content, recipients, opts)` envelopes a MIME entity as an opaque `application/pkcs7-mime` message and `decrypt(message, keyMaterial, opts)` opens one, as `smime-type=authEnveloped-data` (AES-GCM, confidentiality and integrity, the default) or `smime-type=enveloped-data` (AES-CBC, confidentiality only, so `decrypt` reports `authenticated: false`, the §3.3 no-integrity caveat). The `smime-type` is derived from the CMS body rather than the header, and decryption is fail-closed and oracle-free. The crypto is entirely `pki.cms.sign` / `verify` / `encrypt` / `decrypt`, so it is algorithm-agnostic: any RSA / RSASSA-PSS / ECDSA / EdDSA / ML-DSA / SLH-DSA signer and any RSA-OAEP / ECDH / X25519 / X448 / AES-KW / PBKDF2 / ML-KEM recipient carries through. As with `cms.verify`, `verify` returns the per-signer verdict plus the recovered content, and `valid` and `trusted` are separate claims: `valid` says the signature is sound under a certificate the message carried, `trusted` says every signer chained to a root named in `opts.trustAnchors`. Anchoring here is validated for email at both ends of the chain — the signer certificate must carry `emailProtection` (RFC 8551 §4.4.4) and the anchor's own trust metadata must permit that purpose, since a root can be distrusted for email while remaining a good TLS root. Override either with `requiredEku` / `checkPurpose`. `compress(content, opts)` and `decompress(message, opts)` add the opaque `application/pkcs7-mime; smime-type=compressed-data; name=smime.p7z` frame (§3.6, RFC 3274), a size transform with no integrity or confidentiality (§2.4.5), bounded against a bomb; the recovered content, which may itself be signed or enveloped, is returned for the caller to re-verify. Header protection (RFC 9788): `sign` and `encrypt` take `opts.protectHeaders`, which inlines the caller's `opts.headers` on the Cryptographic Payload root (its Content-Type gaining `hp="clear"` when signed or `hp="cipher"` when encrypted) so the CMS signature or encryption covers them, defeating a transport that rewrites or reads Subject, From, and the rest. `verify` and `decrypt` surface the authenticated inner set as `protectedHeaders` plus `headerProtection { present, mode, fromMismatch, confidential, legacy }`, so a tampered outer header cannot alter it and `fromMismatch` flags an outer From that disagrees. Encryption applies a Header Confidentiality Policy: the default `hcp_baseline` obscures the outer Subject to `[...]` and removes Comments and Keywords, so the real values live only in the ciphertext, and `decrypt` recovers them. Every emitted header routes through a fail-closed injection guard that rejects a CR, LF, or NUL value and a non-ftext name, and a malformed or contradictory `hp` wrap fails closed as `smime/bad-header-protection` rather than silently downgrading. The CMS crypto is unchanged. Inbound legacy RFC 8551 header protection is recognized opt-in: `verify` and `decrypt` with `opts.legacyHeaderProtection` detect a legacy `message/rfc822`-wrapped payload by the RFC 9788 §4.10.1 four-condition identification and surface the inner headers under `headerProtection.legacy = { headers, mode, fromMismatch, confidential }`, where `headers` is an ordered `[{ name, value }]` array retaining legally repeated fields such as `Received`. Those never appear in `protectedHeaders` and never set `present: true`. Because a legacy message is structurally indistinguishable from an ordinary forwarded `message/rfc822`, this is an explicit heuristic (§4.10.2, "no strong end-to-end guarantees"): a caller keying trust off `present` or `protectedHeaders` is never misled, and only one that explicitly reads `headerProtection.legacy.headers` and cross-checks `legacy.fromMismatch` consumes it. It is off by default, and a nested crypto layer, an inner `hp=`, a non-`message/rfc822` payload, or a duplicate Content-Type reports `legacy: null`. Bidirectionally interoperable with `openssl smime` and `openssl cms`. Fail-closed with typed `smime/*` errors — `sign`, `verify`, `encrypt`, `decrypt`, `compress`, `decompress` |
242
242
  | `pki.tsp` | Time-Stamp Protocol (RFC 3161). `sign(messageImprint, tsa, opts)` produces a TimeStampToken: a CMS SignedData over `pki.cms.sign` whose content is a `TSTInfo` carrying the timestamped message imprint, the TSA policy, a serial number, and `genTime` with optional accuracy, nonce, and ordering, plus the §2.4.2 signing-certificate attribute binding the token to the TSA certificate (SHA-2 imprints, any `pki.cms.sign` TSA key). `request` and `parseRequest` build and parse the TimeStampReq a client sends (imprint, requested policy, nonce, certReq); `response` and `parseResponse` handle the TimeStampResp a TSA returns, either a granted status wrapping a token or a rejection with PKIStatus and failure info, with the §2.4.2 status-to-token coupling enforced in both directions. `verify(token, data, opts)` verifies a token fail-closed: the CMS signature over the exact signed bytes, the message imprint recomputed from the data, the TSTInfo content type, the ESSCertID(V2) binding to the TSA certificate, the §2.3 critical timeStamping-only extendedKeyUsage, the request nonce when used, and, with a trust anchor supplied, full certification-path validation of the TSA certificate at the token's `genTime`. It returns `{ valid, genTime, serialNumber, tstInfo, … }` — `sign`, `request`, `parseRequest`, `response`, `parseResponse`, `verify` |
243
243
  | `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. Transport-free — `buildRequest`, `sign`, `buildErrorResponse`, `verify` |
244
244
  | `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`. There is no baked-in 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`, `verifyLogListSignature`, `fetchLogList` |
@@ -246,7 +246,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
246
246
  | `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({ trustAnchor, checkPurpose })`. Offline, fail-closed, bounded — `parseCertdata`, `parseCcadbCsv`, `anchor` |
247
247
  | `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` |
248
248
  | `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` |
249
- | `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 — `pae`, `parseBundle`, `verifyBundle` |
249
+ | `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 — `pae`, `parseBundle`, `verifyBundle` |
250
250
  | `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` |
251
251
  | `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, 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. The challenge and origin remain the relying party's to compare, through `parseClientData`. 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. 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` |
252
252
  | `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 plus 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, SAN required and CN-in-SAN, dNSName syntax, serverAuth EKU, weak keys, 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`, `rules`, `profiles` |
package/index.js CHANGED
@@ -92,7 +92,11 @@ module.exports = {
92
92
  // certificate messages (zlib / brotli / zstd) and the RFC 8446 Certificate message
93
93
  // inside them, decoded to per-entry certificate DER. Structure only; no handshake.
94
94
  tls: tls,
95
- cms: cms,
95
+ // Curated, the way pki.cmp is: cms-verify also exports `setEngine`, the seam path-validate injects
96
+ // its path builder through. That is plumbing between two internal modules, not an operator verb,
97
+ // and exporting the module wholesale would put it on the public surface.
98
+ cms: { verify: cms.verify, sign: cms.sign, countersign: cms.countersign, encrypt: cms.encrypt,
99
+ authenticate: cms.authenticate, decrypt: cms.decrypt, compress: cms.compress, decompress: cms.decompress },
96
100
  smime: smime,
97
101
  // `cmc` interprets an RFC 5272 Full PKI Response into one terminal verdict;
98
102
  // `pki.schema.cmc` is the decoder underneath it.
package/lib/cms-verify.js CHANGED
@@ -44,6 +44,7 @@ var validator = require("./validator-all");
44
44
  var compositeSig = require("./composite-sig");
45
45
  var guard = require("./guard-all");
46
46
  var frameworkError = require("./framework-error");
47
+ var pkix = require("./schema-pkix");
47
48
 
48
49
  var CmsError = frameworkError.CmsError;
49
50
  function _err(code, message, cause) { return new CmsError(code, message, cause); }
@@ -570,7 +571,7 @@ function _verifyOneCountersig(vDer, targetSig, parsedCerts) {
570
571
 
571
572
  /**
572
573
  * @primitive pki.cms.verify
573
- * @signature pki.cms.verify(input, opts?) -> Promise<{ valid, signers }>
574
+ * @signature pki.cms.verify(input, opts?) -> Promise<{ valid, trusted, signers }>
574
575
  * @since 0.2.14
575
576
  * @status stable
576
577
  * @spec RFC 5652
@@ -580,10 +581,27 @@ function _verifyOneCountersig(vDer, targetSig, parsedCerts) {
580
581
  * @related pki.schema.cms.parse, pki.path.validate
581
582
  *
582
583
  * Verify a CMS SignedData signature (RFC 5652 sec. 5). `input` is a PEM string, a DER
583
- * `Buffer`, or a parsed `pki.schema.cms` object. Returns `{ valid, signers }` where each
584
- * `signers[i]` is `{ ok, sid, cert }` (`cert` the matched signer certificate DER) or carries
584
+ * `Buffer`, or a parsed `pki.schema.cms` object. Returns `{ valid, trusted, signers }` where each
585
+ * `signers[i]` is `{ ok, sid, cert, trusted }` (`cert` the matched signer certificate DER) or carries
585
586
  * a `code` on a structural failure; `valid` is true when there is at least one signer and
586
- * every signer verified. RSA (PKCS#1 v1.5 and RSASSA-PSS), ECDSA, EdDSA, and the post-quantum
587
+ * every signer verified.
588
+ *
589
+ * `valid` and `trusted` are DIFFERENT claims and neither implies the other. A SignedData carries
590
+ * its own certificates, so `valid` establishes that the message is internally consistent -- the
591
+ * signature is sound under a certificate the message or `opts.certs` supplied. Anyone can mint a
592
+ * certificate, sign with it, and embed it, so that says nothing about WHO signed. `trusted` says
593
+ * every signer chained to a root named in `opts.trustAnchors`, validated through the same RFC 5280
594
+ * path engine `pki.path.validate` uses. Without anchors there is nothing to chain to and `trusted`
595
+ * is `false` -- a definite answer, not a missing one. Anchors that cannot be read are a
596
+ * configuration fault and throw, rather than being absorbed into `trusted: false`, which would
597
+ * report a verdict about the message for a check that never ran.
598
+ *
599
+ * Trust is decided from the certificate the SignerInfo selected -- the one reported as
600
+ * `signers[i].cert` -- never from another certificate that happens to share its key. A
601
+ * `subjectKeyIdentifier` names a key, and several certificates can hold it with different
602
+ * validity windows, key usage and policies; deciding from a sibling would let an expired or
603
+ * wrong-purpose signer certificate be reported trusted because a different certificate chained.
604
+ * Supply the certificate you want used. RSA (PKCS#1 v1.5 and RSASSA-PSS), ECDSA, EdDSA, and the post-quantum
587
605
  * ML-DSA (ML-DSA-44/65/87, RFC 9882) and SLH-DSA (the twelve FIPS 205 sets, RFC 9814) -- pure mode,
588
606
  * empty context -- signatures are recognized, as is composite ML-DSA
589
607
  * (draft-ietf-lamps-cms-composite-sigs), which pairs ML-DSA with a traditional RSA / ECDSA / EdDSA
@@ -593,6 +611,16 @@ function _verifyOneCountersig(vDer, targetSig, parsedCerts) {
593
611
  * encapsulated eContent. Required for a detached signature.
594
612
  * @opts certs Extra signer certificates (an array of DER `Buffer`s) to match against, in
595
613
  * addition to the certificates embedded in the SignedData.
614
+ * @opts trustAnchors The roots the caller accepts (DER `Buffer`s or anchor tuples). Supplying
615
+ * them is what makes `trusted` answerable; the SignedData's own certificates are
616
+ * offered as intermediates, never as anchors.
617
+ * @opts time The instant to validate the signer's chain at (default now). Only read when
618
+ * `trustAnchors` is supplied.
619
+ * @opts requiredEku Key purposes the SIGNER certificate must carry, as OID names or dotted OIDs.
620
+ * @opts checkPurpose The purpose the ANCHOR's own trust metadata must permit -- a separate
621
+ * question from `requiredEku`, since a root distributed with NSS trust bits can be
622
+ * marked untrusted for one purpose and good for another. Those bits and
623
+ * `distrustAfter` are consulted only when this names a purpose.
596
624
  * @example
597
625
  * var pair = await pki.key.generate("Ed25519");
598
626
  * var key = await pki.key.export(pair.privateKey);
@@ -618,9 +646,18 @@ function _snapshotIfBytes(input, label) {
618
646
  return input;
619
647
  }
620
648
 
649
+ // Every option pki.cms.verify reads. Adding one here is the only way to make it accepted, so a
650
+ // capability cannot arrive with its option silently ignored at this boundary.
651
+ var _VERIFY_OPTS = { certs: 1, content: 1, trustAnchors: 1, time: 1, requiredEku: 1, checkPurpose: 1 };
652
+
621
653
  function verify(input, opts) {
622
654
  opts = opts || {};
623
655
  if (typeof opts !== "object" || Buffer.isBuffer(opts)) throw _err("cms/bad-input", "pki.cms.verify options must be an object");
656
+ // An unrecognized option is refused, not swallowed. This is what kept the missing trust seam
657
+ // silent: a caller writing `trustAnchors` before it existed -- or `trustAnchor` now -- got a
658
+ // verdict that looked anchored and was not. The guard rejects through a (code, message) FACTORY
659
+ // and tests membership with hasOwnProperty, so the permitted set is a lookup object.
660
+ guard.identifier.assertKnownKeys(opts, _VERIFY_OPTS, _err, "cms/bad-input", "pki.cms.verify has an unknown option ");
624
661
  // Handed MUTABLE BYTES, parse a private copy. This function decodes synchronously
625
662
  // and checks signatures in a later promise turn, so without the copy every range
626
663
  // the parse surfaced -- the signed content above all -- stays a view into the
@@ -657,6 +694,12 @@ function verify(input, opts) {
657
694
  (parsed.certificates || []).forEach(function (c) { _addCert(parsedCerts, c && c.bytes ? c.bytes : c); });
658
695
  (opts.certs || []).forEach(function (c) { _addCert(parsedCerts, c); });
659
696
  var eContentType = parsed.encapContentInfo.eContentType;
697
+ // The trust configuration is captured SYNCHRONOUSLY here, before any signature work yields. It is
698
+ // read a promise turn later, and everything in it stays caller-owned across that gap: the array
699
+ // can be re-pointed, an anchor's DER rewritten, the validation instant moved. Any of those would
700
+ // have the chain judged against a configuration the caller never asked for while the verdict
701
+ // reports the one they did. The same defence the input and the certificates already have.
702
+ var trustCfg = _snapshotTrust(opts);
660
703
  return Promise.all(parsed.signerInfos.map(function (si) {
661
704
  // res.valid reflects only the PRIMARY signerInfos; a countersignature / unsigned attribute is
662
705
  // outside the signature and NEVER flips the top-level verdict. Both are surfaced per signer.
@@ -667,7 +710,197 @@ function verify(input, opts) {
667
710
  return verdict;
668
711
  });
669
712
  });
670
- })).then(function (signers) { return { valid: signers.length > 0 && signers.every(function (s) { return s.ok === true; }), signers: signers }; });
713
+ })).then(function (signers) {
714
+ var res = { valid: signers.length > 0 && signers.every(function (s) { return s.ok === true; }), signers: signers };
715
+ return _applyTrust(res, parsedCerts, trustCfg).then(function () { return res; });
716
+ });
717
+ }
718
+
719
+ // The full path build + validate, injected by path-validate the way it injects into crl-verify and
720
+ // cmp-verify. It is a seam rather than a require because path-validate is the higher layer: taking
721
+ // the dependency the other way round would be a cycle, and re-implementing a weaker chain walk here
722
+ // is exactly how a second, divergent notion of trust gets into a toolkit.
723
+ var _engine = null;
724
+ function setEngine(engine) { _engine = engine; }
725
+
726
+ // `valid` and `trusted` are DIFFERENT claims and neither implies the other. `valid` says every
727
+ // signature is sound under a certificate the message or the caller supplied -- a message carries its
728
+ // own certificates, so that establishes internal consistency and nothing about who signed. `trusted`
729
+ // says the signer chained to a root the CALLER named. Without anchors there is no one to chain to,
730
+ // so it is false: not null, because "nobody anchored this" is a definite answer, and the same one a
731
+ // caller gets from pki.cmp.verify.
732
+ // A private copy of the trust configuration, taken at the entry point. The array is copied so it
733
+ // cannot be re-pointed or grown; each anchor's BYTES are copied because rewriting those in place is
734
+ // the same substitution one level down; the instant is copied because a Date is mutable. An anchor
735
+ // TUPLE is copied too, field by field: `{ name, publicKey, algorithm }` is an ordinary object whose
736
+ // key bytes and name can be rewritten just as readily as a DER buffer's, and copying only the DER
737
+ // form would leave the documented tuple form aliased across the same gap -- the window closed for
738
+ // one spelling of an anchor and left open for the other. A PEM string needs no copy: it cannot be
739
+ // rewritten. `requiredEku` is copied for the same reason as the array.
740
+ var _ANCHOR_CLONE_DEPTH = 8;
741
+ function _cloneAnchorValue(v, depth) {
742
+ if (Buffer.isBuffer(v) || ArrayBuffer.isView(v) || v instanceof ArrayBuffer) {
743
+ return _snapshotIfBytes(v, "opts.trustAnchors[]");
744
+ }
745
+ if (v === null || typeof v !== "object") return v;
746
+ // A Date BEFORE the generic object walk. It has no enumerable own properties, so copying it
747
+ // field by field yields `{}` -- an anchor's `distrustAfter: { emailProtection: <Date> }` would
748
+ // survive the snapshot as an empty object and be rejected as an invalid date, disabling the very
749
+ // policy it encodes. Copied by value, like the validation instant.
750
+ if (v instanceof Date) return new Date(v.getTime());
751
+ // A structure too deep to copy is REFUSED, not shared. Returning it by reference at the cap
752
+ // would leave part of the anchor caller-mutable across the chain walk while the rest was
753
+ // snapshotted -- a guarantee that holds for the shallow fields and quietly lapses for the deep
754
+ // ones, which is worse than not offering it. An anchor tuple is a handful of levels deep; a
755
+ // deeper one is a caller's mistake, and it fails closed at the entry point.
756
+ if (depth >= _ANCHOR_CLONE_DEPTH) {
757
+ throw _err("cms/bad-input", "an opts.trustAnchors entry nests deeper than " + _ANCHOR_CLONE_DEPTH +
758
+ " levels, so it cannot be copied before the chain is walked; pass the anchor as certificate DER or a { name, publicKey, algorithm } tuple");
759
+ }
760
+ if (Array.isArray(v)) return v.map(function (e) { return _cloneAnchorValue(e, depth + 1); });
761
+ var out = {}, k;
762
+ for (k in v) {
763
+ if (!Object.prototype.hasOwnProperty.call(v, k)) continue;
764
+ out[k] = _cloneAnchorValue(v[k], depth + 1);
765
+ }
766
+ return out;
767
+ }
768
+ function _snapshotTrust(opts) {
769
+ var raw = opts.trustAnchors;
770
+ var anchors = null;
771
+ if (raw != null) {
772
+ anchors = (Array.isArray(raw) ? raw : [raw]).map(function (a) {
773
+ return typeof a === "string" ? a : _cloneAnchorValue(a, 0);
774
+ });
775
+ }
776
+ return {
777
+ trustAnchors: anchors,
778
+ time: opts.time instanceof Date ? new Date(opts.time.getTime()) : opts.time,
779
+ requiredEku: Array.isArray(opts.requiredEku) ? opts.requiredEku.slice() : opts.requiredEku,
780
+ checkPurpose: opts.checkPurpose,
781
+ };
782
+ }
783
+
784
+ // A signer certificate whose keyUsage FORBIDS signing has not been permitted to sign, however well
785
+ // it chains. RFC 5280 sec. 4.2.1.3: when the extension is present it is binding, and a leaf
786
+ // asserting keyEncipherment alone must not verify a signature. Path validation checks the CA's
787
+ // keyCertSign, not the target's own usage, so the format that knows a signature was made asks the
788
+ // question -- the same format-local gate on top of path.validate that pki.cmp.verify applies.
789
+ // contentCommitment (nonRepudiation) counts: RFC 5652 signatures are made under either bit.
790
+ // The value is read through the ONE strict decoder, which enforces the X.690 sec. 11.2.2 minimal
791
+ // NamedBitList form, so a malformed keyUsage fails the gate rather than a hand-rolled bit test
792
+ // authorizing it.
793
+ // The shared RFC 5280 sec. 4.2.1 extension-value decoders, the same set pki.cmp.verify reads its
794
+ // signer keyUsage through -- one structurally-strict decoder rather than a second, weaker bit test.
795
+ var _CERT_EXT_DECODERS = pkix.certExtensionDecoders(pkix.makeNS("cms", CmsError, oid)).byOid;
796
+
797
+ function _keyUsagePermitsSigning(parsedCerts, der) {
798
+ var entry = parsedCerts.filter(function (c) { return c.der.equals(der); })[0];
799
+ if (!entry) return true; // not among the candidates: nothing to read
800
+ var kuOid = oid.byName("keyUsage");
801
+ var exts = entry.cert.extensions || [];
802
+ for (var i = 0; i < exts.length; i++) {
803
+ if (exts[i].oid !== kuOid) continue;
804
+ var ku;
805
+ try { ku = _CERT_EXT_DECODERS[kuOid](exts[i].value); }
806
+ catch (_e) { return false; } // unreadable usage is not permission
807
+ return ku.digitalSignature === true || ku.contentCommitment === true || ku.nonRepudiation === true;
808
+ }
809
+ return true; // absent: unconstrained (sec. 4.2.1.3)
810
+ }
811
+
812
+ // `cfg` is verify's SNAPSHOT, never the caller's options object -- the parameter is named for that
813
+ // so a future caller cannot hand it the live one without noticing.
814
+ function _applyTrust(res, parsedCerts, cfg) {
815
+ res.trusted = false;
816
+ // Every signer carries the field too, whether or not anchors were supplied. A verdict whose
817
+ // per-signer shape depends on which options were passed makes a caller iterating `signers` write
818
+ // a different loop for each case -- and the absent field reads as "unknown" where the answer is
819
+ // a definite "nothing anchored this".
820
+ res.signers.forEach(function (s) { s.trusted = false; });
821
+ if (cfg.trustAnchors == null) return Promise.resolve();
822
+ if (!_engine) {
823
+ throw _err("cms/bad-input", "opts.trustAnchors requires the path validator; load pki.path before verifying (require the toolkit through its index)");
824
+ }
825
+ // Every signer, not merely one: a multi-signer message's signers are independent claims, and
826
+ // reporting the whole as trusted because one of them anchored would let an unanchored signer ride
827
+ // out on another's chain -- the same rule the per-signer `ok` already follows for signatures.
828
+ var pool = parsedCerts.map(function (c) { return c.der; });
829
+ var at = cfg.time !== undefined ? cfg.time : new Date();
830
+ var anchors = cfg.trustAnchors;
831
+ // The configuration is checked HERE, once, before any signer is looked at. Leaving it to the
832
+ // chain walk would make a caller's mistake depend on the message: a SignedData whose every signer
833
+ // failed to verify never reaches a build call, so unusable anchors would be accepted in silence
834
+ // and reported as `trusted: false` -- the config fault dressed as a verdict, which is exactly the
835
+ // conflation this seam exists to remove. An empty anchor list is refused for the same reason a
836
+ // policy that constrains nothing is: it cannot make anything trusted, so asking for it is a
837
+ // mistake rather than a request.
838
+ if (!anchors.length) {
839
+ throw _err("cms/bad-input", "opts.trustAnchors is empty -- name at least one root, or omit it to state that the signer is not being anchored");
840
+ }
841
+ // The INSTANT and the key purposes, before the anchors -- the anchor check reads the NORMALIZED
842
+ // purpose the resolver returns, so it has to run first.
843
+ if (cfg.time !== undefined) guard.time.assertValid(cfg.time, _err, "cms/bad-input", "opts.time");
844
+ // The resolver's RETURN is what the walk goes on to use: it normalizes a dotted purpose OID to
845
+ // its registered name, which is the key an anchor's per-purpose metadata is stored under.
846
+ // Preflighting with the caller's raw spelling would look up `distrustAfter["1.3.6.1.5.5.7.3.4"]`
847
+ // where the walk reads `distrustAfter.emailProtection` -- the same value checked under two
848
+ // different keys, so the early check would pass on metadata the walk then rejects.
849
+ var purposes = _engine.resolvePurposeOpts({ requiredEku: cfg.requiredEku, checkPurpose: cfg.checkPurpose });
850
+ // The WHOLE anchor, not only its identity: `toAnchor` settles the name / key / algorithm tuple,
851
+ // and the constraint metadata beside it -- the per-purpose distrustAfter dates -- is validated
852
+ // through the same definition the walk uses. Checking one and not the other is how this rule has
853
+ // repeatedly come back: each new part of the configuration has to join the preflight, not wait
854
+ // to be caught where it is consumed.
855
+ anchors.forEach(function (a) {
856
+ _engine.toAnchor(a);
857
+ if (purposes.checkPurpose != null) _engine.assertAnchorConstraints(a, purposes.checkPurpose);
858
+ });
859
+ // The INSTANT is checked here for the same reason the anchors are, and in the same place: a
860
+ // Everything above is the preflight: the anchor list, the instant, the key purposes, and each
861
+ // anchor's own constraint metadata -- all judged before a single signer is looked at, and all
862
+ // through the definitions the walk itself uses. A new option joins it here; validating one only
863
+ // where it is consumed is what made a caller's mistake depend on the message.
864
+ return res.signers.reduce(function (p, s) {
865
+ return p.then(function () {
866
+ if (s.ok !== true || !s.cert) { s.trusted = false; return null; }
867
+ var buildOpts = { trustAnchors: anchors, intermediates: pool, validate: true, time: at };
868
+ // A key purpose, when the caller names one. "Trusted" is not a property of a chain alone: a
869
+ // certificate restricted to serverAuth chains perfectly well and is still the wrong key to
870
+ // have signed an email. The verb that KNOWS the purpose supplies it -- pki.smime.verify asks
871
+ // for emailProtection -- rather than this layer guessing one for every CMS use.
872
+ if (cfg.requiredEku != null) buildOpts.requiredEku = cfg.requiredEku;
873
+ // The ANCHOR's own trust metadata, which is a separate question from the leaf's EKU. A root
874
+ // distributed with NSS trust bits can be marked untrusted for email while remaining a
875
+ // perfectly good TLS root, and `pki.path` consults those bits -- and distrustAfter -- only
876
+ // when a purpose is named. Requiring the leaf's EKU without naming the purpose checks one
877
+ // end of the chain and not the other, so a root explicitly distrusted for the very purpose
878
+ // being asked about would still answer "trusted".
879
+ if (cfg.checkPurpose != null) buildOpts.checkPurpose = cfg.checkPurpose;
880
+ // THE certificate this SignerInfo selected, and only that one -- the same certificate the
881
+ // verdict reports as `cert`. When a subjectKeyIdentifier is used, several certificates can
882
+ // hold the signing key, and it is tempting to let any of them answer: the embedded one may
883
+ // be self-signed while the caller supplied a CA-issued twin. Doing so would decide trust
884
+ // from a certificate the message did not present, carrying a different validity window, key
885
+ // usage and policy set -- so an expired or wrong-purpose signer certificate could be
886
+ // reported trusted through its sibling. `trusted` describes the certificate named in the
887
+ // same verdict; a caller who wants a particular one used supplies that one.
888
+ // Permitted to have signed, as well as chained. Checked BEFORE the chain walk because a
889
+ // certificate that forbids signing cannot become trusted by chaining, so there is nothing to
890
+ // learn from walking it.
891
+ if (!_keyUsagePermitsSigning(parsedCerts, s.cert)) { s.trusted = false; return null; }
892
+ return _engine.build(s.cert, buildOpts)
893
+ .then(function (r) { s.trusted = !!(r && r.valid); }, function (e) {
894
+ // A CONFIG fault -- unusable anchors, an invalid time -- is the caller's mistake and must
895
+ // not be absorbed into "untrusted", which would read as a verdict about the message. A
896
+ // chain that simply does not reach an anchor is not that: it is the answer.
897
+ if (e && e.code && /bad-input|bad-anchor|bad-time/.test(String(e.code))) throw e;
898
+ s.trusted = false;
899
+ });
900
+ });
901
+ }, Promise.resolve()).then(function () {
902
+ res.trusted = res.valid && res.signers.length > 0 && res.signers.every(function (s) { return s.trusted === true; });
903
+ });
671
904
  }
672
905
  // Parse a candidate cert DER and index its SKI; a cert that will not parse is skipped (it
673
906
  // simply cannot be a signer match, and a malformed embedded cert must not fail the verify).
@@ -961,4 +1194,5 @@ var compress = cmsCompress.compress;
961
1194
  */
962
1195
  var decompress = cmsCompress.decompress;
963
1196
 
964
- module.exports = { verify: verify, sign: sign, countersign: countersign, encrypt: encrypt, authenticate: authenticate, decrypt: decrypt, compress: compress, decompress: decompress };
1197
+ // `setEngine` is @internal -- the path-validate injection seam, never part of pki.cms.
1198
+ module.exports = { verify: verify, sign: sign, countersign: countersign, encrypt: encrypt, authenticate: authenticate, decrypt: decrypt, compress: compress, decompress: decompress, setEngine: setEngine };
package/lib/jose.js CHANGED
@@ -327,7 +327,7 @@ function assertPublicJwk(jwk) {
327
327
 
328
328
  /**
329
329
  * @primitive pki.jose.verify
330
- * @signature pki.jose.verify(jws, opts) -> Promise<{ header, payload }>
330
+ * @signature pki.jose.verify(jws, opts) -> Promise<{ header, payload, keySource }>
331
331
  * @since 0.1.25
332
332
  * @status stable
333
333
  * @spec RFC 7515, RFC 7518, RFC 8555
@@ -337,10 +337,20 @@ function assertPublicJwk(jwk) {
337
337
  * `"acme-outer"`). Structural rules fail closed BEFORE any crypto: the
338
338
  * `signatures`/`header` members and a detached payload are rejected, the
339
339
  * protected header is validated against the profile (alg registry, nonce, url,
340
- * exactly-one-of jwk/kid, crit), the signature byte length is pinned per alg, and
341
- * the verification key is the header `jwk` (only where the profile permits it) or
342
- * `opts.key` (a JWK). Returns the decoded `{ header, payload }` (payload a raw
343
- * `Buffer`); a failed signature throws `jose/verify-failed`.
340
+ * exactly-one-of jwk/kid, crit), and the signature byte length is pinned per alg.
341
+ *
342
+ * `opts.key` names the key the message must be signed under, and it governs: where
343
+ * the profile also permits an embedded header `jwk`, the two must be the SAME key
344
+ * or the message is refused with `jose/key-mismatch`. They are compared as RFC 7638
345
+ * thumbprints, so member order and members outside the key itself cannot make equal
346
+ * keys look different. Without `opts.key` the embedded `jwk` is used where the
347
+ * profile permits one -- which verifies that the message is internally consistent,
348
+ * not that any particular signer produced it. `keySource` reports which of the two
349
+ * answered, so a signature checked against a caller-named key is distinguishable
350
+ * from one checked against the key the message brought with it.
351
+ *
352
+ * Returns `{ header, payload, keySource }` (payload a raw `Buffer`); a failed
353
+ * signature throws `jose/verify-failed`.
344
354
  *
345
355
  * @opts
346
356
  * profile: string // "acme-outer" | "eab-inner" | "keychange-inner"
@@ -365,8 +375,38 @@ async function verify(jws, opts) {
365
375
  var header = parseJson(b64uDecode(jws.protected));
366
376
  var profileName = opts.profile || "acme-outer";
367
377
  var checked = _checkHeader(header, profileName);
378
+ // A caller supplying opts.key is NAMING the key this message must be signed under. A profile
379
+ // that also permits an embedded jwk lets the message carry one, and preferring that would let
380
+ // the sender choose which key verifies it -- exactly the question opts.key was asked to settle.
381
+ // So when both are present they must be the SAME key, compared as RFC 7638 thumbprints: that
382
+ // canonicalizes member order and ignores members outside the key itself, so two spellings of
383
+ // one key agree and two different keys cannot.
384
+ // A SUPPLIED key that cannot be used is refused, never quietly treated as absent. Falling back
385
+ // to the embedded jwk there would drop the caller's intent to pin a signer at the moment it
386
+ // matters most -- a `key` that came back null from a lookup would verify against whatever the
387
+ // message carried, and the verdict would report the embedded key as though none was named.
388
+ // `undefined` alone means "not supplied", so spreading an options object stays safe.
389
+ if (opts.key !== undefined && (typeof opts.key !== "object" || opts.key === null || Array.isArray(opts.key))) {
390
+ throw E("jose/bad-key", "opts.key was supplied but is not a JWK object, so the key this message must be signed under cannot be established");
391
+ }
368
392
  var jwk = header.jwk || opts.key;
369
393
  if (!jwk) throw E("jose/bad-key", "a verification key is required (opts.key) when the profile does not embed a jwk");
394
+ var keySource = "embedded-jwk";
395
+ if (opts.key) {
396
+ keySource = "opts.key";
397
+ if (header.jwk) {
398
+ var embeddedTp, suppliedTp;
399
+ try { embeddedTp = await thumbprint(header.jwk); suppliedTp = await thumbprint(opts.key); }
400
+ catch (e) { throw E("jose/bad-key", "the embedded jwk and opts.key could not be compared as RFC 7638 thumbprints", e); }
401
+ if (embeddedTp !== suppliedTp) {
402
+ throw E("jose/key-mismatch",
403
+ "the JWS embeds a jwk that is not the key supplied as opts.key, so the message names a " +
404
+ "different signer than the caller expects (embedded thumbprint " + embeddedTp +
405
+ ", supplied " + suppliedTp + ")");
406
+ }
407
+ }
408
+ jwk = opts.key;
409
+ }
370
410
  _assertKeyType(checked.algRow, jwk);
371
411
  var sig = b64uDecode(jws.signature);
372
412
  var want = _expectedSigBytes(checked.algRow, jwk);
@@ -378,7 +418,10 @@ async function verify(jws, opts) {
378
418
  catch (e) { throw E("jose/bad-key", "the JWK could not be imported for verification", e); }
379
419
  var ok = await webcrypto.subtle.verify(_cryptoAlg(checked.algRow, jwk), key, sig, signingInput);
380
420
  if (!ok) throw E("jose/verify-failed", "the JWS signature did not verify");
381
- return { header: header, payload: b64uDecode(jws.payload) };
421
+ // WHICH key answered. A caller auditing the decision needs "checked against the key I named"
422
+ // to be distinguishable from "checked against the one the message brought with it"; the two
423
+ // are different claims and only the first says anything about who the signer is.
424
+ return { header: header, payload: b64uDecode(jws.payload), keySource: keySource };
382
425
  }
383
426
 
384
427
  // ---- flattened-JWS sign (RFC 7515 sec. 5.1) ------------------------------