@blamejs/pki 0.7.40 → 0.7.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@ 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.7.42 — 2026-09-12
8
+
9
+ An S/MIME message is signed only under a certificate a receiving agent accepts a mail signature from.
10
+
11
+ ### Changed
12
+
13
+ - pki.smime.sign refuses, with smime/bad-signer-certificate, a signer certificate whose keyUsage extension asserts neither digitalSignature nor nonRepudiation (RFC 8550 sec. 4.4.2) or whose extendedKeyUsage extension carries neither emailProtection nor anyExtendedKeyUsage (sec. 4.4.4), naming the signer by position. A certificate that carries neither extension signs, as a reader presumes both key usage bits and any purpose for it. The rule is about the certificate the message carries: a key-only signer, which RFC 8550 sec. 3 permits where correspondents hold each other's certificates by other means, still signs, and the reader holds the certificate it supplies to the same rule. The signer descriptors are read once, so a certificate accessor answers the same certificate to this check and to the signing, and a sparse signer list is refused before it is traversed.
14
+
15
+ ### Fixed
16
+
17
+ - The emailProtection requirement pki.smime.verify applies to a trusted signer is cited to RFC 8550 sec. 4.4.4, the certificate-handling specification, in the README and the reference; it was cited to RFC 8551.
18
+
19
+ ## v0.7.41 — 2026-09-12
20
+
21
+ A delivered or archived private key is proven the private half of its certificate with the key itself.
22
+
23
+ ### Fixed
24
+
25
+ - pki.est.serverkeygen proves a cleartext server-generated private key is the private half of the returned certificate it binds it to (a signature, an agreement, or an encapsulation the key completes), and refuses with est/key-cert-mismatch a key whose stated public half matches the certificate while its private material cannot use it. The selection of the certificate by public-key match and the ambiguity refusal are unchanged, and a finite-field Diffie-Hellman key, whose private structure carries the exponent alone, is still bound by the value it derives, at any width the runtime reads.
26
+ - pki.crmf.build proves the key enclosed in an encryptedKey proof of possession is the private half of certTemplate.publicKey with the key itself, and refuses with crmf/bad-popo an RSA, EC, EdDSA, ML-DSA, ML-KEM or composite key that only states the template's public components; a key the toolkit cannot read stays crmf/bad-input. A finite-field Diffie-Hellman key, whose private structure carries the exponent alone, keeps the derived comparison at any width, so the archival path still accepts a key wider than any group the toolkit agrees over.
27
+
7
28
  ## v0.7.40 — 2026-09-12
8
29
 
9
30
  A PKCS#12 store links a certificate only to its own key.
package/README.md CHANGED
@@ -240,7 +240,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
240
240
  | `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` |
241
241
  | `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, a key paired with a certificate (the `{ key, cert }` form, or bags sharing a `localKeyId`) must be the private half of that certificate's public key, 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` |
242
242
  | `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. The content may also be an async iterable of byte chunks, which signs a large detached payload without holding it in memory: the payload is hashed incrementally under every signer's digest algorithm in one pass, and `verify` accepts the same streamed form as its `content` option. A signer may also be key-only — `{ key, spki, keyIdentifier }` with no certificate — which RFC 5272 §3.2 requires when a Full PKI Request's signer is the key of a certification request that request 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 (and composite ML-KEM, draft-lamps-pq-composite-kem) KEMRecipientInfo (RFC 9629/9936), wrapping one fresh content key for every recipient. The content may also be an async iterable of byte chunks, streamed through the AES-GCM or AES-CBC content cipher so a large plaintext is encrypted without being buffered whole; the ciphertext is assembled before the definite-length encryptedContent is emitted, byte-for-byte the buffered form. `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. With `opts.stream: true` the recovered `content` is an async iterable of plaintext chunks instead of a single Buffer, for a payload too large to hold whole; the authenticated modes (AuthEnvelopedData, AuthenticatedData, AES-GCM) verify integrity before the iterable yields, so a forged message throws before any plaintext is exposed, while a CBC EnvelopedData or EncryptedData yields the plaintext as the cipher produces it. 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). `certsOnly(certs, opts?)` builds a certs-only certificate-management message (RFC 8551 §3.8): a degenerate SignedData with an empty signerInfos and no eContent that conveys certificates and CRLs without signing anything, `parseCertsOnly(input, opts?)` reads one back to its raw certificate and CRL DER (a CRL-only message is valid here, unlike the cert-required RFC 5272 form that `pki.est.parseCertsOnly` reads), and `isCertsOnly(input)` recognizes one structurally. Fail-closed with typed `cms/*` errors — `sign`, `verify`, `countersign`, `encrypt`, `authenticate`, `decrypt`, `compress`, `decompress`, `certsOnly`, `parseCertsOnly`, `isCertsOnly` |
