@blamejs/pki 0.5.1 → 0.5.3
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 +50 -0
- package/README.md +3 -3
- package/index.js +5 -1
- package/lib/cms-verify.js +240 -6
- package/lib/constants.js +7 -4
- package/lib/guard-bytes.js +6 -2
- package/lib/guard-name.js +15 -0
- package/lib/jose.js +39 -0
- package/lib/path-validate.js +72 -36
- package/lib/sign-scheme.js +45 -6
- package/lib/smime.js +41 -6
- package/lib/tsp-sign.js +10 -0
- package/lib/validator-cose.js +55 -3
- package/lib/webauthn-mds.js +207 -23
- package/lib/webauthn.js +447 -140
- package/package.json +2 -2
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,56 @@ 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.3 — 2026-08-15
|
|
8
|
+
|
|
9
|
+
pki.webauthn checks the ceremony at registration, withholds a revoked model's anchors, and refuses a name comparison it cannot perform.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- pki.webauthn.verify accepts opts.clientDataJSON as an alternative to the clientDataHash argument -- exactly one of the two, neither inferred from the other's absence. Given the JSON it reads it: the ceremony TYPE is checked unconditionally, because which ceremony a response belongs to is fixed by the specification rather than chosen by a caller, and a login response replayed into a registration is what that check stops. The challenge, origin and top-level origin are checked against what you issued, and the verdict carries clientData, whose checked field says which comparisons ran. From the digest form clientData is null: nothing read it, and reporting anything else would claim a check that never happened. An expectation supplied without the JSON is refused rather than left silently uncompared.
|
|
14
|
+
- parseClientData, verify and verifyAssertion take expectedTopOrigin -- an origin, a list of them, or null to require an unframed ceremony, which a list cannot express. In a cross-origin ceremony origin is the framed document's and topOrigin names the page that framed it, so the framing policy is about the second: a value a relying party could read and nothing could compare. It is compared whole and case-sensitively, as origin already was, and reported in checked alongside it. Whether a ceremony was framed is stated by both crossOrigin and topOrigin and is only usable when they agree, so a response cannot answer the policy with the field it left out.
|
|
15
|
+
- A verified FIDO metadata result reports rollbackChecked and the previousNo it was compared against, as it already reported what the freshness rule found. The rollback rule runs only when a caller supplies the sequence number it holds, so a result that recorded nothing could not be told from one where the check was skipped -- and being able to show the catalogue never went backwards is the whole point of the rule.
|
|
16
|
+
- pki.jose.sigAlgs() lists the JWS signature algorithms this toolkit verifies, one row per alg with the JWK key type, curve, hash and PSS salt length it requires. Each call returns fresh rows; the registry that drives verification is never handed out, so nothing a caller does to the result can widen what a signature check accepts. MAC algorithms are absent by construction -- HS256 is not a signature algorithm, and listing it beside RS256 is how the key-confusion class starts.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Every byte argument in pki.webauthn accepts the same forms: a Buffer, any typed-array view, a DataView, or an ArrayBuffer. They differed per argument before, so verify took an attestation object as an ArrayBuffer while refusing a clientDataHash in the form crypto.subtle.digest returns -- the natural output of the API these verbs exist to serve. The same held for a metadata BLOB, which is retrieved over the network and so most naturally arrives as the ArrayBuffer a fetched body gives you, and for the certificates supplied as trust anchors, where identical DER is the identical certificate whichever container it was read into. Anything that is not bytes is refused by name rather than described by whichever parser reaches it first.
|
|
21
|
+
- opts.requireCtsProfileMatch is refused when the attestation cannot satisfy it. It is a demand about an android-safetynet device-integrity signal and was checked only inside that format's arm, so a relying party that demanded a CTS-matching device got a pass from a packed or none attestation that was never asked the question -- the same shape the TPM key policy beside it already guarded against. A mistyped requireCtsProfileMatch or verifySafetyNetJws is now a configuration fault whatever the format, rather than a truthy string that demands nothing.
|
|
22
|
+
- pki.webauthn.metadataAnchors takes a second, optional argument carrying the metadata result, the instant to judge at, and the attestation certificate presented.
|
|
23
|
+
- The documented signatures of pki.webauthn.verify, verifyAssertion and parseAuthenticatorData list every field their verdicts carry, and verify documents its options.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- The canonical RFC 5280 sec. 7.1 distinguished-name comparison refuses input it cannot compare, instead of answering that it matched. It takes the RDN sequence on both sides; handed a parsed Name object it compared two absent lengths, ran its loop zero times, and returned true for two unrelated names. One caller passed that shape -- the FIDO metadata anchor test, whose name half was therefore inert, leaving the public-key comparison to carry a decision RFC 5280 sec. 6.1.1 defines as name AND key. It passes the RDN sequence now, and every other call site in the toolkit already did.
|
|
28
|
+
- A metadata entry whose status reports disqualify the model registers no attestation anchors. The route an operator follows to anchor an attestation themselves -- metadataFor, then metadataAnchors, then pki.path.validate -- never consulted those reports, so a REVOKED authenticator's registered roots were handed back and the path validated against them. metadataAnchors now refuses with webauthn/metadata-status. An entry may only be judged against the catalogue it was read out of: a process holding two would otherwise be able to pair an entry from one with the other, and the second's status policy and freshness would decide about the first's reports -- a by-date reading handing back anchors the entry's own catalogue records as revoked. It reads the status reports exactly as the attestation path does when given the same three things -- the verified catalogue and its status policy, the instant to judge at, and the attestation certificate presented -- and applies the strictest reading of whichever you leave out: any disqualifying report denies, judged now, with no report treated as concerning some other certificate. Supplying all three, which is what the attestation path does, is what makes the two verdicts identical; supplying none can only refuse more.
|
|
29
|
+
- An attestation certificate's public key must be the same KIND of key as the credential public key, not only the same bytes. An X25519 key-agreement key and an Ed25519 signing key are both 32 raw bytes, so on the Edwards curves the material alone cannot separate them -- and for the apple format, which carries no attestation signature, this comparison is the entire binding. The certificate's declared key algorithm is now part of it for every key type; before, only the EC branch asked, as a curve check.
|
|
30
|
+
- An RSA credential key's public exponent must be minimally encoded, as its modulus already had to be. Padded, 00 01 reads as a two-byte exponent and skips the value check that refuses 1 -- the identity exponent, under which every signature verifies. A padded short modulus could clear the modulus floor the same way.
|
|
31
|
+
- An Edwards certificate key is validated on-curve and full-order before a metadata BLOB signature is verified under it. The identity point and the other low-order points are imported by the platform without complaint and verify a trivial signature over ANY message, so a leaf carrying one authenticates whatever payload it is shown -- and chaining to the pinned FIDO root does not help, because such a certificate is malformed rather than unissued. Every other Edwards key in the toolkit already passed this gate; the metadata reader takes the same one now, through the shared entry rather than a second copy of it.
|
|
32
|
+
- An id-RSASSA-PSS key's parameters restrict it even where they look empty, in both directions -- signing and verification read the restriction through one reader. RFC 4055 sec. 3.1 makes the PRESENCE of the parameters the line: absent, they restrict nothing; present, the certificate user must use the hash they identify. hashAlgorithm is [0] ... DEFAULT sha1Identifier, so a parameters SEQUENCE that omits it names SHA-1 rather than declining to name anything -- and reading that omission as no restriction let a key its own certificate confines to SHA-1 sign and verify under SHA-256 or SHA-512. Parameters that are present but unreadable are likewise a restriction that cannot be honored, which is not the same as no restriction, so they are refused rather than fallen through.
|
|
33
|
+
- The RSA credential-key floor is measured in bits, not bytes. A byte count is not a bit count: a minimally encoded 256-byte modulus whose leading byte is 01 is 2041 bits, seven short of the 2048-bit floor, and cleared a test written in bytes.
|
|
34
|
+
- A metadata BLOB signed with RSASSA-PSS, EdDSA or ML-DSA verifies. The reader carried its own six-row JWS algorithm table beside the toolkit's registry, and only the registry had been extended, so PS256 was accepted as an ACME signature and refused as a metadata signature. The table is derived from pki.jose's registry now and is total over it, so an algorithm the toolkit verifies cannot be one this reader rejects: an X.509 SubjectPublicKeyInfo carries an Edwards key (RFC 8410) and an ML-DSA key (RFC 9881) as readily as an EC one. EdDSA names a scheme without fixing a curve, so the certificate decides whether it is Ed25519 or Ed448. An x5c leaf carrying an id-RSASSA-PSS key verifies a PS256/384/512 BLOB, and the restriction that certificate places on the key is enforced in both directions: the key may not verify an RSASSA-PKCS1-v1_5 signature, and where the certificate pins a single hash, a signature under any other is refused.
|
|
35
|
+
- The android-safetynet service chain reaches its anchors through the same walk as every other certificate chain in the namespace, rather than a second copy of it. An x5c ending in a cross-signed form of the pinned root -- the ordinary shape during a CA rotation -- now chains, where the local copy left it in the path to fail against a root that never issued it.
|
|
36
|
+
|
|
37
|
+
## v0.5.2 — 2026-08-12
|
|
38
|
+
|
|
39
|
+
pki.cms.verify gains a trust seam: name the roots you accept, and the verdict says whether the signer chained to one.
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- 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.
|
|
44
|
+
- 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.
|
|
45
|
+
- 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.
|
|
46
|
+
- 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.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- 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.
|
|
51
|
+
- 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.
|
|
52
|
+
|
|
53
|
+
### Security
|
|
54
|
+
|
|
55
|
+
- 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.
|
|
56
|
+
|
|
7
57
|
## v0.5.1 — 2026-08-12
|
|
8
58
|
|
|
9
59
|
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.
|
package/README.md
CHANGED
|
@@ -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
|
|
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
|
|
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` |
|
|
@@ -248,7 +248,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
|
|
|
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
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
|
-
| `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.
|
|
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/384/512, EdDSA (Ed25519), and the RFC 9864 fully-specified identifiers ESP256/384/512, Ed25519, and Ed448 — validating the public-key point on its curve, rejecting the compressed EC point form, and enforcing a minimally encoded DER ECDSA signature. The verdict field is `attestationVerified`, and `signatureVerified` for an assertion, rather than a bare `verified`, because a sound statement is a different claim from an acceptable ceremony: an attestation naming another origin's RP ID with user presence clear is perfectly sound and must not be registered. Pass `expectedRpId`, `requireUserPresence`, `requireUserVerification`, or `allowedAlgorithms` and those are checked, with `bindingChecked` reporting which ran, so a check that passed can be told from one that never happened. Pass `opts.clientDataJSON` instead of the digest and the client data is read here too: the ceremony type is checked unconditionally, since a login response replayed into a registration is what that rule stops, and the challenge, origin, and top-level origin are checked against what the relying party issued, with `clientData.checked` reporting which comparisons ran. A registration verdict also carries the `credentialId`, `credentialPublicKey`, and initial `signCount` a later login needs. A credential key declaring COSE algorithm `-65535` (RSASSA-PKCS1-v1_5 with SHA-1) is refused unless `allowedAlgorithms` names it, since every signature that credential ever makes would use SHA-1. Anchoring the trust path has two routes: `opts.metadata` resolves the roots the authenticator's own model registered, and `opts.rootCertificates` pins roots directly, which is what anchors the formats FIDO MDS does not cover, Apple's authenticators and the Google hardware-attestation roots among them. `metadata` governs when both are given, and `anchoredTo` names every route that anchored the path, joined with `+` when more than one did: `"metadata"`, `"rootCertificates"`, and `"safetyNetRoots"` for the android-safetynet chain, which anchors through the roots that format requires whether or not either other route was asked for. It is `null` only when nothing anchored the path. `verifyMetadataBlob(blob, opts)` reads a FIDO Metadata Service (MDS v3) BLOB, the signed catalogue of registered authenticator models, verifying its JWS and chaining its signer to an operator-supplied FIDO root before the payload is parsed, with sequence-number rollback and `nextUpdate` freshness checks. Passing the result as `opts.metadata` to `verify` resolves the authenticator's registered attestation roots from its identifier and requires the trust path to fully validate to one of them, refusing an unlisted or revoked model. `metadataAnchors(entry, opts)` applies the same status gate for a caller anchoring the path themselves: a model the catalogue has disqualified registers no anchors. Both of the catalogue's key spaces are covered: an aaguid, and the attestation-certificate key identifiers a U2F authenticator is listed under instead. No FIDO root is bundled, there is no trust-on-first-use, and retrieving the BLOB is out of scope. Fail-closed with typed `webauthn/*` errors — `parseAttestationObject`, `verify`, `verifyMetadataBlob`, `metadataFor`, `metadataAnchors` |
|
|
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` |
|
|
253
253
|
| `pki.C` / `pki.constants` | Version-stable constants: the functional scale helpers `C.TIME.*` and `C.BYTES.*`, the codec `LIMITS`, and `version` |
|
|
254
254
|
| `pki.errors` | The `PkiError` taxonomy — `defineClass` plus `ConstantsError`, `Asn1Error`, `OidError`, `PemError`, `CertificateError`, `CrlError`, `CsrError`, `Pkcs8Error`, `CmsError`, `OcspError`, `TspError`, `AttrCertError`, `CrmfError`, `Pkcs12Error`, `CmpError`, `PathError`, `CtError`, `JoseError`, `AcmeError`, `WebauthnError`, and `LintError`, each carrying a stable `code` in `domain/reason` form |
|
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
|
-
|
|
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.
|
|
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) {
|
|
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
|
-
|
|
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/constants.js
CHANGED
|
@@ -308,10 +308,13 @@ var LIMITS = {
|
|
|
308
308
|
// would otherwise let unauthenticated input buy heap ahead of every authentication step.
|
|
309
309
|
MDS_BLOB_HEADER_MAX_BYTES: BYTES.kib(256),
|
|
310
310
|
// The JWS signature, likewise read before anything is authenticated. Every algorithm this reader
|
|
311
|
-
// supports has a tightly bounded signature: 64/96/132 bytes for ECDSA P-256/384/521, and
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
|
|
311
|
+
// supports has a tightly bounded signature: 64/96/132 bytes for ECDSA P-256/384/521, 64 and 114
|
|
312
|
+
// for Ed25519 and Ed448, the modulus size for RSA, and 2420 / 3309 / 4627 for ML-DSA-44/65/87
|
|
313
|
+
// (FIPS 204). The bound has to clear the LARGEST of those -- an accepted algorithm whose ordinary
|
|
314
|
+
// signature the ceiling refuses is a row that can never verify -- so 8 KiB, which covers ML-DSA-87
|
|
315
|
+
// and an RSA-16384 key with room to spare while still refusing a segment whose only purpose is to
|
|
316
|
+
// make the verifier allocate.
|
|
317
|
+
MDS_BLOB_SIG_MAX_BYTES: BYTES.kib(8),
|
|
315
318
|
MDS_MAX_ENTRIES: 4096,
|
|
316
319
|
// A model's certification history: bounded like the other repeated per-entry structures, because
|
|
317
320
|
// the status gate walks the whole array on every verification.
|
package/lib/guard-bytes.js
CHANGED
|
@@ -42,8 +42,12 @@ function view(input, ErrorClass, code, label) {
|
|
|
42
42
|
|
|
43
43
|
// source(input, ErrorClass, code, label) -> Buffer | throws ErrorClass
|
|
44
44
|
// Accepts the full W3C BufferSource (Buffer / TypedArray view / raw ArrayBuffer)
|
|
45
|
-
// -- the WebCrypto input contract.
|
|
46
|
-
//
|
|
45
|
+
// -- the WebCrypto input contract. Re-VIEWS in every case, sharing the caller's
|
|
46
|
+
// memory rather than taking a copy: `Buffer.from(arrayBuffer)` wraps the backing
|
|
47
|
+
// store, it does not duplicate it. That is what makes this safe to call before a
|
|
48
|
+
// size ceiling has been applied -- nothing is materialized -- and it is also why a
|
|
49
|
+
// caller that must hold the bytes across an await wants `snapshotSource`, which
|
|
50
|
+
// copies, rather than this. Throws on a detached backing store.
|
|
47
51
|
// @enforced-by guard-shape-reinlined (the re-view shape is declared on view above)
|
|
48
52
|
function source(input, ErrorClass, code, label) {
|
|
49
53
|
var isAb = input instanceof ArrayBuffer;
|
package/lib/guard-name.js
CHANGED
|
@@ -76,8 +76,22 @@ function _canonAttrValue(v, E, code, label) {
|
|
|
76
76
|
// composes; dnEqual composes it over the RDN sequence. Comparing an RDN by raw DER
|
|
77
77
|
// would treat two RFC 5280-equal names as different (or let a truncation name
|
|
78
78
|
// compare equal).
|
|
79
|
+
// Both comparands must be the RDN SEQUENCE, not the parsed Name object that carries one. This is
|
|
80
|
+
// not a style check: a parsed Name has no `length`, so `a.length !== b.length` compares undefined
|
|
81
|
+
// with undefined, the loop over `i < undefined` never runs, and the comparison returns TRUE for two
|
|
82
|
+
// unrelated names. An identity comparison that answers "equal" because it was handed the wrong
|
|
83
|
+
// shape is the worst possible failure of this guard -- it is the one place a DN identity is
|
|
84
|
+
// decided, and every caller reads a true from it as proof. So a comparison that CANNOT be
|
|
85
|
+
// performed refuses, and the caller learns at the call site rather than trusting a fabricated
|
|
86
|
+
// match. (A caller holding a parsed Name passes its `.rdns`.)
|
|
87
|
+
function _assertSequence(a, b, E, code, label, what) {
|
|
88
|
+
if (!Array.isArray(a) || !Array.isArray(b)) {
|
|
89
|
+
throw E(code, "cannot compare " + label + ": " + what + " comparison requires the RDN sequence on both sides (pass name.rdns, not the parsed Name)");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
79
92
|
// @enforced-by guard-shape-reinlined (shares the canonicalization shape declared on dnEqual)
|
|
80
93
|
function rdnEqual(a, b, E, code, label) {
|
|
94
|
+
_assertSequence(a, b, E, code, label, "an RDN");
|
|
81
95
|
if (a.length !== b.length) return false;
|
|
82
96
|
var used = [];
|
|
83
97
|
for (var i = 0; i < a.length; i++) {
|
|
@@ -101,6 +115,7 @@ function rdnEqual(a, b, E, code, label) {
|
|
|
101
115
|
// @enforced-by guard-shape-reinlined
|
|
102
116
|
// @guard-shape replace\(/\\s\+/g,
|
|
103
117
|
function dnEqual(rdnsA, rdnsB, E, code, label) {
|
|
118
|
+
_assertSequence(rdnsA, rdnsB, E, code, label, "a distinguished-name");
|
|
104
119
|
if (rdnsA.length !== rdnsB.length) return false;
|
|
105
120
|
for (var i = 0; i < rdnsA.length; i++) {
|
|
106
121
|
if (!rdnEqual(rdnsA[i], rdnsB[i], E, code, label)) return false;
|