@blamejs/pki 0.5.1 → 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 +20 -0
- package/README.md +2 -2
- package/index.js +5 -1
- package/lib/cms-verify.js +240 -6
- package/lib/path-validate.js +72 -36
- package/lib/smime.js +41 -6
- package/lib/tsp-sign.js +10 -0
- package/lib/validator-cose.js +16 -3
- package/lib/webauthn.js +84 -7
- package/package.json +2 -2
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ 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
|
+
|
|
7
27
|
## v0.5.1 — 2026-08-12
|
|
8
28
|
|
|
9
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.
|
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` |
|
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/path-validate.js
CHANGED
|
@@ -41,6 +41,7 @@ var ocsp = require("./schema-ocsp");
|
|
|
41
41
|
var ocspVerify = require("./ocsp-verify");
|
|
42
42
|
var crlVerify = require("./crl-verify");
|
|
43
43
|
var cmpVerify = require("./cmp-verify");
|
|
44
|
+
var cmsVerify = require("./cms-verify");
|
|
44
45
|
var cmpSession = require("./cmp-session");
|
|
45
46
|
var guard = require("./guard-all");
|
|
46
47
|
var constants = require("./constants");
|
|
@@ -1187,40 +1188,9 @@ async function validate(path, opts) {
|
|
|
1187
1188
|
// opts.requiredEku -- the key purposes the TARGET certificate must be good
|
|
1188
1189
|
// for, each a registered OID name or a dotted OID string. Resolved (and
|
|
1189
1190
|
// typo-checked) here at the entry point.
|
|
1190
|
-
var
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
throw E("path/bad-input", "validate: opts.requiredEku must be a non-empty array of key-purpose OID names or dotted OID strings");
|
|
1194
|
-
}
|
|
1195
|
-
requiredEku = opts.requiredEku.map(function (p) {
|
|
1196
|
-
if (typeof p !== "string" || p.length === 0) throw E("path/bad-input", "validate: opts.requiredEku entries must be non-empty strings");
|
|
1197
|
-
// A dotted-form attempt (leads with a digit) must be a canonical OID -- a
|
|
1198
|
-
// loose regex accepted a leading-zero / out-of-bounds key that would never
|
|
1199
|
-
// match the canonical EKU the target advertises; anything else is a name.
|
|
1200
|
-
if (/^[0-9]/.test(p)) return guard.identifier.assertCanonicalOid(p, E, "path/bad-input", "validate: opts.requiredEku entry " + JSON.stringify(p));
|
|
1201
|
-
var dotted = oid.byName(p);
|
|
1202
|
-
if (typeof dotted !== "string") throw E("path/bad-input", "validate: opts.requiredEku entry " + JSON.stringify(p) + " is not a registered OID name");
|
|
1203
|
-
return dotted;
|
|
1204
|
-
});
|
|
1205
|
-
}
|
|
1206
|
-
// opts.checkPurpose -- the single key purpose the ANCHOR's NSS trust metadata
|
|
1207
|
-
// (distrustAfter / purposes) is consulted for. Independent of requiredEku
|
|
1208
|
-
// (which gates the leaf's own EKU extension): this selects the per-purpose
|
|
1209
|
-
// key in the trust-anchor constraint contract. A purpose OID name (or a
|
|
1210
|
-
// canonical dotted OID normalized to its name); a bad value throws here.
|
|
1211
|
-
var checkPurpose = null;
|
|
1212
|
-
if (opts.checkPurpose !== undefined) {
|
|
1213
|
-
if (typeof opts.checkPurpose !== "string" || opts.checkPurpose.length === 0) {
|
|
1214
|
-
throw E("path/bad-input", "validate: opts.checkPurpose must be a key-purpose OID name or dotted OID string");
|
|
1215
|
-
}
|
|
1216
|
-
if (/^[0-9]/.test(opts.checkPurpose)) {
|
|
1217
|
-
var cpDotted = guard.identifier.assertCanonicalOid(opts.checkPurpose, E, "path/bad-input", "validate: opts.checkPurpose");
|
|
1218
|
-
checkPurpose = oid.name(cpDotted) || cpDotted; // normalize a dotted purpose OID to its name for the anchor map
|
|
1219
|
-
} else {
|
|
1220
|
-
if (typeof oid.byName(opts.checkPurpose) !== "string") throw E("path/bad-input", "validate: opts.checkPurpose " + JSON.stringify(opts.checkPurpose) + " is not a registered OID name");
|
|
1221
|
-
checkPurpose = opts.checkPurpose;
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1191
|
+
var purposeOpts = resolvePurposeOpts(opts);
|
|
1192
|
+
var requiredEku = purposeOpts.requiredEku;
|
|
1193
|
+
var checkPurpose = purposeOpts.checkPurpose;
|
|
1224
1194
|
|
|
1225
1195
|
var state = initialize(certs, opts, seeds);
|
|
1226
1196
|
state._n = n;
|
|
@@ -1385,9 +1355,8 @@ async function validate(path, opts) {
|
|
|
1385
1355
|
// NaN time) would make `notBefore > it` NaN-false and SILENTLY drop the distrust
|
|
1386
1356
|
// restriction -- the NaN-Date fail-open. Validate a present date fail-closed
|
|
1387
1357
|
// before the comparison; an absent (undefined/null) date is no restriction.
|
|
1388
|
-
var distrustDate = (
|
|
1358
|
+
var distrustDate = assertAnchorConstraints(ta, checkPurpose);
|
|
1389
1359
|
if (distrustDate != null) {
|
|
1390
|
-
distrustDate = guard.time.assertValid(distrustDate, E, "path/bad-input", "trustAnchor.distrustAfter." + checkPurpose);
|
|
1391
1360
|
// STRICTLY > : a leaf whose notBefore == the distrust date stays trusted
|
|
1392
1361
|
// (Mozilla certverifier isDistrustedCertificateChain: endEntityNotBefore
|
|
1393
1362
|
// <= distrustAfterTime -> not distrusted; the end-of-day ...235959Z
|
|
@@ -2211,6 +2180,16 @@ var ocspCore = ocspVerify.makeOcspVerify({
|
|
|
2211
2180
|
cmpVerify.setEngine({ verifyWithSpki: _verifyWithSpki, build: build, validate: validate });
|
|
2212
2181
|
// pki.cmp.session validates the ISSUED leaf certificate (its signature + chain) through the same engine.
|
|
2213
2182
|
cmpSession.setEngine({ build: build, validate: validate, toAnchor: toAnchor, coerceCert: coerceCert });
|
|
2183
|
+
// pki.cms.verify chains a SignedData's signer certificate to the anchors the CALLER named, so its
|
|
2184
|
+
// `trusted` is decided by this one path engine rather than a second, weaker walk of its own.
|
|
2185
|
+
// `toAnchor` so cms.verify can validate the caller's anchors ONCE at entry, before any signer is
|
|
2186
|
+
// walked -- otherwise a message whose signers all failed would never reach a build call and a
|
|
2187
|
+
// malformed anchor would pass unnoticed.
|
|
2188
|
+
// `resolvePurposeOpts` so cms.verify can reject a malformed requiredEku / checkPurpose at ITS entry
|
|
2189
|
+
// point, through the SAME definition the walk uses -- a message whose signers all failed never
|
|
2190
|
+
// reaches a build call, and a caller's configuration must not be judged by the message's quality.
|
|
2191
|
+
cmsVerify.setEngine({ build: build, validate: validate, toAnchor: toAnchor,
|
|
2192
|
+
resolvePurposeOpts: resolvePurposeOpts, assertAnchorConstraints: assertAnchorConstraints });
|
|
2214
2193
|
|
|
2215
2194
|
/**
|
|
2216
2195
|
* @primitive pki.path.ocspChecker
|
|
@@ -2440,6 +2419,63 @@ function coerceCert(input) {
|
|
|
2440
2419
|
// tuple. The algorithm is the SPKI KEY-algorithm OID (the sec. 6.1.4(f)
|
|
2441
2420
|
// parameter-inheritance value), mirroring trust.js _mkAnchor -- NOT the
|
|
2442
2421
|
// signature OID. The anchor is an input to validate, never one of the path certs.
|
|
2422
|
+
// The two key-purpose options, resolved and typo-checked. Extracted so a CALLER can validate them
|
|
2423
|
+
// at ITS entry point rather than only when a path is actually walked: a format verb that skips the
|
|
2424
|
+
// walk -- pki.cms.verify does when no signer verified -- would otherwise accept a malformed
|
|
2425
|
+
// purpose in silence, making configuration validity depend on the message. One definition, so the
|
|
2426
|
+
// answer cannot drift between the caller's early check and the walk's own.
|
|
2427
|
+
//
|
|
2428
|
+
// `requiredEku` gates the TARGET certificate's own EKU extension; `checkPurpose` selects which
|
|
2429
|
+
// per-purpose key the ANCHOR's NSS trust metadata (purposes / distrustAfter) is consulted under.
|
|
2430
|
+
// They are independent, and each is a registered OID name or a canonical dotted OID.
|
|
2431
|
+
// An anchor's CONSTRAINT metadata for one purpose, validated fail-closed and returned normalized.
|
|
2432
|
+
// A PRESENT-but-malformed distrustAfter (an Invalid Date: instanceof Date yet a NaN time) would
|
|
2433
|
+
// make `notBefore > it` NaN-false and SILENTLY drop the distrust restriction -- the NaN-Date
|
|
2434
|
+
// fail-open. Absent metadata is no restriction and returns null.
|
|
2435
|
+
//
|
|
2436
|
+
// Separate from resolvePurposeOpts because it validates the ANCHOR rather than the options, and
|
|
2437
|
+
// exposed for the same reason: a caller that may never reach the walk -- pki.cms.verify when no
|
|
2438
|
+
// signer verified -- has to be able to reject a malformed anchor at ITS entry point, through this
|
|
2439
|
+
// same definition, so configuration validity never depends on the message.
|
|
2440
|
+
function assertAnchorConstraints(ta, checkPurpose) {
|
|
2441
|
+
var d = (checkPurpose && ta && ta.distrustAfter) ? ta.distrustAfter[checkPurpose] : null;
|
|
2442
|
+
if (d == null) return null;
|
|
2443
|
+
return guard.time.assertValid(d, E, "path/bad-input", "trustAnchor.distrustAfter." + checkPurpose);
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
function resolvePurposeOpts(opts) {
|
|
2447
|
+
var requiredEku = null;
|
|
2448
|
+
if (opts.requiredEku !== undefined) {
|
|
2449
|
+
if (!Array.isArray(opts.requiredEku) || opts.requiredEku.length === 0) {
|
|
2450
|
+
throw E("path/bad-input", "validate: opts.requiredEku must be a non-empty array of key-purpose OID names or dotted OID strings");
|
|
2451
|
+
}
|
|
2452
|
+
requiredEku = opts.requiredEku.map(function (p) {
|
|
2453
|
+
if (typeof p !== "string" || p.length === 0) throw E("path/bad-input", "validate: opts.requiredEku entries must be non-empty strings");
|
|
2454
|
+
// A dotted-form attempt (leads with a digit) must be a canonical OID -- a
|
|
2455
|
+
// loose regex accepted a leading-zero / out-of-bounds key that would never
|
|
2456
|
+
// match the canonical EKU the target advertises; anything else is a name.
|
|
2457
|
+
if (/^[0-9]/.test(p)) return guard.identifier.assertCanonicalOid(p, E, "path/bad-input", "validate: opts.requiredEku entry " + JSON.stringify(p));
|
|
2458
|
+
var dotted = oid.byName(p);
|
|
2459
|
+
if (typeof dotted !== "string") throw E("path/bad-input", "validate: opts.requiredEku entry " + JSON.stringify(p) + " is not a registered OID name");
|
|
2460
|
+
return dotted;
|
|
2461
|
+
});
|
|
2462
|
+
}
|
|
2463
|
+
var checkPurpose = null;
|
|
2464
|
+
if (opts.checkPurpose !== undefined) {
|
|
2465
|
+
if (typeof opts.checkPurpose !== "string" || opts.checkPurpose.length === 0) {
|
|
2466
|
+
throw E("path/bad-input", "validate: opts.checkPurpose must be a key-purpose OID name or dotted OID string");
|
|
2467
|
+
}
|
|
2468
|
+
if (/^[0-9]/.test(opts.checkPurpose)) {
|
|
2469
|
+
var cpDotted = guard.identifier.assertCanonicalOid(opts.checkPurpose, E, "path/bad-input", "validate: opts.checkPurpose");
|
|
2470
|
+
checkPurpose = oid.name(cpDotted) || cpDotted; // normalize a dotted purpose OID to its name for the anchor map
|
|
2471
|
+
} else {
|
|
2472
|
+
if (typeof oid.byName(opts.checkPurpose) !== "string") throw E("path/bad-input", "validate: opts.checkPurpose " + JSON.stringify(opts.checkPurpose) + " is not a registered OID name");
|
|
2473
|
+
checkPurpose = opts.checkPurpose;
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
return { requiredEku: requiredEku, checkPurpose: checkPurpose };
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2443
2479
|
function toAnchor(entry) {
|
|
2444
2480
|
if (entry && typeof entry === "object" && !Buffer.isBuffer(entry) && entry.name && entry.publicKey && entry.algorithm) {
|
|
2445
2481
|
// A ready anchor tuple: validate the shape build + validate consume -- name.rdns
|
package/lib/smime.js
CHANGED
|
@@ -567,7 +567,7 @@ function _capped(msg) {
|
|
|
567
567
|
|
|
568
568
|
/**
|
|
569
569
|
* @primitive pki.smime.verify
|
|
570
|
-
* @signature pki.smime.verify(message, opts?) -> Promise<{ valid, signers, form, content, micalg, protectedHeaders, headerProtection }>
|
|
570
|
+
* @signature pki.smime.verify(message, opts?) -> Promise<{ valid, trusted, signers, form, content, micalg, protectedHeaders, headerProtection }>
|
|
571
571
|
* @since 0.2.25
|
|
572
572
|
* @status stable
|
|
573
573
|
* @spec RFC 8551, RFC 5652, RFC 9788
|
|
@@ -577,9 +577,19 @@ function _capped(msg) {
|
|
|
577
577
|
* `application/pkcs7-mime; smime-type=signed-data`. For `multipart/signed` the detached CMS signature
|
|
578
578
|
* is recomputed over the first part's RFC 8551 sec. 3.1.1 canonical form (the SAME canonicalizer the
|
|
579
579
|
* signer used); for `application/pkcs7-mime` the base64 body is the attached CMS SignedData. Returns
|
|
580
|
-
* `pki.cms.verify`'s `{ valid, signers }` verdict PLUS `form`, the recovered `content` (the
|
|
581
|
-
* entity bytes), and the `micalg`.
|
|
582
|
-
*
|
|
580
|
+
* `pki.cms.verify`'s `{ valid, trusted, signers }` verdict PLUS `form`, the recovered `content` (the
|
|
581
|
+
* signed MIME entity bytes), and the `micalg`.
|
|
582
|
+
*
|
|
583
|
+
* `valid` and `trusted` are separate claims, exactly as in `cms.verify`: a SignedData carries its own
|
|
584
|
+
* certificates, so `valid` says the signature is sound under one of them and nothing about who signed.
|
|
585
|
+
* Name the roots you accept in `opts.trustAnchors` and `trusted` says every signer chained to one --
|
|
586
|
+
* validated for EMAIL, at both ends of the chain. The signer certificate must carry the
|
|
587
|
+
* `emailProtection` key purpose (RFC 8551 sec. 4.4.4), because a certificate restricted to `serverAuth`
|
|
588
|
+
* chains to its root perfectly well and is still the wrong key to have signed a message; and the anchor's
|
|
589
|
+
* own trust metadata must permit that purpose, because a root distributed with NSS trust bits can be
|
|
590
|
+
* marked untrusted for email while remaining a good TLS root. Override either with `opts.requiredEku`
|
|
591
|
+
* and `opts.checkPurpose`. Supply no anchors and `trusted` is `false` -- there was nothing to chain to.
|
|
592
|
+
* A `micalg`
|
|
583
593
|
* that disagrees with the actual digest is advisory unless `opts.strictMicalg` (then `smime/micalg-mismatch`).
|
|
584
594
|
* If the message is header-protected (RFC 9788), `protectedHeaders` is the AUTHENTICATED inner header set (a
|
|
585
595
|
* tampered outer header cannot alter it) and `headerProtection` is `{ present, mode, fromMismatch, confidential, legacy }`
|
|
@@ -596,6 +606,11 @@ function _capped(msg) {
|
|
|
596
606
|
* `protectedHeaders` cannot mistake the opt-in heuristic for authenticated headers.
|
|
597
607
|
*
|
|
598
608
|
* @opts certs extra signer certificates (DER `Buffer`s) to match, forwarded to `cms.verify`.
|
|
609
|
+
* @opts trustAnchors the roots you accept, forwarded to `cms.verify`; supplying them is what makes
|
|
610
|
+
* `trusted` answerable. Certificate DER or anchor tuples.
|
|
611
|
+
* @opts time the instant the signer's chain is judged at (default now). Only read with `trustAnchors`.
|
|
612
|
+
* @opts requiredEku key purposes the SIGNER certificate must carry. Defaults to `["emailProtection"]`.
|
|
613
|
+
* @opts checkPurpose the purpose the ANCHOR's own trust metadata must permit. Defaults to `"emailProtection"`.
|
|
599
614
|
* @opts strictMicalg reject a `multipart/signed` whose `micalg` disagrees with the SignerInfo digest.
|
|
600
615
|
* @opts legacyHeaderProtection opt in to detecting a LEGACY RFC 8551 header-protected message (RFC 9788 sec. 4.10): a Cryptographic Payload that is a bare `message/rfc822` wrap with no `hp=` parameter. When set, a precisely-identified legacy message surfaces the inner message's headers under `headerProtection.legacy = { headers, mode, fromMismatch, confidential }` -- `headers` an ordered `[{ name, value }]` array (retaining legally-repeated fields such as `Received`), the mode inferred from the envelope (`clear` here) -- NOT under `protectedHeaders`, and `present` stays `false`. Consuming `headerProtection.legacy.headers` is an explicit choice: a legacy message is structurally indistinguishable from an ordinary forwarded `message/rfc822`, so this is a heuristic (RFC 9788 sec. 4.10.2: "not based on any strong end-to-end guarantees") -- cross-check `legacy.fromMismatch`. Anything not precisely identified (a nested crypto layer, an `hp=` on the inner message, a non-`message/rfc822` payload, a duplicate of a singleton field, or a duplicate Content-Type) reports `legacy: null`. Off by default. The signed-and-encrypted form (RFC 9788 Appendix C.3.17) is a documented gap (`legacy: null` at `decrypt`; surfaces as `clear` only via the caller's re-`verify` step) -- the non-recursive layered API exposes no single seam holding both the inner signature verdict and the outer header section.
|
|
601
616
|
* @example
|
|
@@ -611,8 +626,28 @@ async function verify(message, opts) {
|
|
|
611
626
|
opts = opts || {};
|
|
612
627
|
var ent = mime.parse(message, SmimeError, "smime/bad-mime");
|
|
613
628
|
var ct = ent.contentType;
|
|
629
|
+
// Forwarded, not re-decided here. This verb documents itself as pki.cms.verify's verdict plus the
|
|
630
|
+
// MIME surface, so the trust seam that verb offers has to reach it: building the options from
|
|
631
|
+
// scratch and passing only `certs` would leave a caller naming trust anchors with no way to have
|
|
632
|
+
// them applied, and a `trusted` that read false for want of ever being asked.
|
|
614
633
|
var vOpts = {};
|
|
615
634
|
if (opts.certs) vOpts.certs = opts.certs;
|
|
635
|
+
if (opts.trustAnchors != null) {
|
|
636
|
+
vOpts.trustAnchors = opts.trustAnchors;
|
|
637
|
+
// Trusted FOR THIS PURPOSE. A chain alone does not make a signer right for email: a
|
|
638
|
+
// certificate restricted to serverAuth chains to its root perfectly well and is still the
|
|
639
|
+
// wrong key to have signed a message. RFC 8551 sec. 4.4.4 names emailProtection as the purpose
|
|
640
|
+
// an S/MIME signer's certificate must carry, so this verb asks for it rather than accepting the
|
|
641
|
+
// purpose-neutral answer. A caller who means something else says so with `requiredEku`.
|
|
642
|
+
vOpts.requiredEku = opts.requiredEku != null ? opts.requiredEku : ["emailProtection"];
|
|
643
|
+
// Both ends of the chain. The EKU above constrains the LEAF; this selects the anchor's own
|
|
644
|
+
// trust metadata, which pki.path consults only when a purpose is named. A root distributed
|
|
645
|
+
// with NSS trust bits can be marked untrusted for email while remaining a good TLS root, so
|
|
646
|
+
// asking only the leaf would let a root explicitly distrusted for email still answer
|
|
647
|
+
// "trusted" for an email message.
|
|
648
|
+
vOpts.checkPurpose = opts.checkPurpose != null ? opts.checkPurpose : "emailProtection";
|
|
649
|
+
}
|
|
650
|
+
if (opts.time !== undefined) vOpts.time = opts.time;
|
|
616
651
|
if (_isPkcs7(ct.type, "mime")) {
|
|
617
652
|
if (ct.params["smime-type"] && ct.params["smime-type"] !== "signed-data") throw _err("smime/unsupported-type", "unsupported smime-type " + JSON.stringify(ct.params["smime-type"]) + " (only signed-data)");
|
|
618
653
|
var p7m = _decodeCms(ent);
|
|
@@ -620,7 +655,7 @@ async function verify(message, opts) {
|
|
|
620
655
|
var inner;
|
|
621
656
|
try { inner = _toBuf(schemaCms.parse(p7m).encapContentInfo.eContent); }
|
|
622
657
|
catch (e) { throw _err("smime/bad-mime", "the pkcs7-mime SignedData has no encapsulated content", e); }
|
|
623
|
-
return Object.assign({ valid: res.valid, signers: res.signers, form: "pkcs7-mime", content: inner, micalg: null }, _hpSurface(inner, ent, "clear", res.valid, opts.legacyHeaderProtection === true));
|
|
658
|
+
return Object.assign({ valid: res.valid, trusted: res.trusted, signers: res.signers, form: "pkcs7-mime", content: inner, micalg: null }, _hpSurface(inner, ent, "clear", res.valid, opts.legacyHeaderProtection === true));
|
|
624
659
|
}
|
|
625
660
|
if (ct.type === "multipart/signed") {
|
|
626
661
|
if (ct.params.protocol && !_isPkcs7(ct.params.protocol, "signature")) throw _err("smime/bad-multipart", "multipart/signed protocol must be application/pkcs7-signature");
|
|
@@ -647,7 +682,7 @@ async function verify(message, opts) {
|
|
|
647
682
|
if (opts.strictMicalg && micalg && _micalgSet(micalg) !== (_micalgOf(p7s) || "")) {
|
|
648
683
|
throw _err("smime/micalg-mismatch", "the multipart/signed micalg " + JSON.stringify(micalg) + " disagrees with the SignerInfo digests");
|
|
649
684
|
}
|
|
650
|
-
return Object.assign({ valid: res2.valid, signers: res2.signers, form: "multipart/signed", content: parts[0], micalg: micalg }, _hpSurface(parts[0], ent, "clear", res2.valid, opts.legacyHeaderProtection === true));
|
|
685
|
+
return Object.assign({ valid: res2.valid, trusted: res2.trusted, signers: res2.signers, form: "multipart/signed", content: parts[0], micalg: micalg }, _hpSurface(parts[0], ent, "clear", res2.valid, opts.legacyHeaderProtection === true));
|
|
651
686
|
}
|
|
652
687
|
throw _err("smime/unsupported-type", "not a signed S/MIME message (Content-Type " + JSON.stringify(ct.type) + ")");
|
|
653
688
|
}
|
package/lib/tsp-sign.js
CHANGED
|
@@ -604,9 +604,19 @@ function _buildTsaChains(leaf, pool) {
|
|
|
604
604
|
* res.valid; // boolean; pass opts.trustAnchor to also chain the TSA cert to a root
|
|
605
605
|
* res.genTime; // Date, read from the verified eContent
|
|
606
606
|
*/
|
|
607
|
+
// Every option pki.tsp.verify reads. Adding one here is the only way to make it accepted.
|
|
608
|
+
var _VERIFY_OPTS = { certs: 1, trustAnchor: 1, nonce: 1, reqPolicy: 1, revocationChecker: 1 };
|
|
609
|
+
|
|
607
610
|
async function verify(token, data, opts) {
|
|
608
611
|
opts = opts || {};
|
|
609
612
|
if (typeof opts !== "object" || Buffer.isBuffer(opts)) throw _err("tsp/bad-input", "pki.tsp.verify options must be an object");
|
|
613
|
+
// An unrecognized option is refused rather than ignored. This verb spells its anchor option
|
|
614
|
+
// SINGULAR -- `trustAnchor`, an anchor tuple -- while pki.cms.verify and pki.cmp.verify spell it
|
|
615
|
+
// `trustAnchors` and take certificate DER. A caller carrying the plural spelling here would
|
|
616
|
+
// otherwise get no anchoring and no error: the TSA certificate unchained, `valid: true`, and
|
|
617
|
+
// nothing to notice it by. Naming the difference at the boundary is the only place it is cheap.
|
|
618
|
+
guard.identifier.assertKnownKeys(opts, _VERIFY_OPTS, _err, "tsp/bad-input",
|
|
619
|
+
"pki.tsp.verify has an unknown option (note the anchor option here is `trustAnchor`, singular, an anchor tuple -- not the `trustAnchors` certificate list pki.cms.verify takes) ");
|
|
610
620
|
if (opts.certs != null && (!Array.isArray(opts.certs) || !opts.certs.every(function (c) { return Buffer.isBuffer(c) || c instanceof Uint8Array; }))) {
|
|
611
621
|
throw _err("tsp/bad-input", "pki.tsp.verify opts.certs must be an array of DER certificate Buffers"); // a bad element is a caller error, never silently dropped
|
|
612
622
|
}
|
package/lib/validator-cose.js
CHANGED
|
@@ -61,7 +61,12 @@ var ALG_PROFILE = {
|
|
|
61
61
|
"-7": { kty: 2, crv: 1 }, "-35": { kty: 2, crv: 2 }, "-36": { kty: 2, crv: 3 },
|
|
62
62
|
"-9": { kty: 2, crv: 1 }, "-51": { kty: 2, crv: 2 }, "-52": { kty: 2, crv: 3 },
|
|
63
63
|
"-8": { kty: 1, crv: 6 }, "-19": { kty: 1, crv: 6 }, "-53": { kty: 1, crv: 7 },
|
|
64
|
-
|
|
64
|
+
// RSASSA-PSS at all three strengths. PS256 alone left PS384/PS512 refused at PARSE time on a key
|
|
65
|
+
// that is perfectly well-formed -- the same bytes accepted under -37 -- so the refusal blamed the
|
|
66
|
+
// key rather than the algorithm, and a relying party migrating credential rows written by another
|
|
67
|
+
// implementation could not tell which of its stored keys this verifier would decline, or why.
|
|
68
|
+
"-257": { kty: 3 }, "-258": { kty: 3 }, "-259": { kty: 3 },
|
|
69
|
+
"-37": { kty: 3 }, "-38": { kty: 3 }, "-39": { kty: 3 }, "-65535": { kty: 3 },
|
|
65
70
|
};
|
|
66
71
|
|
|
67
72
|
// credentialKey(node, E, code) -> the decoded + validated credential public key
|
|
@@ -72,7 +77,10 @@ var ALG_PROFILE = {
|
|
|
72
77
|
// @enforced-by validator-shape-reinlined
|
|
73
78
|
// @validator-shape kty\s*===\s*2n
|
|
74
79
|
// @validator-shape EC2_CRV_LEN|ALG_PROFILE
|
|
75
|
-
|
|
80
|
+
// `unsupportedCode` is OPTIONAL and names the code raised when the key is well-formed but its
|
|
81
|
+
// algorithm is not one this verifier implements -- a different fact from a malformed key. Omit it
|
|
82
|
+
// and that case keeps raising `code`, so an existing caller sees no change.
|
|
83
|
+
function credentialKey(node, E, code, unsupportedCode) {
|
|
76
84
|
function bad(msg, cause) { return new E(code, msg, cause); }
|
|
77
85
|
if (!node || node.majorType !== 5) throw bad("a COSE_Key must be a CBOR map (RFC 9052 sec. 7)");
|
|
78
86
|
// Every parameter read maps a wrong-type cbor/* fault to the caller's domain -- a
|
|
@@ -111,7 +119,12 @@ function credentialKey(node, E, code) {
|
|
|
111
119
|
if (node.children.length !== expectedParams) throw bad("the COSE_Key carries parameters beyond the canonical set for its key type (WebAuthn sec. 6.5.1)");
|
|
112
120
|
// PROFILE: the declared alg must match the key type (and, for EC2, the curve).
|
|
113
121
|
var prof = ALG_PROFILE[String(key.alg)];
|
|
114
|
-
|
|
122
|
+
// An algorithm this verifier does not implement is NOT a malformed key. The key can be perfectly
|
|
123
|
+
// well-formed -- the same bytes may parse under a neighbouring algorithm id -- and a relying
|
|
124
|
+
// party migrating credential rows written elsewhere needs to tell "I cannot check this
|
|
125
|
+
// algorithm" from "these bytes are wrong", since only one of those is fixable by re-registering.
|
|
126
|
+
// Callers that do not distinguish the two pass one code and keep the previous behaviour.
|
|
127
|
+
if (!prof) throw new E(unsupportedCode || code, "unsupported credential key algorithm " + key.alg);
|
|
115
128
|
if (prof.kty !== key.kty) throw bad("credential key algorithm " + key.alg + " is inconsistent with key type " + key.kty);
|
|
116
129
|
if (prof.crv != null && prof.crv !== key.crv) throw bad("credential key algorithm " + key.alg + " requires a different curve");
|
|
117
130
|
// ON-CURVE: import the SPKI so OpenSSL validates the EC point on its curve. An off-curve
|
package/lib/webauthn.js
CHANGED
|
@@ -73,7 +73,7 @@ function _isInteger(node) { return !!node && !node.constructed && node.tagClass
|
|
|
73
73
|
// step rejects the attestation before the signature is evaluated. EdDSA (-8/-19/-53) is
|
|
74
74
|
// absent by design: a TPM 2.0 AIK never signs with EdDSA, so such an attestation is
|
|
75
75
|
// correctly refused.
|
|
76
|
-
var COSE_ALG_HASH = { "-7": "sha256", "-9": "sha256", "-257": "sha256", "-37": "sha256", "-35": "sha384", "-51": "sha384", "-258": "sha384", "-36": "sha512", "-52": "sha512", "-259": "sha512", "-65535": "sha1" };
|
|
76
|
+
var COSE_ALG_HASH = { "-7": "sha256", "-9": "sha256", "-257": "sha256", "-37": "sha256", "-35": "sha384", "-51": "sha384", "-258": "sha384", "-38": "sha384", "-36": "sha512", "-52": "sha512", "-259": "sha512", "-39": "sha512", "-65535": "sha1" };
|
|
77
77
|
function _coseAlgHash(alg, E) {
|
|
78
78
|
var h = COSE_ALG_HASH[String(alg)];
|
|
79
79
|
if (!h) throw E("webauthn/unsupported-algorithm", "no hash mapping for COSE algorithm " + alg);
|
|
@@ -141,7 +141,54 @@ function _parseAuthData(buf, E) {
|
|
|
141
141
|
// The complete COSE credential-key conformance rule set (kty/alg/crv/length/canonical/
|
|
142
142
|
// profile/on-curve) lives in validator-cose, composed here so every credential key
|
|
143
143
|
// routes through the one home -- never a per-format re-derivation of a partial subset.
|
|
144
|
-
function _decodeCoseKey(node) {
|
|
144
|
+
function _decodeCoseKey(node) {
|
|
145
|
+
return validator.cose.credentialKey(node, WebauthnError, "webauthn/bad-cose-key", "webauthn/unsupported-algorithm");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @primitive pki.webauthn.parseCoseKey
|
|
150
|
+
* @signature pki.webauthn.parseCoseKey(bytes) -> object
|
|
151
|
+
* @since 0.5.2
|
|
152
|
+
* @status stable
|
|
153
|
+
* @spec RFC 9052, W3C WebAuthn Level 3 sec. 6.5.1
|
|
154
|
+
* @related pki.webauthn.verify, pki.webauthn.verifyAssertion
|
|
155
|
+
*
|
|
156
|
+
* Decode a bare COSE_Key -- the credential public key a relying party stored at
|
|
157
|
+
* registration -- back into the object `verifyAssertion` takes. `pki.webauthn.verify`
|
|
158
|
+
* returns that object, but the durable form is bytes: the object carries `Buffer`
|
|
159
|
+
* values, so a JSON round trip through a datastore yields
|
|
160
|
+
* `{"type":"Buffer","data":[...]}` rather than the object that went in, and existing
|
|
161
|
+
* credential stores already hold COSE bytes whoever wrote them. Without this the only
|
|
162
|
+
* routes into the decoder were `parseAttestationObject` and `parseAuthenticatorData`,
|
|
163
|
+
* both of which parse a CONTAINING structure -- so recovering a stored key meant
|
|
164
|
+
* fabricating an authenticatorData that never existed.
|
|
165
|
+
*
|
|
166
|
+
* The same validation the attestation path applies: the key type, the algorithm, the
|
|
167
|
+
* curve, and the coordinates are checked, and anything that is not a credential COSE
|
|
168
|
+
* key is refused with `webauthn/bad-cose-key`. `verifyAssertion` accepts either form
|
|
169
|
+
* for `credentialPublicKey`, so calling this first is a convenience rather than a step.
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* // requires: `attestationObject` / `clientDataHash` -- what a browser returns from a
|
|
173
|
+
* // registration ceremony
|
|
174
|
+
* var reg = await pki.webauthn.verify(attestationObject, clientDataHash, {});
|
|
175
|
+
* var stored = reg.credentialPublicKeyBytes; // the form a credential row holds
|
|
176
|
+
* // ... at a login months later, read it back:
|
|
177
|
+
* var key = pki.webauthn.parseCoseKey(stored);
|
|
178
|
+
* key.alg; // -> -7 for ES256
|
|
179
|
+
* // verifyAssertion takes either form, so this parse is a convenience, not a step:
|
|
180
|
+
* // pass `stored` straight as its credentialPublicKey.
|
|
181
|
+
*/
|
|
182
|
+
function parseCoseKey(bytes) {
|
|
183
|
+
var buf = _snapshotBytes(bytes, "the COSE key");
|
|
184
|
+
if (!Buffer.isBuffer(buf)) {
|
|
185
|
+
throw _err("webauthn/bad-input", "parseCoseKey takes the stored COSE key bytes (a Buffer, TypedArray or ArrayBuffer)");
|
|
186
|
+
}
|
|
187
|
+
var node;
|
|
188
|
+
try { node = cbor.decode(buf); }
|
|
189
|
+
catch (e) { throw _err("webauthn/bad-cose-key", "the stored credential key is not decodable CBOR", e); }
|
|
190
|
+
return _decodeCoseKey(node);
|
|
191
|
+
}
|
|
145
192
|
|
|
146
193
|
// ---- signature verification bridge ------------------------------------------
|
|
147
194
|
|
|
@@ -165,7 +212,11 @@ var COSE_ALG = {
|
|
|
165
212
|
"-257": { imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, verify: { name: "RSASSA-PKCS1-v1_5" }, ecdsa: 0 },
|
|
166
213
|
"-258": { imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-384" }, verify: { name: "RSASSA-PKCS1-v1_5" }, ecdsa: 0 },
|
|
167
214
|
"-259": { imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }, verify: { name: "RSASSA-PKCS1-v1_5" }, ecdsa: 0 },
|
|
215
|
+
// RSASSA-PSS. The salt length is the hash length, the profile WebCrypto verifies and the one
|
|
216
|
+
// RFC 8230 sec. 2 fixes for the COSE PS* identifiers -- 32 / 48 / 64 bytes for SHA-256/384/512.
|
|
168
217
|
"-37": { imp: { name: "RSA-PSS", hash: "SHA-256" }, verify: { name: "RSA-PSS", saltLength: 32 }, ecdsa: 0 },
|
|
218
|
+
"-38": { imp: { name: "RSA-PSS", hash: "SHA-384" }, verify: { name: "RSA-PSS", saltLength: 48 }, ecdsa: 0 },
|
|
219
|
+
"-39": { imp: { name: "RSA-PSS", hash: "SHA-512" }, verify: { name: "RSA-PSS", saltLength: 64 }, ecdsa: 0 },
|
|
169
220
|
// RS1 (RSASSA-PKCS1-v1_5 / SHA-1): a legacy COSE algorithm real Windows Hello TPM
|
|
170
221
|
// authenticators emit in their attestation statement. VERIFY-only support -- the
|
|
171
222
|
// toolkit never signs with SHA-1; it must still evaluate the attestations that
|
|
@@ -1139,6 +1190,11 @@ function _result(fmt, attestationType, chain, att) {
|
|
|
1139
1190
|
aaguid: att.authData.aaguid,
|
|
1140
1191
|
credentialId: att.authData.credentialId,
|
|
1141
1192
|
credentialPublicKey: att.authData.credentialPublicKey,
|
|
1193
|
+
// The same key in the form that SURVIVES STORAGE. The decoded object carries Buffers, so a JSON
|
|
1194
|
+
// round trip through a datastore returns {"type":"Buffer","data":[...]} rather than what went
|
|
1195
|
+
// in; the COSE bytes are what a credential row actually holds. Returning only the object left
|
|
1196
|
+
// the caller re-parsing the attestation object to recover bytes this call had already isolated.
|
|
1197
|
+
credentialPublicKeyBytes: att.authData.credentialPublicKeyBytes,
|
|
1142
1198
|
signCount: att.authData.signCount,
|
|
1143
1199
|
flags: att.authData.flags,
|
|
1144
1200
|
};
|
|
@@ -1176,7 +1232,7 @@ function _checkAndroidKeyDescription(cert, clientDataHash) {
|
|
|
1176
1232
|
|
|
1177
1233
|
/**
|
|
1178
1234
|
* @primitive pki.webauthn.verify
|
|
1179
|
-
* @signature pki.webauthn.verify(attestationObject, clientDataHash, opts) -> Promise<{ attestationVerified, fmt, attestationType, trustPath, anchoredTo, anchoredElements, aaguid, credentialId, credentialPublicKey, signCount, flags, bindingChecked }>
|
|
1235
|
+
* @signature pki.webauthn.verify(attestationObject, clientDataHash, opts) -> Promise<{ attestationVerified, fmt, attestationType, trustPath, anchoredTo, anchoredElements, aaguid, credentialId, credentialPublicKey, credentialPublicKeyBytes, signCount, flags, bindingChecked }>
|
|
1180
1236
|
* @since 0.2.5
|
|
1181
1237
|
* @status stable
|
|
1182
1238
|
* @spec W3C WebAuthn Level 3 sec. 8 / sec. 7.1
|
|
@@ -1201,7 +1257,12 @@ function _checkAndroidKeyDescription(cert, clientDataHash) {
|
|
|
1201
1257
|
* them against the state only the relying party has.
|
|
1202
1258
|
*
|
|
1203
1259
|
* The verdict also carries what a relying party must STORE to run a later login:
|
|
1204
|
-
* `credentialId`, `credentialPublicKey` and the initial `signCount`.
|
|
1260
|
+
* `credentialId`, `credentialPublicKey` and the initial `signCount`. The credential key
|
|
1261
|
+
* comes back in both forms: the decoded object, and `credentialPublicKeyBytes`, which is
|
|
1262
|
+
* what a credential row should hold -- the object carries `Buffer` values, so a JSON round
|
|
1263
|
+
* trip through a datastore returns `{"type":"Buffer","data":[...]}` rather than the object
|
|
1264
|
+
* that went in. `pki.webauthn.parseCoseKey` reads those bytes back, and
|
|
1265
|
+
* `verifyAssertion` accepts either form.
|
|
1205
1266
|
*
|
|
1206
1267
|
* @intro This verifies the attestation STATEMENT -- the signature and the format's
|
|
1207
1268
|
* structural bindings (the x5c leaf key == credential key, the apple nonce, the tpm
|
|
@@ -1937,7 +1998,15 @@ function _snapshotAssertion(input) {
|
|
|
1937
1998
|
out[f] = guard.bytes.snapshotSource(out[f], WebauthnError, "webauthn/bad-input", f);
|
|
1938
1999
|
}
|
|
1939
2000
|
});
|
|
1940
|
-
|
|
2001
|
+
// The BYTES form first. A Buffer satisfies the plain-object test below, so leaving it to that
|
|
2002
|
+
// branch copies its numeric indices into a `{0:.., 1:..}` object that is no longer a key at all
|
|
2003
|
+
// -- the stored credential silently becoming something the SPKI builder cannot read. It is
|
|
2004
|
+
// snapshotted here for the same reason the other byte inputs are: it is read after a yield.
|
|
2005
|
+
if (Buffer.isBuffer(out.credentialPublicKey) || ArrayBuffer.isView(out.credentialPublicKey) ||
|
|
2006
|
+
out.credentialPublicKey instanceof ArrayBuffer) {
|
|
2007
|
+
out.credentialPublicKey = guard.bytes.snapshotSource(out.credentialPublicKey, WebauthnError,
|
|
2008
|
+
"webauthn/bad-input", "credentialPublicKey");
|
|
2009
|
+
} else if (_isPlainObject(out.credentialPublicKey)) {
|
|
1941
2010
|
var key = {}, kk;
|
|
1942
2011
|
for (kk in out.credentialPublicKey) {
|
|
1943
2012
|
if (!Object.prototype.hasOwnProperty.call(out.credentialPublicKey, kk)) continue;
|
|
@@ -2000,9 +2069,16 @@ function verifyAssertion(input) {
|
|
|
2000
2069
|
}
|
|
2001
2070
|
clientDataHash = Buffer.from(input.clientDataHash);
|
|
2002
2071
|
}
|
|
2072
|
+
// Either form a relying party can be holding. `verify` hands back the parsed object, but the
|
|
2073
|
+
// durable form is BYTES: the object carries Buffers, so a JSON round trip through a datastore
|
|
2074
|
+
// returns {"type":"Buffer","data":[...]} rather than what went in, and every existing credential
|
|
2075
|
+
// store already holds the COSE bytes. Accepting only the object made a caller fabricate an
|
|
2076
|
+
// authenticatorData that never existed just to reach their own key.
|
|
2003
2077
|
var coseKey = input.credentialPublicKey;
|
|
2004
|
-
if (
|
|
2005
|
-
|
|
2078
|
+
if (Buffer.isBuffer(coseKey) || ArrayBuffer.isView(coseKey) || coseKey instanceof ArrayBuffer) {
|
|
2079
|
+
coseKey = parseCoseKey(coseKey);
|
|
2080
|
+
} else if (!_isPlainObject(coseKey)) {
|
|
2081
|
+
throw _err("webauthn/bad-input", "credentialPublicKey must be the stored COSE key -- the object pki.webauthn.verify returned, or its COSE bytes");
|
|
2006
2082
|
}
|
|
2007
2083
|
var bindingChecked = _applyBindings(authData, coseKey, input);
|
|
2008
2084
|
// The counter's SHAPE is a config-time question and is answered here; whether it
|
|
@@ -2057,6 +2133,7 @@ module.exports = {
|
|
|
2057
2133
|
parseAttestationObject: parseAttestationObject,
|
|
2058
2134
|
parseAuthenticatorData: parseAuthenticatorData,
|
|
2059
2135
|
parseClientData: parseClientData,
|
|
2136
|
+
parseCoseKey: parseCoseKey,
|
|
2060
2137
|
verify: verify,
|
|
2061
2138
|
verifyAssertion: verifyAssertion,
|
|
2062
2139
|
verifyMetadataBlob: mds.verifyMetadataBlob,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blamejs/pki",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "blamejs contributors",
|
|
@@ -76,6 +76,6 @@
|
|
|
76
76
|
"devDependencies": {
|
|
77
77
|
"c8": "12.0.0",
|
|
78
78
|
"esbuild": "0.28.1",
|
|
79
|
-
"eslint": "10.
|
|
79
|
+
"eslint": "10.8.1"
|
|
80
80
|
}
|
|
81
81
|
}
|
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:36c2730b-a6b9-424c-9247-67c6cd2e2db1",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-08-
|
|
8
|
+
"timestamp": "2026-08-13T03:02:25.353Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/pki@0.5.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.5.2",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.5.
|
|
25
|
+
"version": "0.5.2",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/pki@0.5.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.5.2",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/pki@0.5.
|
|
57
|
+
"ref": "@blamejs/pki@0.5.2",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|