243
- | `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`. `buildCertsOnly(certs, opts?)` wraps a `pki.cms.certsOnly` certificate-management message in one `application/pkcs7-mime; smime-type=certs-only; name=smime.p7c` entity, read back by OpenSSL's `pkcs7 -print_certs`. Fail-closed with typed `smime/*` errors — `sign`, `verify`, `encrypt`, `decrypt`, `compress`, `decompress`, `buildCertsOnly` |
243
+ | `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 8550 §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. `sign` refuses a signer certificate a conforming reader would reject: a `keyUsage` extension without `digitalSignature` or `nonRepudiation` (RFC 8550 §4.4.2), or an `extendedKeyUsage` extension without `emailProtection` or `anyExtendedKeyUsage` (§4.4.4); a certificate carrying neither extension signs. 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`. `buildCertsOnly(certs, opts?)` wraps a `pki.cms.certsOnly` certificate-management message in one `application/pkcs7-mime; smime-type=certs-only; name=smime.p7c` entity, read back by OpenSSL's `pkcs7 -print_certs`. Fail-closed with typed `smime/*` errors — `sign`, `verify`, `encrypt`, `decrypt`, `compress`, `decompress`, `buildCertsOnly` |
244
244
  | `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). The TSA certificate is held to §2.3 before signing (one critical extendedKeyUsage naming timeStamping alone) with the codes `verify` answers, `genTime` keeps its milliseconds as the §2.4.2 fraction of a second, and an accuracy naming no field is refused. `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` — judged under the `timeStamping` purpose, so an anchor's own per-purpose trust metadata reaches the decision rather than sitting inert. It returns `{ valid, trusted, genTime, serialNumber, tstInfo, ... }`, where `trusted` is the separate claim that the authority chained to an anchor you named: without one there is nothing to chain to and it is `false`, which is the distinction a timestamp re-read years later turns on — `sign`, `request`, `parseRequest`, `response`, `parseResponse`, `verify` |
245
245
  | `pki.ocsp` | Online Certificate Status Protocol (RFC 6960), both the responder and relying-party surface. `buildRequest(query, opts)` builds an OCSPRequest for one or more `{ cert, issuer }` pairs, with the CertID hashed under SHA-1 by default per the RFC 5019 lightweight profile or under SHA-2, plus an optional RFC 9654 nonce (32 to 128 octets, the requester floor that section sets) and an optional requestor signature, under which the signer certificate's subject is the requestorName unless one is stated (RFC 6960 §4.1.2). `sign(responseData, responder, opts)` produces a signed BasicOCSPResponse over the exact `ResponseData` DER, from the issuing CA directly or a delegated responder, under any `pki.cms.sign` key including the post-quantum ML-DSA and SLH-DSA sets, with `good`, `revoked` (reason and time), or `unknown` per-certificate status, and per-response `singleExtensions` naming the §4.4.4 archive cutoff and the §4.4.2 CRL reference as fields, each read back by name by the parser. The signer refuses what its own lint would grade an error: a pre-encoded singleExtension that RFC 6960 places in requests or in responseExtensions, a CRL entry extension carried at the wrong criticality, and CRL references or entry extensions on a non-issued response, which itself places the §4.4.8 extended revoked definition in responseExtensions without being asked. `buildErrorResponse(status)` produces the unsigned §2.3 error (`tryLater`, `unauthorized`, and the rest). `verify(response, opts)` verifies a response fail-closed against the same hardened gates `pki.path.ocspChecker` runs: the CertID binding, responder authorization (the issuing CA, or a CA-issued delegate bearing id-kp-OCSPSigning and id-pkix-ocsp-nocheck and passing the full out-of-path certificate gates), the signature over `tbsResponseDataBytes`, currency against `thisUpdate` and `nextUpdate`, and the request-nonce echo. It returns `{ status: "good" / "revoked" / "unknown", ... }` and never silently accepts. `verifyRequest(request, opts)` is the responder-side inverse of `buildRequest`: it verifies a client's signed request (RFC 6960 §4.1.1) under the requestor certificate the request carries, trying each embedded certificate since the field is unordered, and reports `signed`, `signatureValid`, every certificate whose key verified the signature and whose subject is the `requestorName` the request states (`signerCerts`, so a key carried under an expired certificate beside its renewal is not hidden behind ordering, while a same-key certificate under another name is not the requestor) with the first one's decoded subject, the full embedded certificate bag (`certs`, including intermediates for path building), and the request's CertIDs; the name check is reported as `requestorNamed` (RFC 6960 §4.1.2) and `valid` requires it, a request no verifying certificate names falls back to listing the verifying ones with `valid: false`, and an unsigned request is reported with `signed: false` rather than refused. Transport-free — `buildRequest`, `sign`, `buildErrorResponse`, `verify`, `verifyRequest` |
246
246
  | `pki.ct` | Certificate Transparency (RFC 6962). `parseSctList` decodes the `SignedCertificateTimestampList` a certificate or OCSP response carries, a TLS-presentation-language payload inside the §3.3 double DER wrap, into per-SCT log id, exact `timestamp` (BigInt), named signature algorithm, and raw signature. `reconstructSignedData` rebuilds the exact `digitally-signed` preimage, and `verifySct` verifies an SCT signature against a log's public key, routing an ECDSA signature through the strict DER-conformance gate and verifying through the crypto engine, resolving true or false and throwing a typed error on a structural fault. On the producing side, `encodeSctList` builds the extension value byte for byte as the exact inverse of `parseSctList`, and `signSct` performs a log's signing step. For trust, `parseLogList` ingests the CT log-list JSON into constraint-carrying trusted logs, recomputing each log's id as SHA-256 of its key and refusing a disagreeing id (a swapped key, §3.2) and decoding the state and temporal-interval constraints; `verifySctWithLogList` resolves the log key from an SCT's log id, enforces the state (usable, qualified, and readonly trusted; retired only before retirement; pending and rejected refused) and the temporal-interval window, then delegates the signature check to `verifySct`. `verifyLogListSignature(json, signature, publicKey)` verifies the detached `log_list.sig` over the raw log-list bytes against a caller-pinned signer key (RSASSA-PKCS1-v1.5/SHA-256 and an EC P-256 arm, with forgeable-key defenses failing closed), cross-checked against `openssl dgst`. `fetchLogList(opts)` turns that chain into a live client: it GETs the `log_list.json` and its detached `log_list.sig` over `pki.transport`, verifies the detached signature over the raw fetched bytes against a caller-pinned distributor key before parsing, so an unverified document is never parsed, read, cached, or surfaced, then ingests the same bytes through `parseLogList` and returns the trusted-log set plus the surfaced `version` and `timestamp`. Above the per-SCT layer, `verifySctList` renders a certificate-level verdict over every SCT a certificate carries (§3.3), reporting how many distinct trusted logs verified an SCT (so a duplicated SCT cannot inflate the count) and from how many distinct trusted operators against a caller's CT policy (`minScts` and `minOperators`, both defaulting to the RFC floor of one), where a policy shortfall is a verdict and a per-SCT failure is a recorded row rather than a thrown call; `x509CertEntry` reconstructs the precertificate log entry from a final certificate by removing the SCT-list extension from its TBSCertificate as byte surgery, so its embedded SCTs verify end to end (§3.2). The toolkit bakes in no vendor URL or key, TLS trust is explicit with `rejectUnauthorized` always on, each response is size-capped before the trust chain, and the transport is injectable so the whole path is testable offline — `parseSctList`, `reconstructSignedData`, `verifySct`, `encodeSctList`, `signSct`, `parseLogList`, `verifySctWithLogList`, `verifySctList`, `x509CertEntry`, `verifyLogListSignature`, `fetchLogList` |
package/lib/crmf-sign.js CHANGED
@@ -836,7 +836,38 @@ function _buildEncryptedKeyPop(pop, outerTag, template) {
836
836
  }
837
837
  }
838
838
 
839
+ async function _assertEnclosedKeyCorresponds(privateKeyDer, templateSpki) {
840
+ var pair;
841
+ try {
842
+ pair = await key.correspondsTo(privateKeyDer, templateSpki);
843
+ } catch (e) {
844
+ if (e instanceof CrmfError) throw e;
845
+ /** @internal A key the key module cannot read at all is bad input; one it reads and cannot
846
+ * exercise together with the template's key proves possession of nothing, which is the proof
847
+ * failing, not the input. */
848
+ if (e && e.isPkiError === true && e.code === "key/bad-input") {
849
+ throw _err("crmf/bad-input", "pop.privateKey could not be read as a private key to check against " +
850
+ "certTemplate.publicKey: " + (e.message || e), e);
851
+ }
852
+ throw _err("crmf/bad-popo", "the private key enclosed in the encryptedKey proof of possession could not be " +
853
+ "exercised with certTemplate.publicKey, so it cannot be shown to be that key's private half (RFC 4211 sec. 4.2)", e);
854
+ }
855
+ if (pair !== true) {
856
+ throw _err("crmf/bad-popo", "the private key enclosed in the encryptedKey proof of possession is not the " +
857
+ "private half of certTemplate.publicKey, so it proves possession of a key this request does not ask to " +
858
+ "have certified (RFC 4211 sec. 4.2)");
859
+ }
860
+ }
861
+
839
862
  function _assertEnclosedKeyMatches(privateKeyDer, templateSpki) {
863
+ /** @internal Which proof the enclosed key takes depends on what its structure can carry. A
864
+ * finite-field PrivateKeyInfo carries the exponent alone, so the public value derived from it IS
865
+ * the proof, and this arm keeps that derivation at any width, with the cheap tests below. Every
866
+ * other family's private structure carries public components of its own (RFC 5958 sec. 2,
867
+ * RFC 5915, PKCS #1), which a caller could set to the template's beside private material that
868
+ * cannot use it, so those are proven by USING the key against the template. */
869
+ var enclosedAlg = key.algorithmOidAt(privateKeyDer, 1);
870
+ if (enclosedAlg !== null && !key.isDhOid(enclosedAlg)) return _assertEnclosedKeyCorresponds(privateKeyDer, templateSpki);
840
871
  return Promise.resolve()
841
872
  .then(function () { return key.publicFromPrivate(privateKeyDer); })
842
873
  .then(function (derivedSpki) {
@@ -991,7 +1022,9 @@ function _buildProofOfPossession(pop, certReqDer, template, signingKey, opts) {
991
1022
  * intended, so it takes `pop.archive: true`; `pop.privateKey` is the PKCS#8 DER, `pop.recipients` the CMS
992
1023
  * recipients to envelope it to, and `pop.identifier` (a string or GeneralName) is required, because
993
1024
  * sec. 4.2.1 makes it mandatory whenever the purpose is proving possession: without it a decrypting
994
- * agent cannot tell whose key it holds. `pop.contentEncryptionAlgorithm` defaults to `aes-256-cbc`; an
1025
+ * agent cannot tell whose key it holds. The enclosed key is proven the private half of
1026
+ * `certTemplate.publicKey` with the key itself (`crmf/bad-popo` when it is not), not by the public
1027
+ * components its structure states. `pop.contentEncryptionAlgorithm` defaults to `aes-256-cbc`; an
995
1028
  * AEAD algorithm is refused, since it would produce an `AuthEnvelopedData` this field cannot carry.
996
1029
  * - `agreeMAC`, which agrees a secret with the authority and MACs the request under a key derived from it,
997
1030
  * so nothing is signed (RFC 4211 sec. 4.3, RFC 2875 sec. 3). `pop.key` is the requester's finite-field
package/lib/est.js CHANGED
@@ -1742,6 +1742,18 @@ async function _serverkeygenResult(res, opts, derived) {
1742
1742
  var bound = findIssuedCert(out.certificates, spki);
1743
1743
  if (!bound) throw E("est/key-cert-mismatch", "the cleartext server-generated private key matches no returned certificate's public key (RFC 7030 sec. 4.4.2)");
1744
1744
  if (findIssuedCert(out.certificates.filter(function (c) { return c !== bound; }), spki)) throw E("est/ambiguous-issued-cert", "more than one returned certificate carries the server-generated key; the issued certificate is ambiguous (RFC 7030 sec. 4.4.2)");
1745
+ /** @internal The public half the key states selected the certificate; the pair is then proven
1746
+ * with the PRIVATE half, since a private structure states public components of its own that a
1747
+ * server could set to the certificate's beside private material that cannot use it. A
1748
+ * finite-field Diffie-Hellman PrivateKeyInfo carries the exponent alone, so the value derived
1749
+ * from it above IS the proof, at any width the runtime reads. */
1750
+ var keyAlg = key.algorithmOidAt(out.privateKeyDer, 1);
1751
+ if (keyAlg === null || !key.isDhOid(keyAlg)) {
1752
+ var pair;
1753
+ try { pair = await key.correspondsTo(out.privateKeyDer, spki); }
1754
+ catch (e2) { throw E("est/key-cert-mismatch", "the cleartext server-generated private key could not be exercised with the returned certificate's public key, so the two cannot be shown to be a pair (RFC 7030 sec. 4.4.2)", e2); }
1755
+ if (pair !== true) throw E("est/key-cert-mismatch", "the cleartext server-generated private key is not the private half of the returned certificate's public key (RFC 7030 sec. 4.4.2)");
1756
+ }
1745
1757
  delete out.privateKeyDer;
1746
1758
  }
1747
1759
  return out;
@@ -1765,7 +1777,9 @@ async function _serverkeygenResult(res, opts, derived) {
1765
1777
  * the key so the issued certificate's public key is the generated one, not the throwaway CSR key. A cleartext
1766
1778
  * key is bound to its certificate before it resolves: the delivered private key's public half MUST match
1767
1779
  * exactly one returned certificate (`est/key-cert-mismatch` on none, `est/ambiguous-issued-cert` on more than
1768
- * one), so a mis-associated key is refused, never handed back unusable.
1780
+ * one), and the key is then proven the private half of that certificate's public key with the key itself
1781
+ * (`est/key-cert-mismatch` when it is not; a finite-field DH key, whose structure carries the exponent alone,
1782
+ * is proven by the value it derives), so a mis-associated or unusable key is refused, never handed back.
1769
1783
  * The encryption requirement + expected recipient are derived from the CSR's own DecryptKeyIdentifier /
1770
1784
  * AsymmetricDecryptKeyIdentifier attribute; an `opts` value that contradicts the CSR is `est/bad-input`
1771
1785
  * (a cleartext-key downgrade cannot slip past). The delivered key's channel is asserted confidentiality-
package/lib/key.js CHANGED
@@ -680,7 +680,22 @@ function _dhAsPkcs3(der, at, what) {
680
680
  if (!algNode.children || algNode.children.length !== 2) {
681
681
  throw _err("key/bad-input", what + " X9.42 AlgorithmIdentifier does not carry DomainParameters");
682
682
  }
683
- var dom = x942Domain(algNode.children[1], KeyError, "key/bad-input", what);
683
+ /** @internal A field the codec refuses to read as the type the structure states is a malformed
684
+ * key, reported as one rather than as the codec's own complaint. */
685
+ var dom, y = null;
686
+ try {
687
+ dom = x942Domain(algNode.children[1], KeyError, "key/bad-input", what);
688
+ if (at === 0) {
689
+ var pub = asn1.read.bitString(node.children[1]);
690
+ if (!guard.crypto.isOctetAligned(pub)) {
691
+ throw _err("key/bad-input", what + " Diffie-Hellman public key BIT STRING must be octet-aligned (0 unused bits)");
692
+ }
693
+ y = asn1.read.integer(asn1.decode(pub.bytes));
694
+ }
695
+ } catch (e) {
696
+ if (e instanceof KeyError) throw e;
697
+ throw _err("key/bad-input", what + " X9.42 Diffie-Hellman key could not be read", e);
698
+ }
684
699
  assertDhOperandsWithin([dom.p, dom.g, dom.q, dom.j], KeyError, "key/bad-input", what);
685
700
  assertDhDomainCoherent(dom.p, dom.g, dom.q, dom.j, KeyError, "key/bad-input", what);
686
701
  assertDhGeneratorOrder(dom.p, dom.g, dom.q, KeyError, "key/bad-input", what);
@@ -689,11 +704,6 @@ function _dhAsPkcs3(der, at, what) {
689
704
  /** @internal The one field the X9.42 form states that the PKCS #3 form cannot be held to is the
690
705
  * subgroup, so a public value is held to it here, where q is still known. */
691
706
  if (at === 0) {
692
- var pub = asn1.read.bitString(node.children[1]);
693
- if (!guard.crypto.isOctetAligned(pub)) {
694
- throw _err("key/bad-input", what + " Diffie-Hellman public key BIT STRING must be octet-aligned (0 unused bits)");
695
- }
696
- var y = asn1.read.integer(asn1.decode(pub.bytes));
697
707
  if (y <= 1n || y >= dom.p - 1n) {
698
708
  throw _err("key/bad-input", what + " Diffie-Hellman public value is outside the group (RFC 2875 sec. 3)");
699
709
  }
@@ -711,19 +721,45 @@ function _dhAsPkcs3(der, at, what) {
711
721
  * algorithm; every secret the probe makes is wiped on every way out. A public key of another
712
722
  * algorithm, composite or not, is another family and not a pair. */
713
723
  async function _compositeCorrespondsTo(privDer, spkiDer, privateOid) {
724
+ /** @internal The private half is read first, as on the classical route: a composite key the KEM
725
+ * cannot read is bad input whatever the public half names, decided before the family shortcut
726
+ * answers that the two are not a pair. The derivation's result is not what is compared; the
727
+ * read is what it answers for. */
728
+ try { await compositeKem.publicFromPrivate(privDer); }
729
+ catch (e0) {
730
+ if (_isKemStructureFault(e0)) throw _err("key/bad-input", "the private key could not be read", e0);
731
+ throw _err("key/unsupported-algorithm", "the key pair could not be exercised to decide whether its two halves correspond", e0);
732
+ }
714
733
  if (_algorithmOidAt(spkiDer, 0) !== privateOid) return false;
715
734
  var sealed = null, opened = null;
716
735
  try {
717
- sealed = await compositeKem.encapsulate(spkiDer);
718
- opened = await compositeKem.decapsulate(privDer, sealed.ciphertext);
736
+ /** @internal A structure the KEM cannot read is bad input, the verdict an unreadable classical
737
+ * key gets from the runtime import above; a decapsulation that runs and fails is the pair not
738
+ * deciding. The KEM tells the two apart by its own codes, and so does this. */
739
+ try { sealed = await compositeKem.encapsulate(spkiDer); }
740
+ catch (e) {
741
+ if (_isKemStructureFault(e)) throw _err("key/bad-input", "the public key could not be read", e);
742
+ throw _err("key/unsupported-algorithm", "the key pair could not be exercised to decide whether its two halves correspond", e);
743
+ }
744
+ try { opened = await compositeKem.decapsulate(privDer, sealed.ciphertext); }
745
+ catch (e2) {
746
+ if (_isKemStructureFault(e2)) throw _err("key/bad-input", "the private key could not be read", e2);
747
+ throw _err("key/unsupported-algorithm", "the key pair could not be exercised to decide whether its two halves correspond", e2);
748
+ }
719
749
  return guard.crypto.constantTimeEqual(opened, sealed.sharedSecret);
720
- } catch (e) {
721
- throw _err("key/unsupported-algorithm", "the key pair could not be exercised to decide whether its two halves correspond", e);
722
750
  } finally {
723
751
  guard.secret.zeroizeAll([sealed && sealed.sharedSecret, opened], KeyError, "key/bad-input", "the correspondence probe secrets");
724
752
  }
725
753
  }
726
754
 
755
+ var _KEM_STRUCTURE_FAULTS = _assign(_create(null), { "kem/bad-key": 1, "kem/bad-input": 1, "kem/bad-algorithm": 1, "kem/bad-ciphertext": 1 });
756
+
757
+ /** @internal Whether a KEM error says the key or ciphertext could not be READ, as opposed to a
758
+ * decapsulation that ran and failed. */
759
+ function _isKemStructureFault(e) {
760
+ return !!(e && e.isPkiError === true && _KEM_STRUCTURE_FAULTS[guard.text.keyOf(e.code)] === 1);
761
+ }
762
+
727
763
  async function publicFromPrivate(privateKey, opts) {
728
764
  opts = guard.identifier.optionsObject(opts, _err, "key/bad-input", "pki.key.publicFromPrivate options");
729
765
  guard.identifier.assertKnownKeys(opts, _PUBLIC_FROM_PRIVATE_OPTS, _err, "key/bad-input",
@@ -860,13 +896,15 @@ function _pssSalt(a, b) {
860
896
 
861
897
  async function correspondsTo(privateKey, spkiDer) {
862
898
  var privDer = await _toPrivateKeyDer(privateKey);
899
+ var spki = _bufferFrom(spkiDer);
863
900
  var compositeOid = _compositeKemOidOf(privDer);
864
- if (compositeOid !== null) return _compositeCorrespondsTo(privDer, _bufferFrom(spkiDer), compositeOid);
865
- /** @internal Whether the two halves are finite-field Diffie-Hellman is answered from their
866
- * algorithm identifiers before either structure is validated: a DH structure's validation proves
867
- * its modulus prime, and a half of another family is not a pair with it whatever it states, so
868
- * that proof is never run to say so. */
869
- if (_isDhOid(_algorithmOidAt(privDer, 1)) !== _isDhOid(_algorithmOidAt(_bufferFrom(spkiDer), 0))) return false;
901
+ if (compositeOid !== null) return _compositeCorrespondsTo(privDer, spki, compositeOid);
902
+ /** @internal The private half is read FIRST, whatever the public half names: a private key that
903
+ * cannot be read is bad input, and a caller tells that apart from a pair that is not one. Only
904
+ * then is the family question asked, from the two identifiers, before the public structure is
905
+ * validated: a composite or a finite-field half against a half of another family is not a pair
906
+ * whatever it states, so the runtime is never asked to read a structure it cannot, and a DH
907
+ * structure's validation, which proves its modulus prime, is never run to say so. */
870
908
  var priv, pub;
871
909
  /** @internal The PKCS #3 rewrite of an X9.42 private key is this function's own copy of the
872
910
  * exponent, wiped once the runtime holds the key. */
@@ -876,7 +914,10 @@ async function correspondsTo(privateKey, spkiDer) {
876
914
  try { priv = nodeCrypto.createPrivateKey({ key: privAsPkcs3, format: "der", type: "pkcs8" }); }
877
915
  catch (e) { throw _err("key/bad-input", "the private key could not be read", e); }
878
916
  } finally { if (privAsPkcs3 !== privDer) guard.secret.zeroize(privAsPkcs3, KeyError, "key/bad-input", "the rewritten private key"); }
879
- var pubAsPkcs3 = _dhAsPkcs3(_bufferFrom(spkiDer), 0, "the public key's");
917
+ var publicOid = _algorithmOidAt(spki, 0);
918
+ if (publicOid !== null && compositeKem.isCompositeKem(publicOid)) return false;
919
+ if (_isDhOid(_algorithmOidAt(privDer, 1)) !== _isDhOid(publicOid)) return false;
920
+ var pubAsPkcs3 = _dhAsPkcs3(spki, 0, "the public key's");
880
921
  _assertDhWithin(pubAsPkcs3, 0, "the public key's");
881
922
  try { pub = nodeCrypto.createPublicKey({ key: pubAsPkcs3, format: "der", type: "spki" }); }
882
923
  catch (e) { throw _err("key/bad-input", "the public key could not be read", e); }
@@ -1000,6 +1041,7 @@ module.exports = {
1000
1041
  x942Domain: x942Domain, // @internal
1001
1042
  pkcs3Params: pkcs3Params, // @internal
1002
1043
  isDhOid: _isDhOid, // @internal
1044
+ algorithmOidAt: _algorithmOidAt, // @internal
1003
1045
  assertDhOperandsWithin: assertDhOperandsWithin, // @internal
1004
1046
  assertDhDomainCoherent: assertDhDomainCoherent, // @internal
1005
1047
  assertDhGeneratorOrder: assertDhGeneratorOrder, // @internal
package/lib/smime.js CHANGED
@@ -41,7 +41,9 @@ var schemaX509 = require("./schema-x509.js");
41
41
  var pkix = require("./schema-pkix.js");
42
42
  var oid = require("./oid.js");
43
43
  var guard = require("./guard-all.js");
44
+ var pkiBuild = require("./pki-build.js");
44
45
  var C = require("./constants.js");
46
+ var nodeUtil = require("util");
45
47
  var nodeCrypto = require("crypto");
46
48
 
47
49
  var SmimeError = frameworkError.SmimeError;
@@ -322,9 +324,130 @@ function _legacyHpSurface(canon, outerEnt, mode, enabled) {
322
324
  var SAN_OID = oid.byName("subjectAltName");
323
325
  var SMTP_UTF8_MAILBOX = oid.byName("smtpUtf8Mailbox");
324
326
  var EMAIL_ADDRESS_ATTR = oid.byName("emailAddress");
327
+ var EMAIL_PROTECTION_OID = oid.byName("emailProtection");
328
+ var ANY_EKU_OID = oid.byName("anyExtendedKeyUsage");
329
+ var DESCRIPTOR_MAX_PROTOTYPES = 32;
330
+ var DESCRIPTOR_MAX_FIELDS = 64;
325
331
  var _extNs = pkix.makeNS("smime", SmimeError, oid);
326
332
  var _extDecoders = pkix.certExtensionDecoders(_extNs);
327
333
  var _extCtx = Object.assign(Object.create(null), { E: function (c, m, cause) { return new SmimeError(c, m, cause); }, oid: oid });
334
+
335
+ /** @internal A signer certificate fit to sign mail. RFC 8550 sec. 4.4.2: a receiving agent "MUST
336
+ * NOT accept the signature of a message if it was verified using a certificate that contains a key
337
+ * usage extension without at least one of the digitalSignature or nonRepudiation bits set", and
338
+ * sec. 4.4.4: if the extended key usage extension is present, a receiving agent "MUST check that it
339
+ * contains either the id-kp-emailProtection OID or the anyExtendedKeyUsage OID". A message signed
340
+ * under such a certificate is refused by every conforming reader, so it is not produced. An absent
341
+ * extension permits signing: sec. 4.4.2 has a reader presume both bits, and sec. 4.4.4 lets a
342
+ * certificate with no extended key usage sign. */
343
+ function _assertSignerFitForMail(certDer, position) {
344
+ var parsed;
345
+ try { parsed = schemaX509.parse(certDer); }
346
+ catch (e) { throw _err("smime/bad-input", "signer " + position + "'s certificate is not a well-formed X.509 certificate", e); }
347
+ var label = "signer " + position + "'s certificate";
348
+ var ku = pkix.keyUsageOf(_extNs, parsed, _err, "smime/bad-signer-certificate", label);
349
+ if (ku !== null && !(ku.digitalSignature === true || ku.nonRepudiation === true)) {
350
+ throw _err("smime/bad-signer-certificate", label + " carries a keyUsage extension with neither digitalSignature nor nonRepudiation, and a receiving agent must not accept a signature under it (RFC 8550 sec. 4.4.2)");
351
+ }
352
+ var purposes = pkix.extKeyUsageOf(_extNs, parsed, _err, "smime/bad-signer-certificate", label);
353
+ if (purposes !== null && !guard.list.contains(purposes, EMAIL_PROTECTION_OID) && !guard.list.contains(purposes, ANY_EKU_OID)) {
354
+ throw _err("smime/bad-signer-certificate", label + " carries an extendedKeyUsage extension with neither emailProtection nor anyExtendedKeyUsage, and a receiving agent must check for one of them (RFC 8550 sec. 4.4.4)");
355
+ }
356
+ }
357
+
358
+ /** @internal The signer descriptors as this verb read them, held to the rule above before anything
359
+ * is signed, and handed on to pki.cms.sign as the SAME values: every field a descriptor carries, own
360
+ * or inherited, is read once with the descriptor as its receiver into a record, and the certificate
361
+ * is the DER it coerced to, so a `cert` accessor cannot answer one certificate to this check and
362
+ * another to the signing beneath, and an accessor over private state answers to the object it was
363
+ * defined for. A single descriptor is the list of one pki.cms.sign accepts. Anything that is not a
364
+ * descriptor travels as given, for pki.cms.sign to refuse in its own words. */
365
+ function _snapshotSignersFitForMail(signers) {
366
+ /** @internal A proxy, as the list or as a descriptor, is refused before anything trusts what its
367
+ * traps answer, as the toolkit's guarded snapshots refuse one; nothing is read through it. A
368
+ * sparse list is refused at its first missing slot, as pki.cms.sign refuses it, before any slot
369
+ * is visited; a list whose own faults surface is this verb's error too. */
370
+ if (nodeUtil.types.isProxy(signers)) throw _err("smime/bad-input", "the signer list must not be a proxy");
371
+ var list;
372
+ try { list = Array.isArray(signers) ? pkiBuild.reqDenseArray(signers, "the signer list", _err, "smime/bad-input") : [signers]; }
373
+ catch (e) {
374
+ if (e instanceof SmimeError) throw e;
375
+ throw _err("smime/bad-input", "the signer list could not be read", e);
376
+ }
377
+ var out = [];
378
+ for (var i = 0; i < list.length; i++) {
379
+ var s = list[i];
380
+ /** @internal An object or a function is a descriptor pki.cms.sign reads. An absent one travels
381
+ * as given for pki.cms.sign to refuse in its own words; a primitive is refused here, since the
382
+ * only fields it could answer with are ones a built-in prototype was made to carry. */
383
+ if (s === null || s === undefined) { guard.list.append(out, s); continue; }
384
+ if (typeof s !== "object" && typeof s !== "function") throw _err("smime/bad-input", "signer " + (i + 1) + " is not a signer descriptor (an object carrying cert and key)");
385
+ if (nodeUtil.types.isProxy(s)) throw _err("smime/bad-input", "signer " + (i + 1) + " must not be a proxy");
386
+ var copy = _readDescriptor(s, i + 1);
387
+ /** @internal The one read of `cert`, whatever it answered: an absent certificate stays absent,
388
+ * so an accessor cannot answer nothing here and a certificate to the signing beneath. A present
389
+ * one is an owned copy of the bytes, not a view, since a later descriptor's accessor runs after
390
+ * this check and could rewrite a buffer the caller still holds. */
391
+ if (copy.cert != null) {
392
+ /** @internal A certificate that cannot be read, in either encoding, is this verb's bad
393
+ * input, never the PEM codec's own code. */
394
+ try { copy.cert = guard.bytes.snapshot(pkix.coerceToDer(copy.cert, { pemLabel: "CERTIFICATE", PemError: SmimeError, ErrorClass: SmimeError, prefix: "smime" }), SmimeError, "smime/bad-input", "signer " + (i + 1) + "'s certificate"); }
395
+ catch (e) { throw _err("smime/bad-input", "signer " + (i + 1) + "'s certificate could not be read as DER or PEM", e); }
396
+ _assertSignerFitForMail(copy.cert, i + 1);
397
+ }
398
+ guard.list.append(out, copy);
399
+ }
400
+ return out;
401
+ }
402
+
403
+ /** @internal Whether a prototype-level property is a method: a data property holding a function. */
404
+ function _isMethod(proto, name) {
405
+ var d = Object.getOwnPropertyDescriptor(proto, name);
406
+ return !!d && !d.get && !d.set && typeof d.value === "function";
407
+ }
408
+
409
+ /** @internal Every named field of a descriptor, own or inherited up to the built-in roots (the
410
+ * object and function prototypes carry no descriptor field, and the function one carries accessors
411
+ * that throw on being read), read once with the descriptor as receiver into a record with no
412
+ * prototype. An accessor that throws is a caller fault reported in this verb's class. */
413
+ function _readDescriptor(s, position) {
414
+ var copy = Object.create(null), seen = Object.create(null), fields = 0;
415
+ try {
416
+ /** @internal The descriptor's own level carries every key, Symbol-keyed and `constructor`
417
+ * included, as the caller set them; a prototype level's `constructor` is the class, not a
418
+ * field. A descriptor carries a handful of fields, so one answering more than the cap is
419
+ * refused before they are read. */
420
+ var symbols = Object.getOwnPropertySymbols(s);
421
+ for (var y = 0; y < symbols.length; y++) {
422
+ if (++fields > DESCRIPTOR_MAX_FIELDS) throw _err("smime/bad-input", "signer " + position + " carries more than " + DESCRIPTOR_MAX_FIELDS + " fields");
423
+ copy[symbols[y]] = s[symbols[y]];
424
+ }
425
+ /** @internal The walk is bounded: a proxy may answer itself, or another visited object, as its
426
+ * prototype, and a chain has a depth no descriptor reaches. A prototype level's methods are
427
+ * the class's, not signer fields: neither copied nor counted. */
428
+ var visited = [], depth = 0;
429
+ for (var proto = s; proto !== null && proto !== Object.prototype && proto !== Function.prototype; proto = Object.getPrototypeOf(proto)) {
430
+ if (visited.indexOf(proto) !== -1 || ++depth > DESCRIPTOR_MAX_PROTOTYPES) {
431
+ throw _err("smime/bad-input", "signer " + position + "'s prototype chain is cyclic or deeper than " + DESCRIPTOR_MAX_PROTOTYPES);
432
+ }
433
+ if (nodeUtil.types.isProxy(proto)) throw _err("smime/bad-input", "signer " + position + " must not inherit from a proxy");
434
+ guard.list.append(visited, proto);
435
+ var names = Object.getOwnPropertyNames(proto);
436
+ for (var n = 0; n < names.length; n++) {
437
+ var name = names[n];
438
+ if ((name === "constructor" && proto !== s) || seen[name] === 1) continue;
439
+ if (proto !== s && _isMethod(proto, name)) continue;
440
+ seen[name] = 1;
441
+ if (++fields > DESCRIPTOR_MAX_FIELDS) throw _err("smime/bad-input", "signer " + position + " carries more than " + DESCRIPTOR_MAX_FIELDS + " fields");
442
+ copy[name] = s[name];
443
+ }
444
+ }
445
+ } catch (e) {
446
+ if (e instanceof SmimeError) throw e;
447
+ throw _err("smime/bad-input", "signer " + position + " could not be read as a signer descriptor", e);
448
+ }
449
+ return copy;
450
+ }
328
451
  function _signerEmails(certDer) {
329
452
  var out = { addresses: [], unreadable: 0 };
330
453
  var parsed = null;
@@ -473,7 +596,14 @@ function _base64Body(der) {
473
596
  * Assemble a signed S/MIME message (RFC 8551). `content` is the payload: a raw body wrapped as a
474
597
  * `text/plain` entity by default, or the caller's own complete MIME entity when `opts.entity` is set;
475
598
  * `signers` is the `pki.cms.sign` signer array (any RSA / RSASSA-PSS / ECDSA / EdDSA / ML-DSA / SLH-DSA
476
- * signer: the S/MIME layer is algorithm-agnostic). Two forms via `opts.form`:
599
+ * signer: the S/MIME layer is algorithm-agnostic). Each signer's certificate must be one a conforming
600
+ * receiving agent accepts a mail signature under: a `keyUsage` extension it carries must assert
601
+ * `digitalSignature` or `nonRepudiation` (RFC 8550 sec. 4.4.2) and an `extendedKeyUsage` extension it
602
+ * carries must include `emailProtection` or `anyExtendedKeyUsage` (sec. 4.4.4); a certificate with neither
603
+ * extension signs, and one failing either rule is refused with `smime/bad-signer-certificate`. The rule is
604
+ * about the certificate the message carries: a key-only signer (`{ key, spki, keyIdentifier }`, the form
605
+ * RFC 8550 sec. 3 permits where correspondents hold each other's certificates by other means) carries none,
606
+ * and the reader holds the certificate it supplies to the same rule. Two forms via `opts.form`:
477
607
  * - `"multipart"` (default, clear-signed): a `multipart/signed` message carrying the canonical entity
478
608
  * verbatim in the first part and a DETACHED CMS SignedData (`application/pkcs7-signature`) over its
479
609
  * canonical form in the second, with `protocol="application/pkcs7-signature"` + a matching `micalg`.
@@ -505,6 +635,7 @@ function _base64Body(der) {
505
635
  async function sign(content, signers, opts) {
506
636
  opts = opts || {};
507
637
  opts = _knownOpts(opts, SIGN_OPTS, "sign");
638
+ signers = _snapshotSignersFitForMail(signers);
508
639
  var hp = opts.protectHeaders === true;
509
640
  var hpList = hp ? _hpHeaderList(opts.headers) : null;
510
641
  var outerList = hp ? _outerHeaderList(hpList, "clear", opts.hcp) : null;
@@ -553,7 +684,7 @@ function _capped(msg) {
553
684
  * certificates, so `valid` says the signature is sound under one of them and nothing about who signed.
554
685
  * Name the roots you accept in `opts.trustAnchors` and `trusted` says every signer chained to one,
555
686
  * validated for EMAIL, at both ends of the chain. The signer certificate must carry the
556
- * `emailProtection` key purpose (RFC 8551 sec. 4.4.4), because a certificate restricted to `serverAuth`
687
+ * `emailProtection` key purpose (RFC 8550 sec. 4.4.4), because a certificate restricted to `serverAuth`
557
688
  * chains to its root perfectly well and is still the wrong key to have signed a message; and the anchor's
558
689
  * own trust metadata must permit that purpose, because a root distributed with NSS trust bits can be
559
690
  * marked untrusted for email while remaining a good TLS root. Override either with `opts.requiredEku`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.7.40",
3
+ "version": "0.7.42",
4
4
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:19ec3e74-60d4-4102-bfdf-50fadfa1aeaa",
5
+ "serialNumber": "urn:uuid:bc84167a-c3b5-4455-9b3b-6a818f51a616",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-09-12T19:13:54.735Z",
8
+ "timestamp": "2026-09-12T22:42:58.983Z",
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.7.40",
22
+ "bom-ref": "@blamejs/pki@0.7.42",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.7.40",
25
+ "version": "0.7.42",
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.7.40",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.7.42",
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.7.40",
57
+ "ref": "@blamejs/pki@0.7.42",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]