@blamejs/pki 0.3.22 → 0.3.24

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,32 @@ 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.3.24 — 2026-07-26
8
+
9
+ pki.smime.verify / decrypt can now recognize a legacy (RFC 8551) header-protected message -- opt in with `legacyHeaderProtection` and the real headers of an older `message/rfc822`-wrapped message are surfaced, safely separated from the authenticated header set.
10
+
11
+ ### Added
12
+
13
+ - pki.smime.verify and pki.smime.decrypt accept opts.legacyHeaderProtection: true to detect a legacy RFC 8551 header-protected message (RFC 9788 sec. 4.10) -- a signed or encrypted payload that is a bare message/rfc822 wrap with no hp= parameter. On a precise match the inner message's Non-Structural headers are surfaced under headerProtection.legacy = { headers, mode, fromMismatch, confidential }, with the mode inferred from the envelope and an encrypted message's confidential set computed against the visible outer headers. RFC 9788 sec. 4.10.1 / sec. 4.10.2, RFC 8551.
14
+ - headerProtection.legacy is null on every verify / decrypt result unless a legacy message was detected via legacyHeaderProtection. The inferred set is intentionally kept separate from the authenticated protectedHeaders (which stays null) and from present (which stays false), since a legacy message is indistinguishable from a forwarded message/rfc822 -- so a consumer keying trust off present/protectedHeaders is never misled by the opt-in heuristic, and consuming headerProtection.legacy.headers is an explicit, fromMismatch-checkable choice.
15
+
16
+ ### Changed
17
+
18
+ - Detection is opt-in and safe-by-default. Without legacyHeaderProtection the behavior is unchanged: a legacy-form message reads as protectedHeaders: null, present: false, legacy: null (never mis-authenticated). With the option set, a message that is not precisely identified -- an ordinary forwarded message/rfc822 that fails a condition, an inner part that is itself a signed/encrypted layer, an inner part declaring hp=, or a part with a duplicate Content-Type -- reports legacy: null. The signed-and-encrypted legacy form (RFC 9788 Appendix C.3.17) surfaces as clear via the caller's re-verify step, a documented limitation of the non-recursive layered API.
19
+
20
+ ## v0.3.23 — 2026-07-26
21
+
22
+ pki.pkcs12.open now reads legacy PKCS#12 stores -- decrypt the RFC 7292 Appendix C 3DES and RC2 bags an `openssl pkcs12 -legacy` (and NSS) store uses, so an older .p12/.pfx opens instead of being refused.
23
+
24
+ ### Added
25
+
26
+ - pki.pkcs12.open decrypts the RFC 7292 Appendix C legacy PBE bags: pbeWithSHAAnd3-KeyTripleDES-CBC, pbeWithSHAAnd2-KeyTripleDES-CBC, pbeWithSHAAnd128BitRC2-CBC, and pbeWithSHAAnd40BitRC2-CBC. This lets an `openssl pkcs12 -legacy` or NSS store -- whose default key bag is 3DES and default cert bag is 40-bit RC2 -- open, where before it was refused. The cipher key and IV are derived with the PKCS#12 Appendix B method over the BMPString+NULL password; a wrong password is caught by the MAC gate (or, for a MAC-less store, is the uniform pkcs12/decrypt-failed). The iteration count is DoS-capped before the key derivation runs. RFC 7292, RFC 2268, RFC 8018.
27
+ - An in-tree RFC 2268 RC2-CBC (lib/rc2.js) fills the gap left by OpenSSL 3.x moving RC2 to its legacy provider (Node's crypto can no longer decrypt it). It is own code with no new runtime dependency, pinned to the RFC 2268 Section 5 known-answer vectors and cross-checked against real OpenSSL -legacy RC2-40 and RC2-128 stores.
28
+
29
+ ### Changed
30
+
31
+ - The legacy RC4 PBE schemes (pbeWithSHAAnd128BitRC4 / pbeWithSHAAnd40BitRC4) remain refused, now with a message that names the scheme and the remediation (re-export the store under AES-256-CBC or PBE-SHA1-3DES).
32
+
7
33
  ## v0.3.22 — 2026-07-26
8
34
 
9
35
  S/MIME header protection ships -- cover the message headers (Subject, From, To, ...) under the CMS signature or encryption with pki.smime, RFC 9788.
package/README.md CHANGED
@@ -230,9 +230,9 @@ is callable today; nothing below is a stub.
230
230
  | `pki.cmp` | RFC 9810 Certificate Management Protocol message building — `build(message, opts)` assembles a protected `PKIMessage`. `message.header` carries the `sender` / `recipient` GeneralNames (including the anonymous NULL-DN) plus optional transaction metadata (`transactionID`, `senderNonce` / `recipNonce`, `messageTime` as a GeneralizedTime, `senderKID` / `recipKID`, `freeText`, `generalInfo`); `message.body` is a single-key object naming the arm. Request-side: `ir` / `cr` / `kur` (a `CertReqMessages` spec delegated to `pki.crmf.build`), `p10cr` (a PKCS#10 `CertificationRequest`), `certConf`, `pollReq`, `genm`, `rr`. CA/responder-side: `ip` / `cp` / `kup` / `ccp` (a `CertRepMessage` — `caPubs` and `response` entries carrying a `PKIStatusInfo` and, under a granting status, a `certifiedKeyPair`), `rp` (revocation response), `genp`, `error`, `pollRep`, `krp` (key-recovery response), `pkiconf`. Protection is exactly one of `opts.{ key, cert }` — a signature over the message under the sender key, the algorithm resolved from the signer certificate so RSA (PKCS#1 v1.5 / PSS), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch — or `opts.mac` — a PBMAC1 shared-secret HMAC (RFC 9481 / 9579, PBKDF2-derived). The protection covers the exact DER of the virtual `ProtectedPart` (the header and body) and is self-verified before the message is returned; the `protectionAlg` is derived, never caller-set, so the message the parser accepts is coherent by construction. Returns DER, or a PEM `CMP` block with `opts.pem`; malformed input throws a typed `CmpError`. `transfer(url, message, opts)` carries a built message to a CMP endpoint over the shared `pki.transport` (RFC 9811 HTTP transfer) — one POST of the DER PKIMessage, the response classified fail-closed (200-only success, a non-200 2xx or an un-followed 3xx refused, a 4xx/5xx carrying a CMP error PKIMessage forwarded as the integrity-protected verdict) with protection surfaced not verified; `wellKnownUrl(base, opts)` builds the RFC 9811 §3.4 `/.well-known/cmp` request-URIs. Parsing stays at `pki.schema.cmp.parse` — `build`, `transfer`, `wellKnownUrl` |
231
231
  | `pki.crl` | RFC 5280 §5 certificate revocation list issuance — `sign(spec, issuer, opts)` builds and signs a `CertificateList`: a `spec` of `thisUpdate` / `nextUpdate`, an optional `crlNumber`, a `revoked` array (each entry a `serialNumber` + `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; an `issuer` of `{ cert, key }` or `{ name, publicKey, key }`. The signature algorithm is resolved from the issuer key, so RSA (PKCS#1 v1.5 / 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 in the revocation list. 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` |
232
232
  | `pki.key` | RFC 5958 / RFC 8018 key-material lifecycle — `encrypt(privateKey, password, opts)` wraps a PKCS#8 private key (DER, PEM, or an extractable `CryptoKey`) into an `EncryptedPrivateKeyInfo` under PBES2 (PBKDF2 + AES-CBC-Pad): `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, scrypt refused), the salt and iteration count are bounded before any derivation (`opts.maxIterations` lowers the cap), a malformed parameter set or wrong-length IV is a distinct typed error, and — because a MAC-less PBES2-CBC decrypt must not be 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)` / `import(input, opts)` move a private key as PKCS#8 or a public key as SubjectPublicKeyInfo, delegating the encoding to WebCrypto so RSA carries an explicit NULL, EC a named curve, and Ed25519/Ed448/X25519/X448 omit parameters (an ambiguous RSA/EC import requires `opts.algorithm`). `generate(algorithm, opts)` produces a key pair over RSA, ECDSA/ECDH, the Edwards/Montgomery curves, and the FIPS post-quantum ML-DSA / ML-KEM, and `publicFromPrivate(privateKey)` derives the public key. Returns DER or PEM; fail-closed with typed `KeyError`. Parsing stays at `pki.schema.pkcs8.parse` — `encrypt` / `decrypt` / `export` / `import` / `generate` / `publicFromPrivate` |
233
- | `pki.pkcs12` | RFC 7292 / RFC 9579 PKCS#12 (.p12/.pfx) issuance — `build(spec, opts)` assembles a password-integrity store. `spec` is 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 / nested `safeContents` bags. Keys and certs are validated before wrapping; `friendlyName` (BMPString) and `localKeyId` attributes are single-value. The store is protected by a classic Appendix B HMAC (default, max interop) or an RFC 9579 PBMAC1 (`opts.mac.algorithm`), over SHA-256/384/512, with the 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 (what OpenSSL and NSS consume) — so a file it emits opens in OpenSSL and NSS, cross-checked bidirectionally. The MAC is computed over 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 store's 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 — a signature from any `pki.cms.sign` signer (RSA / ECDSA / EdDSA / ML-DSA / SLH-DSA / composite), no MacData (RFC 7292 §4); privacy stays independent, so the `password` still PBES2-encrypts the bags. **Public-key privacy** — per-safe `recipients` (or the `opts.recipientCerts` convenience) wraps a SafeContents as a CMS EnvelopedData (AES-CBC, `id-envelopedData` — never GCM) encrypting it to recipient public keys through the shipped `pki.cms.encrypt` recipient model, restricted to certificate recipients (RSA-OAEP / ECDH / X25519 / X448 / ML-KEM — a password or KEK recipient, which `open` could not reopen, is rejected); all four integrity × privacy combinations are permitted (RFC 7292 §3.1). Legacy-PBE (PKCS#12 Appendix C) bag decryption is not yet built. Returns DER or a PEM `PKCS12`; fail-closed with typed `Pkcs12Error`. `open(pfx, password, opts)` reads a store back: it verifies the MAC **first** (a wrong password is the MAC verdict, not 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/secrets as raw DER, all with `friendlyName`/`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, the signer surfaced in `signers` but never trust-chained — the caller's `pki.path.validate` step); a legacy-PBE store is refused; an `id-envelopedData` (public-key privacy) safe is decrypted with `opts.recipientKey` after the integrity gate (`pkcs12/no-recipient-key` if absent, every recipient-side fault the uniform `pkcs12/decrypt-failed`); a post-integrity decrypt failure is the uniform `pkcs12/decrypt-failed`, and `opts.keys: 'crypto'` imports each key to a `CryptoKey`; it reads what OpenSSL and NSS produce. Parsing stays at `pki.schema.pkcs12.parse` — `build` / `verifyMac` / `open` |
233
+ | `pki.pkcs12` | RFC 7292 / RFC 9579 PKCS#12 (.p12/.pfx) issuance — `build(spec, opts)` assembles a password-integrity store. `spec` is 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 / nested `safeContents` bags. Keys and certs are validated before wrapping; `friendlyName` (BMPString) and `localKeyId` attributes are single-value. The store is protected by a classic Appendix B HMAC (default, max interop) or an RFC 9579 PBMAC1 (`opts.mac.algorithm`), over SHA-256/384/512, with the 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 (what OpenSSL and NSS consume) — so a file it emits opens in OpenSSL and NSS, cross-checked bidirectionally. The MAC is computed over 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 store's 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 — a signature from any `pki.cms.sign` signer (RSA / ECDSA / EdDSA / ML-DSA / SLH-DSA / composite), no MacData (RFC 7292 §4); privacy stays independent, so the `password` still PBES2-encrypts the bags. **Public-key privacy** — per-safe `recipients` (or the `opts.recipientCerts` convenience) wraps a SafeContents as a CMS EnvelopedData (AES-CBC, `id-envelopedData` — never GCM) encrypting it to recipient public keys through the shipped `pki.cms.encrypt` recipient model, restricted to certificate recipients (RSA-OAEP / ECDH / X25519 / X448 / ML-KEM — a password or KEK recipient, which `open` could not reopen, is rejected); all four integrity × privacy combinations are permitted (RFC 7292 §3.1). **Legacy-PBE read** — `open` decrypts the RFC 7292 Appendix C 3DES and RC2 bags an `openssl pkcs12 -legacy` / NSS store uses (RC2 via an in-tree RFC 2268 cipher), so an older store opens; the legacy RC4 schemes are refused. Returns DER or a PEM `PKCS12`; fail-closed with typed `Pkcs12Error`. `open(pfx, password, opts)` reads a store back: it verifies the MAC **first** (a wrong password is the MAC verdict, not 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/secrets as raw DER, all with `friendlyName`/`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, the signer surfaced in `signers` but never trust-chained — the caller's `pki.path.validate` step); a legacy-PBE (App. C) store's 3DES / RC2 bags are decrypted (RC4 refused); an `id-envelopedData` (public-key privacy) safe is decrypted with `opts.recipientKey` after the integrity gate (`pkcs12/no-recipient-key` if absent, every recipient-side fault the uniform `pkcs12/decrypt-failed`); a post-integrity decrypt failure is the uniform `pkcs12/decrypt-failed`, and `opts.keys: 'crypto'` imports each key to a `CryptoKey`; it reads what OpenSSL and NSS produce. Parsing stays at `pki.schema.pkcs12.parse` — `build` / `verifyMac` / `open` |
234
234
  | `pki.cms` | RFC 5652 §5 CMS SignedData signing + signature verification — `sign(content, signers, opts)` produces a SignedData (attached or detached, one or many signers, 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 / EdDSA — 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. `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: when signed attributes are 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), otherwise directly over the content. It returns a per-signer verdict with the matched signer certificate; it does not chain that certificate to a trust anchor — that is the caller's step through `pki.path.validate`. **Countersignatures** (RFC 5652 §11.4): `countersign(cms, signers, opts)` adds a countersignature — a `SignerInfo` over the countersigned SignerInfo's signature value, any signer algorithm, nestable, 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 (an RFC 3161 timestamp token attachable via `sign`'s `unsignedAttributes`) under `signers[i].unsignedAttrs`, surfaced unauthenticated. **Content encryption** (RFC 5652/5083/5084/9629): `encrypt(content, recipients, opts)` produces an EnvelopedData, AuthEnvelopedData (AES-GCM, the authenticated default), or EncryptedData — recipients auto-dispatch off the certificate key to key-transport (RSAES-OAEP; v1.5 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 + RFC 3211 PWRI-KEK), or the post-quantum ML-KEM KEMRecipientInfo (RFC 9629/9936) — one fresh content key wrapped 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 / password-oracle freedom), and PKCS#1 v1.5 is decrypt-only under the RFC 3218 implicit-rejection countermeasure. **AuthenticatedData** (RFC 5652 §9): `authenticate(content, recipients, opts)` produces an `id-ct-authData` — cleartext content plus an HMAC-SHA-256/384/512 MAC (authenticated but not encrypted), the fresh MAC key wrapped for every recipient through the same RecipientInfo model as `encrypt`; the MAC covers the authenticated attributes (content-type + 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`. **Compression** (RFC 3274): `compress(content, opts)` / `decompress(input, opts)` produce and consume a CompressedData (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` — a size transform with no integrity/confidentiality (RFC 8551 §2.4.5). Fail-closed with typed `cms/*` errors — `sign`, `verify`, `countersign`, `encrypt`, `authenticate`, `decrypt`, `compress`, `decompress` |
235
- | `pki.smime` | RFC 8551 S/MIME message assembly, verification, encryption, and compression over the CMS layer — `sign(content, signers, opts)` wraps a MIME entity as a signed S/MIME message in either form: `multipart/signed` (clear-signed — the content stays readable in any MUA, a detached CMS SignedData rides alongside as `application/pkcs7-signature` with a matching `micalg`) or `application/pkcs7-mime; smime-type=signed-data` (opaque — the whole entity is a base64 CMS SignedData). The signed bytes are the entity's RFC 8551 §3.1.1 canonical form (CRLF line endings); `verify(message, opts)` unwraps both forms and recomputes over the same canonicalizer, so a transport that re-wraps line endings still verifies and 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 — `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, not the header, and decryption is fail-closed and oracle-free. The crypto is entirely `pki.cms.sign` / `verify` / `encrypt` / `decrypt` — 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 (algorithm-agnostic). Like `cms.verify`, `verify` returns the per-signer cryptographic verdict plus the recovered content; chaining a signer to a trust anchor is the caller's `pki.path.validate` step. `compress(content, opts)` / `decompress(message, opts)` add the opaque `application/pkcs7-mime; smime-type=compressed-data; name=smime.p7z` frame (RFC 8551 §3.6, RFC 3274) — a size transform with no integrity/confidentiality (§2.4.5), decompress 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` / `encrypt` gain `opts.protectHeaders` — the caller's `opts.headers` are inlined on the Cryptographic Payload root (its Content-Type gains `hp="clear"` signed / `hp="cipher"` encrypted) so the CMS signature/encryption covers them, defeating a transport that rewrites or reads Subject/From/… `verify` / `decrypt` surface the AUTHENTICATED inner set as `protectedHeaders` + `headerProtection { present, mode, fromMismatch }` (a tampered outer header cannot alter it; `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/Keywords, so the real values live only in the ciphertext; `decrypt` recovers them. Every emitted header routes through a fail-closed injection guard (a CR/LF/NUL value or a non-ftext name is rejected), and a malformed/contradictory `hp` wrap fails closed (`smime/bad-header-protection`), never a silent downgrade; the CMS crypto is unchanged. Bidirectionally interoperable with `openssl smime` / `openssl cms`. Fail-closed with typed `smime/*` errors — `sign`, `verify`, `encrypt`, `decrypt`, `compress`, `decompress` |
235
+ | `pki.smime` | RFC 8551 S/MIME message assembly, verification, encryption, and compression over the CMS layer — `sign(content, signers, opts)` wraps a MIME entity as a signed S/MIME message in either form: `multipart/signed` (clear-signed — the content stays readable in any MUA, a detached CMS SignedData rides alongside as `application/pkcs7-signature` with a matching `micalg`) or `application/pkcs7-mime; smime-type=signed-data` (opaque — the whole entity is a base64 CMS SignedData). The signed bytes are the entity's RFC 8551 §3.1.1 canonical form (CRLF line endings); `verify(message, opts)` unwraps both forms and recomputes over the same canonicalizer, so a transport that re-wraps line endings still verifies and 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 — `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, not the header, and decryption is fail-closed and oracle-free. The crypto is entirely `pki.cms.sign` / `verify` / `encrypt` / `decrypt` — 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 (algorithm-agnostic). Like `cms.verify`, `verify` returns the per-signer cryptographic verdict plus the recovered content; chaining a signer to a trust anchor is the caller's `pki.path.validate` step. `compress(content, opts)` / `decompress(message, opts)` add the opaque `application/pkcs7-mime; smime-type=compressed-data; name=smime.p7z` frame (RFC 8551 §3.6, RFC 3274) — a size transform with no integrity/confidentiality (§2.4.5), decompress 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` / `encrypt` gain `opts.protectHeaders` — the caller's `opts.headers` are inlined on the Cryptographic Payload root (its Content-Type gains `hp="clear"` signed / `hp="cipher"` encrypted) so the CMS signature/encryption covers them, defeating a transport that rewrites or reads Subject/From/… `verify` / `decrypt` surface the AUTHENTICATED inner set as `protectedHeaders` + `headerProtection { present, mode, fromMismatch, confidential, legacy }` (a tampered outer header cannot alter it; `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/Keywords, so the real values live only in the ciphertext; `decrypt` recovers them. Every emitted header routes through a fail-closed injection guard (a CR/LF/NUL value or a non-ftext name is rejected), and a malformed/contradictory `hp` wrap fails closed (`smime/bad-header-protection`), never a silent downgrade; the CMS crypto is unchanged. Inbound **legacy** RFC 8551 header protection is recognized opt-in: `verify` / `decrypt` with `opts.legacyHeaderProtection` detect a legacy `message/rfc822`-wrapped payload (the RFC 9788 §4.10.1 four-condition identification) and surface the inner message's headers under `headerProtection.legacy = { headers, mode, fromMismatch, confidential }` (`headers` an ordered `[{ name, value }]` array that retains legally-repeated fields like `Received`) — never in `protectedHeaders` and never setting `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`/`protectedHeaders` is never misled, and only one that explicitly reads `headerProtection.legacy.headers` (cross-checking `legacy.fromMismatch`) consumes it. Off by default; 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` / `openssl cms`. Fail-closed with typed `smime/*` errors — `sign`, `verify`, `encrypt`, `decrypt`, `compress`, `decompress` |
236
236
  | `pki.tsp` | RFC 3161 Time-Stamp Protocol — `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 / ordering), plus the RFC 3161 §2.4.2 signing-certificate attribute binding the token to the TSA certificate (SHA-2 imprints, any `pki.cms.sign` TSA key). `request` / `parseRequest` build and parse the TimeStampReq a client sends (imprint, requested policy, nonce, certReq), `response` / `parseResponse` the TimeStampResp a TSA returns — a granted status wrapping a token, or a rejection with PKIStatus and failure info, the §2.4.2 status↔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`, returning `{ valid, genTime, serialNumber, tstInfo, … }` — `sign`, `request`, `parseRequest`, `response`, `parseResponse`, `verify` |
237
237
  | `pki.ocsp` | RFC 6960 Online Certificate Status Protocol — the responder and relying-party surface. `buildRequest(query, opts)` builds an OCSPRequest for one or more `{ cert, issuer }` pairs (CertID hashed under SHA-1 by default per the RFC 5019 lightweight profile, or SHA-2; optional RFC 9654 nonce, optional requestor signature). `sign(responseData, responder, opts)` produces a signed BasicOCSPResponse over the exact `ResponseData` DER — the issuing CA directly or a delegated responder, any `pki.cms.sign` key including the post-quantum ML-DSA / SLH-DSA sets, with `good` / `revoked` (reason + time) / `unknown` per-certificate status, and `buildErrorResponse(status)` the unsigned §2.3 error (`tryLater` / `unauthorized` / …). `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, passing the full out-of-path certificate gates), the signature over `tbsResponseDataBytes`, currency (`thisUpdate`/`nextUpdate`), and the request-nonce echo — returning `{ status: "good" / "revoked" / "unknown", … }`, never a silent accept. Transport-free — `buildRequest`, `sign`, `buildErrorResponse`, `verify` |
238
238
  | `pki.ct` | RFC 6962 Certificate Transparency SCTs — `parseSctList` decodes the `SignedCertificateTimestampList` a certificate or OCSP response carries in the SCT extension (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; `verifySct` verifies an SCT signature against a log's public key by reconstructing the signed data, 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. The producing side: `encodeSctList` builds the extension value byte-for-byte (the exact inverse of `parseSctList`) and `signSct` performs a log's signing step (rebuilding the same signed-data preimage the verifier hashes and signing it with the log's ECDSA-P-256 / RSA key). The trust surface: `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), decoding the state + temporal-interval constraints — and `verifySctWithLogList` resolves the log key from an SCT's log id, enforces the state (usable/qualified/readonly trusted; retired only before retirement; pending/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, EC P-256 arm; forgeable-key defenses fail closed) — cross-checked against `openssl dgst`, completing the offline log-list trust chain. `fetchLogList(opts)` turns that chain into a live client: it GETs the `log_list.json` and its detached `log_list.sig` over the shared `pki.transport`, verifies the detached signature over the raw fetched bytes against a caller-pinned distributor key **before** parsing (verify-before-parse — 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`/`timestamp`. No baked-in vendor URL or key (both caller-pinned); explicit TLS trust with `rejectUnauthorized` always on; each response is size-capped before the trust chain; the transport is dependency-injectable so it is fully testable offline. Structure decoded, crypto fail-closed — `parseSctList`, `reconstructSignedData`, `verifySct`, `encodeSctList`, `signSct`, `parseLogList`, `verifySctWithLogList`, `verifyLogListSignature`, `fetchLogList` |
@@ -34,6 +34,7 @@
34
34
 
35
35
  var nodeCrypto = require("crypto");
36
36
  var asn1 = require("./asn1-der");
37
+ var rc2 = require("./rc2");
37
38
  var oid = require("./oid");
38
39
  var pbes2 = require("./pbes2");
39
40
  var pkcs8 = require("./schema-pkcs8");
@@ -68,6 +69,12 @@ var MAX_PBMAC1_KEYLEN = 1024; // an HMAC key beyond a hash block is pointless
68
69
  // pkcs12-local (its KDF is bespoke here) while PBMAC1 reuses the toolkit-wide PBKDF2 ceiling. 1e6 is ~500x
69
70
  // the OpenSSL default (2048) yet still bounds the loop to ~1 second.
70
71
  var CLASSIC_MAC_MAX_ITERATIONS = 1000000;
72
+ // The AGGREGATE cap on synchronous legacy-PBE App. B KDF work across a single open() -- SHA-1 rounds summed
73
+ // over every legacy bag (its KDF block count x iterations). The per-bag iteration cap resets per bag, so a
74
+ // hostile store that duplicates a costly legacy bag up to the parser's 1024-bag limit would otherwise block
75
+ // the event loop for minutes; this bounds the total to ~the classic MAC ceiling. A conforming store runs only
76
+ // a few thousand rounds (its handful of bags at ~2048 iterations).
77
+ var LEGACY_KDF_MAX_ROUNDS = CLASSIC_MAC_MAX_ITERATIONS;
71
78
 
72
79
  // The classic App. B.2 KDF (u = hash output bytes, v = compression block bytes) per RFC 7292 App. B.2.
73
80
  var P12_KDF_UV = {
@@ -131,20 +138,40 @@ function _blockFill(src, blockSize) {
131
138
  return out;
132
139
  }
133
140
 
134
- // RFC 7292 Appendix B.2 KDF: derive `nBytes` of key material for purpose `id` (3 = MAC integrity) from the
135
- // App. B.1 password bytes + salt over `iterations` rounds of `hashName`. NOT PBKDF2. The only consumer is the
136
- // classic MAC, which always requests exactly the hash output length u, so the App. B.2 block count
137
- // c = ceil(nBytes/u) is 1 -- one diversified, salted, iterated hash (D || S || P, hashed r times). The
138
- // multi-block feedback (the c > 1 case, App. B.2 step 6C) has no consumer here; a future >u-byte derivation
139
- // (e.g. a classic-PBE bag path) reintroduces it with its own known-answer test rather than shipping it
140
- // untested. `nBytes` over u is rejected. The single-block path is proven bidirectionally against OpenSSL.
141
+ // RFC 7292 Appendix B.2 KDF: derive `nBytes` of key material for purpose `id` (1 = cipher key, 2 = IV,
142
+ // 3 = MAC) from the App. B.1 password bytes + salt over `iterations` rounds of `hashName`. NOT PBKDF2. The
143
+ // block count c = ceil(nBytes/u): the MAC always requests exactly the hash output u (c = 1, one diversified,
144
+ // salted, iterated hash), while a legacy-PBE cipher key wider than u (e.g. 24-byte 3-key-3DES from SHA-1's
145
+ // 20-byte output, c = 2) exercises the multi-block feedback (step 6C: I = (I + B + 1) mod 2^(8v) between
146
+ // blocks). The single-block path is byte-identical to before (proven bidirectionally against OpenSSL); the
147
+ // multi-block path is proven against an `openssl pkcs12 -legacy` store (its own known-answer test).
141
148
  function _p12Kdf(hashName, id, pwBytes, salt, iterations, nBytes) {
142
- var uv = P12_KDF_UV[hashName];
143
- if (nBytes > uv.u) throw _err("pkcs12/unsupported-algorithm", "the App. B.2 KDF here derives at most the hash output length");
144
- var v = uv.v;
145
- var A = Buffer.concat([Buffer.alloc(v, id), _blockFill(salt, v), _blockFill(pwBytes, v)]); // D || S || P
146
- for (var r = 0; r < iterations; r++) A = nodeCrypto.createHash(hashName).update(A).digest(); // H^r(D||I)
147
- return A.subarray(0, nBytes);
149
+ var uv = P12_KDF_UV[hashName], u = uv.u, v = uv.v;
150
+ var D = Buffer.alloc(v, id); // step 1: v copies of the ID diversifier
151
+ var I = Buffer.concat([_blockFill(salt, v), _blockFill(pwBytes, v)]); // steps 2-4: I = S || P
152
+ var c = Math.ceil(nBytes / u); // step 5
153
+ var out = Buffer.alloc(0);
154
+ for (var i = 0; i < c; i++) { // step 6
155
+ var A = Buffer.concat([D, I]);
156
+ for (var r = 0; r < iterations; r++) A = nodeCrypto.createHash(hashName).update(A).digest(); // 6A: A = H^r(D||I)
157
+ out = Buffer.concat([out, A]); // step 7 (accumulate A_1..A_c)
158
+ if (i < c - 1) _kdfStepC(I, _blockFill(A, v), v); // 6B+6C: modify I for the next block
159
+ }
160
+ return out.subarray(0, nBytes); // step 8: first nBytes of A
161
+ }
162
+
163
+ // RFC 7292 App. B.2 step 6C: treat I as consecutive v-byte blocks and set each I_j = (I_j + B + 1) mod 2^(8v)
164
+ // via a big-endian byte-carry add (the +1 is the initial carry; carry out of the top byte is discarded, the
165
+ // mod). In place on I.
166
+ function _kdfStepC(I, B, v) {
167
+ for (var j = 0; j < I.length; j += v) {
168
+ var carry = 1;
169
+ for (var k = v - 1; k >= 0; k--) {
170
+ var sum = I[j + k] + B[k] + carry;
171
+ I[j + k] = sum & 0xff;
172
+ carry = sum >>> 8;
173
+ }
174
+ }
148
175
  }
149
176
 
150
177
  // ---- input coercion --------------------------------------------------------
@@ -526,8 +553,10 @@ function _capWork(iterations, salt, opts, keyLength, hardCap) {
526
553
  * password / MAC-less mode). The signer is surfaced, NEVER trust-chained -- anchoring `signers[i].cert` to a
527
554
  * trust root is the caller's `pki.path.validate` step (the out-of-path signer contract). Privacy is
528
555
  * independent of integrity, so the bag `password` still decrypts a public-key store's bags; a wrong bag
529
- * password there is the uniform `pkcs12/decrypt-failed` (no MAC to catch it first). Only PBES2 (AES-CBC) bags
530
- * are decrypted -- a legacy PBE scheme is named and refused.
556
+ * password there is the uniform `pkcs12/decrypt-failed` (no MAC to catch it first). Bags encrypted under
557
+ * PBES2 (AES-CBC), or the RFC 7292 App. C legacy schemes an `openssl pkcs12 -legacy` store uses -- 3-key /
558
+ * 2-key Triple-DES-CBC and 40-/128-bit RC2-CBC (the App. B KDF over the BMPString password, RC2 via an in-tree
559
+ * RFC 2268 cipher) -- are decrypted; the legacy RC4 schemes are named and refused.
531
560
  *
532
561
  * @opts
533
562
  * - `allowUnauthenticated` (boolean) -- open a MAC-less store anyway (result carries `macVerified: false`).
@@ -565,21 +594,22 @@ async function open(pfx, password, opts) {
565
594
  } else if (!opts.allowUnauthenticated) {
566
595
  throw _err("pkcs12/no-integrity", "the store carries no integrity MAC (integrityMode " + m.integrityMode + "); set opts.allowUnauthenticated to open it anyway");
567
596
  }
568
- // The PBES2 bag/safe password is UTF-8 (the pinned interop convention), distinct from the classic MAC
569
- // BMPString -- and INDEPENDENT of the public-key integrity keypair; a wrong bag password fails at the first
570
- // encrypted bag as the uniform pkcs12/decrypt-failed (there is no MAC to catch it first in public-key mode).
571
- var pwBytes = _pbePassword(password);
597
+ // The raw password is threaded to each bag/safe decrypt site, which picks the encoding its scheme needs:
598
+ // PBES2 uses UTF-8 (the pinned interop convention), a legacy App. C scheme uses the BMPString+NULL form (the
599
+ // App. B.1 encoding the classic MAC also uses) -- both distinct from the public-key integrity keypair. A
600
+ // wrong bag password fails at the first encrypted bag as the uniform pkcs12/decrypt-failed.
572
601
  var out = { integrityMode: m.integrityMode, macVerified: macVerified, signers: signers, keys: [], certs: [], crls: [], secrets: [] };
573
602
  var i;
574
- for (i = 0; i < m.safeBags.length; i++) _openBag(m.safeBags[i], pwBytes, opts, out, 0);
575
- for (i = 0; i < m.encryptedSafes.length; i++) await _openEncryptedSafe(m.encryptedSafes[i], pwBytes, opts, out, 0);
603
+ var kdfBudget = { rounds: LEGACY_KDF_MAX_ROUNDS }; // aggregate legacy-PBE KDF work budget for this whole open()
604
+ for (i = 0; i < m.safeBags.length; i++) _openBag(m.safeBags[i], password, opts, out, 0, kdfBudget);
605
+ for (i = 0; i < m.encryptedSafes.length; i++) await _openEncryptedSafe(m.encryptedSafes[i], password, opts, out, 0, kdfBudget);
576
606
  if (opts.keys === "crypto") {
577
607
  for (i = 0; i < out.keys.length; i++) out.keys[i].key = await key.import(out.keys[i].pkcs8, { algorithm: opts.importAlgorithm });
578
608
  }
579
609
  return out;
580
610
  }
581
611
 
582
- function _openBag(bag, pwBytes, opts, out, depth) {
612
+ function _openBag(bag, password, opts, out, depth, budget) {
583
613
  switch (bag.type) {
584
614
  case "keyBag":
585
615
  // The open contract surfaces keys[].pkcs8 as canonical PrivateKeyInfo DER (key.import re-decodes strict
@@ -590,7 +620,7 @@ function _openBag(bag, pwBytes, opts, out, depth) {
590
620
  out.keys.push({ pkcs8: bag.keyDer, encrypted: false, friendlyName: bag.friendlyName, localKeyId: bag.localKeyId });
591
621
  break;
592
622
  case "pkcs8ShroudedKeyBag":
593
- out.keys.push({ pkcs8: _decryptShroudedKey(bag.encrypted, pwBytes, opts), encrypted: true, friendlyName: bag.friendlyName, localKeyId: bag.localKeyId });
623
+ out.keys.push({ pkcs8: _decryptShroudedKey(bag.encrypted, password, opts, budget), encrypted: true, friendlyName: bag.friendlyName, localKeyId: bag.localKeyId });
594
624
  break;
595
625
  case "certBag":
596
626
  out.certs.push({ cert: bag.certValue, certType: bag.certType, friendlyName: bag.friendlyName, localKeyId: bag.localKeyId });
@@ -603,18 +633,90 @@ function _openBag(bag, pwBytes, opts, out, depth) {
603
633
  break;
604
634
  case "safeContentsBag":
605
635
  if (depth + 1 > C.LIMITS.PKCS12_MAX_BAG_DEPTH) throw _err("pkcs12/too-deep", "safeContentsBag nesting exceeds the depth cap " + C.LIMITS.PKCS12_MAX_BAG_DEPTH);
606
- for (var n = 0; n < (bag.nested || []).length; n++) _openBag(bag.nested[n], pwBytes, opts, out, depth + 1);
636
+ for (var n = 0; n < (bag.nested || []).length; n++) _openBag(bag.nested[n], password, opts, out, depth + 1, budget);
607
637
  break;
608
638
  default:
609
639
  throw _err("pkcs12/bad-input", "unexpected bag type " + bag.type);
610
640
  }
611
641
  }
612
642
 
613
- // A pkcs8ShroudedKeyBag is a bare EncryptedPrivateKeyInfo (RFC 5958 sec. 3): PBES2-decrypt to a PrivateKeyInfo,
643
+ // The RFC 7292 Appendix C / RFC 8018 PBES1 legacy PKCS#12 PBE schemes: the App. B.2 KDF (BMPString password)
644
+ // derives a cipher key (id=1) + IV (id=2), then a legacy CBC cipher decrypts the bag. An `openssl pkcs12
645
+ // -legacy` store uses pbeWithSHAAnd3-KeyTripleDES-CBC for keys and 40-bit RC2 for certs by default. RC2 and
646
+ // RC4 were moved to OpenSSL 3.x's legacy provider (not loaded by node), so they are recognized but refused
647
+ // with an actionable message rather than silently failing; the 3DES schemes decrypt via node.
648
+ // Keyed by the IMMUTABLE dotted OID (resolved once at module load via oid.byName), NOT the display name --
649
+ // so `pki.oid.register` renaming a built-in OID, or aliasing a legacy name onto an unrelated private OID,
650
+ // cannot alter which cipher a bag decrypts under (the PBES2 branch dispatches on the OID the same way).
651
+ var LEGACY_PBE = Object.create(null);
652
+ LEGACY_PBE[O("pbeWithSHAAnd3-KeyTripleDES-CBC")] = { cipher: "des-ede3-cbc", keyLen: 24, ivLen: 8, hash: "sha1" };
653
+ LEGACY_PBE[O("pbeWithSHAAnd2-KeyTripleDES-CBC")] = { cipher: "des-ede-cbc", keyLen: 16, ivLen: 8, hash: "sha1" };
654
+ LEGACY_PBE[O("pbeWithSHAAnd128BitRC2-CBC")] = { rc2: 128, keyLen: 16, ivLen: 8, hash: "sha1" };
655
+ LEGACY_PBE[O("pbeWithSHAAnd40BitRC2-CBC")] = { rc2: 40, keyLen: 5, ivLen: 8, hash: "sha1" };
656
+ LEGACY_PBE[O("pbeWithSHAAnd128BitRC4")] = { rc4: true };
657
+ LEGACY_PBE[O("pbeWithSHAAnd40BitRC4")] = { rc4: true };
658
+
659
+ // Decrypt a legacy-PBE bag. pkcs-12PbeParams ::= SEQUENCE { salt OCTET STRING, iterations INTEGER }. Every
660
+ // secret-dependent fault (a bad key -> a CBC unpad failure) collapses to the uniform pkcs12/decrypt-failed
661
+ // (no padding oracle, RFC 8018 sec. 8). The iteration count is DoS-capped BEFORE the sync KDF runs.
662
+ function _decryptLegacyPbe(ea, ct, password, opts, budget) {
663
+ var scheme = LEGACY_PBE[ea.oid];
664
+ if (!scheme) throw _err("pkcs12/unsupported-algorithm", "the bag uses " + (ea.name || ea.oid) + " (only RFC 8018 PBES2 and the RFC 7292 App. C 3DES / RC2 schemes are decrypted; re-export with -keypbe/-certpbe AES-256-CBC)");
665
+ if (scheme.rc4) throw _err("pkcs12/unsupported-algorithm", "the bag uses " + ea.name + " -- RC4 legacy PBE is not supported; re-export with -keypbe/-certpbe AES-256-CBC or PBE-SHA1-3DES");
666
+ // Decode + read pkcs-12PbeParams, wrapping EVERY fault in a typed pkcs12 error so a malformed legacy store is
667
+ // classified like a malformed PBES2 one (pkcs12/bad-algorithm-parameters), never a leaked asn1/* code: omitted
668
+ // parameters make asn1.decode throw asn1/not-buffer, a wrong-type salt/iterations makes asn1.read.* throw.
669
+ // Content regions are normatively BER (RFC 7292 sec. 4.1) and the parser accepts BER anywhere, so a conforming
670
+ // store may use an indefinite-length SEQUENCE or a constructed OCTET STRING salt -- decode in BER mode.
671
+ var salt, iterations;
672
+ try {
673
+ var params = asn1.decode(ea.parameters, { ber: true });
674
+ // Coverage residual -- the tagNumber!==SEQUENCE arm is exercised (a non-SEQUENCE params vector); the
675
+ // !children and length<2 arms are defensive-unreachable via a conforming decode (asn1.decode of a
676
+ // constructed node always sets a children array, and every real pkcs-12PbeParams carries salt+iterations).
677
+ if (params.tagNumber !== asn1.TAGS.SEQUENCE || !params.children || params.children.length < 2) throw _err("pkcs12/bad-algorithm-parameters", "malformed pkcs-12PbeParams (RFC 7292 App. C)");
678
+ salt = Buffer.from(asn1.read.octetString(params.children[0]));
679
+ iterations = guard.range.positiveInt31(asn1.read.integer(params.children[1]), _err, "pkcs12/bad-algorithm-parameters", "legacy PBE iterations");
680
+ } catch (e) {
681
+ if (e && e.isPkcs12Error) throw e; // an already-typed pkcs12 fault (the checks above) passes through
682
+ throw _err("pkcs12/bad-algorithm-parameters", "malformed pkcs-12PbeParams (RFC 7292 App. C)", e);
683
+ }
684
+ var cap = opts.maxIterations != null ? Math.min(opts.maxIterations, CLASSIC_MAC_MAX_ITERATIONS) : CLASSIC_MAC_MAX_ITERATIONS;
685
+ if (iterations > cap) throw _err("pkcs12/iteration-limit", "the legacy PBE iteration count " + iterations + " exceeds the cap " + cap);
686
+ // Coverage residual -- the salt cap is the shared C.LIMITS.PBKDF2_MAX_SALT the classic MAC / PBES2 paths
687
+ // already exercise; reaching this legacy-path duplicate needs a >1024-octet salt (a length-cascade-crafted
688
+ // store no conforming producer emits), so the over-cap arm is documented rather than fixture-forced.
689
+ if (salt.length > C.LIMITS.PBKDF2_MAX_SALT) throw _err("pkcs12/bad-input", "the legacy PBE salt exceeds the " + C.LIMITS.PBKDF2_MAX_SALT + "-octet cap");
690
+ // Charge the aggregate KDF-work budget BEFORE deriving: the App. B KDF hashes `iterations` times per output
691
+ // block, and this bag derives ceil(keyLen/u) key blocks + ceil(ivLen/u) IV blocks. A hostile many-bag store
692
+ // exhausts the shared budget instead of resetting the per-bag cap each time.
693
+ var u = P12_KDF_UV[scheme.hash].u;
694
+ budget.rounds -= (Math.ceil(scheme.keyLen / u) + Math.ceil(scheme.ivLen / u)) * iterations;
695
+ if (budget.rounds < 0) throw _err("pkcs12/iteration-limit", "the store's aggregate legacy PBE key-derivation work exceeds the budget (a hostile many-bag store)");
696
+ var p12pw = _p12Password(password); // App. B.1 BMPString + NULL (NOT the PBES2 UTF-8 encoding)
697
+ var keyM = _p12Kdf(scheme.hash, 1, p12pw, salt, iterations, scheme.keyLen);
698
+ var iv = _p12Kdf(scheme.hash, 2, p12pw, salt, iterations, scheme.ivLen);
699
+ // BOTH ciphers funnel through ONE uniform failure (same code AND message), so a bad-pad (secret-dependent)
700
+ // and a valid-pad-wrong-content outcome are indistinguishable -- no CBC padding oracle (RFC 8018 sec. 8). RC2
701
+ // is unavailable in node; the in-tree RFC 2268 primitive fills the gap and its typed reject is normalized here.
702
+ try {
703
+ if (scheme.rc2) return rc2.cbcDecrypt(keyM, scheme.rc2, iv, ct, _err, "pkcs12/decrypt-failed");
704
+ var d = nodeCrypto.createDecipheriv(scheme.cipher, keyM, iv);
705
+ return Buffer.concat([d.update(ct), d.final()]);
706
+ } catch (_e) { throw _err("pkcs12/decrypt-failed", "decryption failed"); }
707
+ }
708
+
709
+ // Decrypt a PBES2 (RFC 8018) or legacy-PBE (RFC 7292 App. C) bag/safe -- dispatch on the encryptionAlgorithm
710
+ // OID. PBES2 uses the UTF-8 password (the pinned interop convention); legacy PBE uses the App. B.1 BMPString.
711
+ function _decryptBag(ea, ct, password, opts, budget) {
712
+ if (ea.oid === O("pbes2")) return pbes2.pbes2Decrypt(_pbePassword(password), ea.parameters, ct, opts, _err, "pkcs12");
713
+ return _decryptLegacyPbe(ea, ct, password, opts, budget);
714
+ }
715
+
716
+ // A pkcs8ShroudedKeyBag is a bare EncryptedPrivateKeyInfo (RFC 5958 sec. 3): decrypt to a PrivateKeyInfo,
614
717
  // re-validated (the re-parse is the MAC-less integrity check for the bag ciphertext).
615
- function _decryptShroudedKey(encrypted, pwBytes, opts) {
616
- if (encrypted.encryptionAlgorithm.oid !== O("pbes2")) throw _err("pkcs12/unsupported-algorithm", "the shrouded key uses " + (encrypted.encryptionAlgorithm.name || encrypted.encryptionAlgorithm.oid) + " (only RFC 8018 PBES2 is decrypted; re-export with -keypbe AES-256-CBC)");
617
- var der = pbes2.pbes2Decrypt(pwBytes, encrypted.encryptionAlgorithm.parameters, encrypted.encryptedData, opts, _err, "pkcs12");
718
+ function _decryptShroudedKey(encrypted, password, opts, budget) {
719
+ var der = _decryptBag(encrypted.encryptionAlgorithm, encrypted.encryptedData, password, opts, budget);
618
720
  try { pkcs8.parse(der); } catch (_e) { throw _err("pkcs12/decrypt-failed", "decryption failed"); }
619
721
  return der;
620
722
  }
@@ -632,7 +734,7 @@ function _mapDecryptError(e) {
632
734
  // A privacy safe: id-encryptedData (PBES2 password) OR id-envelopedData (public-key, RFC 7292 sec. 3.1) --
633
735
  // decrypt the SafeContents, then re-walk it through the strict parser (same PKCS12_MAX_* caps) and open each
634
736
  // recovered bag. Async because the envelope path awaits pki.cms.decrypt.
635
- async function _openEncryptedSafe(encSafe, pwBytes, opts, out, depth) {
737
+ async function _openEncryptedSafe(encSafe, password, opts, out, depth, budget) {
636
738
  if (encSafe.type === "envelopedData") {
637
739
  // INTEGRITY-BEFORE-USE: open()'s pre-loop MAC / SignedData gate already ran over the WHOLE AuthenticatedSafe
638
740
  // (which covers this enveloped element), so the ciphertext reaching cms.decrypt is integrity-checked. The
@@ -649,14 +751,13 @@ async function _openEncryptedSafe(encSafe, pwBytes, opts, out, depth) {
649
751
  // bad pad -- a distinguishable structural code here would be a padding oracle. The strict walk's caps fire.
650
752
  try { envBags = schemaPkcs12.walkSafeContents(res.content); }
651
753
  catch (_e) { throw _err("pkcs12/decrypt-failed", "decryption failed"); }
652
- for (var k = 0; k < envBags.length; k++) _openBag(envBags[k], pwBytes, opts, out, depth);
754
+ for (var k = 0; k < envBags.length; k++) _openBag(envBags[k], password, opts, out, depth, budget);
653
755
  return;
654
756
  }
655
757
  if (encSafe.type !== "encryptedData") throw _err("pkcs12/unsupported-algorithm", "an " + encSafe.type + " privacy safe is not supported (only id-encryptedData under PBES2 or id-envelopedData under a recipient key)");
656
758
  var eci = encSafe.content.encryptedContentInfo;
657
- if (eci.contentEncryptionAlgorithm.oid !== O("pbes2")) throw _err("pkcs12/unsupported-algorithm", "the privacy safe uses " + (eci.contentEncryptionAlgorithm.name || eci.contentEncryptionAlgorithm.oid) + " (only RFC 8018 PBES2 is decrypted; re-export with -certpbe AES-256-CBC)");
658
759
  if (eci.encryptedContent == null) throw _err("pkcs12/bad-input", "the encrypted privacy safe has no content");
659
- var safeContentsDer = pbes2.pbes2Decrypt(pwBytes, eci.contentEncryptionAlgorithm.parameters, eci.encryptedContent, opts, _err, "pkcs12");
760
+ var safeContentsDer = _decryptBag(eci.contentEncryptionAlgorithm, eci.encryptedContent, password, opts, budget);
660
761
  // The bag/safe password MAY differ from the (already-verified) MAC password, so a decrypt under the MAC
661
762
  // password can succeed with a valid pad yet yield bytes that are not a SafeContents. Collapse THAT re-parse
662
763
  // failure into the same uniform pkcs12/decrypt-failed as a bad pad -- a distinguishable structural code here
@@ -664,7 +765,7 @@ async function _openEncryptedSafe(encSafe, pwBytes, opts, out, depth) {
664
765
  var bags;
665
766
  try { bags = schemaPkcs12.walkSafeContents(safeContentsDer); }
666
767
  catch (_e) { throw _err("pkcs12/decrypt-failed", "decryption failed"); }
667
- for (var i = 0; i < bags.length; i++) _openBag(bags[i], pwBytes, opts, out, depth);
768
+ for (var i = 0; i < bags.length; i++) _openBag(bags[i], password, opts, out, depth, budget);
668
769
  }
669
770
 
670
771
  module.exports = {
package/lib/rc2.js ADDED
@@ -0,0 +1,130 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ //
5
+ // @internal -- no operator-facing namespace. RC2 is exposed only through the legacy-PBE bag decryption of
6
+ // pki.pkcs12.open (RFC 7292 App. C), never as a general cipher; RC2 is a broken 64-bit block cipher (RC2-40
7
+ // is export-crippled) kept solely to READ old `openssl pkcs12 -legacy` / NSS stores.
8
+ //
9
+ // rc2 -- a from-scratch RFC 2268 RC2-CBC. OpenSSL 3.x moved RC2 to the legacy provider (not loaded by Node),
10
+ // so node:crypto cannot decrypt these bags; this in-tree primitive fills that single gap (Hard rule #1: own
11
+ // code, no npm hop -- MANIFEST stays empty; a lib primitive like webcrypto.js, not a lib/vendor/ bundle). The
12
+ // RFC 2268 sec. 5 known-answer vectors pin the cipher; the `openssl -legacy` store is the end-to-end KAT.
13
+
14
+ // RFC 2268 sec. 2: PITABLE, a fixed 256-byte permutation of the digits of pi used by the key schedule.
15
+ var PITABLE = [
16
+ 0xd9, 0x78, 0xf9, 0xc4, 0x19, 0xdd, 0xb5, 0xed, 0x28, 0xe9, 0xfd, 0x79, 0x4a, 0xa0, 0xd8, 0x9d,
17
+ 0xc6, 0x7e, 0x37, 0x83, 0x2b, 0x76, 0x53, 0x8e, 0x62, 0x4c, 0x64, 0x88, 0x44, 0x8b, 0xfb, 0xa2,
18
+ 0x17, 0x9a, 0x59, 0xf5, 0x87, 0xb3, 0x4f, 0x13, 0x61, 0x45, 0x6d, 0x8d, 0x09, 0x81, 0x7d, 0x32,
19
+ 0xbd, 0x8f, 0x40, 0xeb, 0x86, 0xb7, 0x7b, 0x0b, 0xf0, 0x95, 0x21, 0x22, 0x5c, 0x6b, 0x4e, 0x82,
20
+ 0x54, 0xd6, 0x65, 0x93, 0xce, 0x60, 0xb2, 0x1c, 0x73, 0x56, 0xc0, 0x14, 0xa7, 0x8c, 0xf1, 0xdc,
21
+ 0x12, 0x75, 0xca, 0x1f, 0x3b, 0xbe, 0xe4, 0xd1, 0x42, 0x3d, 0xd4, 0x30, 0xa3, 0x3c, 0xb6, 0x26,
22
+ 0x6f, 0xbf, 0x0e, 0xda, 0x46, 0x69, 0x07, 0x57, 0x27, 0xf2, 0x1d, 0x9b, 0xbc, 0x94, 0x43, 0x03,
23
+ 0xf8, 0x11, 0xc7, 0xf6, 0x90, 0xef, 0x3e, 0xe7, 0x06, 0xc3, 0xd5, 0x2f, 0xc8, 0x66, 0x1e, 0xd7,
24
+ 0x08, 0xe8, 0xea, 0xde, 0x80, 0x52, 0xee, 0xf7, 0x84, 0xaa, 0x72, 0xac, 0x35, 0x4d, 0x6a, 0x2a,
25
+ 0x96, 0x1a, 0xd2, 0x71, 0x5a, 0x15, 0x49, 0x74, 0x4b, 0x9f, 0xd0, 0x5e, 0x04, 0x18, 0xa4, 0xec,
26
+ 0xc2, 0xe0, 0x41, 0x6e, 0x0f, 0x51, 0xcb, 0xcc, 0x24, 0x91, 0xaf, 0x50, 0xa1, 0xf4, 0x70, 0x39,
27
+ 0x99, 0x7c, 0x3a, 0x85, 0x23, 0xb8, 0xb4, 0x7a, 0xfc, 0x02, 0x36, 0x5b, 0x25, 0x55, 0x97, 0x31,
28
+ 0x2d, 0x5d, 0xfa, 0x98, 0xe3, 0x8a, 0x92, 0xae, 0x05, 0xdf, 0x29, 0x10, 0x67, 0x6c, 0xba, 0xc9,
29
+ 0xd3, 0x00, 0xe6, 0xcf, 0xe1, 0x9e, 0xa8, 0x2c, 0x63, 0x16, 0x01, 0x3f, 0x58, 0xe2, 0x89, 0xa9,
30
+ 0x0d, 0x38, 0x34, 0x1b, 0xab, 0x33, 0xff, 0xb0, 0xbb, 0x48, 0x0c, 0x5f, 0xb9, 0xb1, 0xcd, 0x2e,
31
+ 0xc5, 0xf3, 0xdb, 0x47, 0xe5, 0xa5, 0x9c, 0x77, 0x0a, 0xa6, 0x20, 0x68, 0xfe, 0x7f, 0xc1, 0xad,
32
+ ];
33
+
34
+ // RFC 2268 sec. 2: expand `key` (1..128 bytes) to the 64 16-bit words K[0..63], reducing to `effectiveBits`.
35
+ function _expandKey(key, effectiveBits) {
36
+ var T = key.length, L = new Array(128);
37
+ for (var i = 0; i < T; i++) L[i] = key[i];
38
+ for (i = T; i < 128; i++) L[i] = PITABLE[(L[i - 1] + L[i - T]) & 0xff];
39
+ var T8 = (effectiveBits + 7) >>> 3;
40
+ var TM = 0xff >>> ((8 - (effectiveBits & 7)) & 7); // 255 mod 2^(8 + effectiveBits - 8*T8)
41
+ L[128 - T8] = PITABLE[L[128 - T8] & TM];
42
+ for (i = 127 - T8; i >= 0; i--) L[i] = PITABLE[L[i + 1] ^ L[i + T8]];
43
+ var K = new Array(64);
44
+ for (i = 0; i < 64; i++) K[i] = L[2 * i] + (L[2 * i + 1] << 8);
45
+ return K;
46
+ }
47
+
48
+ function _rotl16(x, n) { return ((x << n) | (x >>> (16 - n))) & 0xffff; }
49
+ function _rotr16(x, n) { return ((x >>> n) | (x << (16 - n))) & 0xffff; }
50
+ var SHIFT = [1, 2, 3, 5];
51
+
52
+ // RFC 2268 sec. 3: encrypt one 8-byte block (little-endian 16-bit words) in place-ish, returning 8 bytes.
53
+ function _encBlock(K, b, off) {
54
+ var R = [b[off] | (b[off + 1] << 8), b[off + 2] | (b[off + 3] << 8), b[off + 4] | (b[off + 5] << 8), b[off + 6] | (b[off + 7] << 8)];
55
+ var j = 0, i;
56
+ function mix() {
57
+ for (var k = 0; k < 4; k++) {
58
+ R[k] = (R[k] + K[j] + (R[(k + 3) & 3] & R[(k + 2) & 3]) + (~R[(k + 3) & 3] & R[(k + 1) & 3])) & 0xffff;
59
+ j++;
60
+ R[k] = _rotl16(R[k], SHIFT[k]);
61
+ }
62
+ }
63
+ function mash() { for (var k = 0; k < 4; k++) R[k] = (R[k] + K[R[(k + 3) & 3] & 63]) & 0xffff; }
64
+ for (i = 0; i < 5; i++) mix();
65
+ mash();
66
+ for (i = 0; i < 6; i++) mix();
67
+ mash();
68
+ for (i = 0; i < 5; i++) mix();
69
+ return R;
70
+ }
71
+
72
+ // RFC 2268 sec. 3 inverse: decrypt one 8-byte block.
73
+ function _decBlock(K, b, off) {
74
+ var R = [b[off] | (b[off + 1] << 8), b[off + 2] | (b[off + 3] << 8), b[off + 4] | (b[off + 5] << 8), b[off + 6] | (b[off + 7] << 8)];
75
+ var j = 63, i;
76
+ function rMix() {
77
+ for (var k = 3; k >= 0; k--) {
78
+ R[k] = _rotr16(R[k], SHIFT[k]);
79
+ R[k] = (R[k] - K[j] - (R[(k + 3) & 3] & R[(k + 2) & 3]) - (~R[(k + 3) & 3] & R[(k + 1) & 3])) & 0xffff;
80
+ j--;
81
+ }
82
+ }
83
+ function rMash() { for (var k = 3; k >= 0; k--) R[k] = (R[k] - K[R[(k + 3) & 3] & 63]) & 0xffff; }
84
+ for (i = 0; i < 5; i++) rMix();
85
+ rMash();
86
+ for (i = 0; i < 6; i++) rMix();
87
+ rMash();
88
+ for (i = 0; i < 5; i++) rMix();
89
+ return R;
90
+ }
91
+
92
+ function _wordsToBytes(R) {
93
+ return Buffer.from([R[0] & 0xff, (R[0] >>> 8) & 0xff, R[1] & 0xff, (R[1] >>> 8) & 0xff, R[2] & 0xff, (R[2] >>> 8) & 0xff, R[3] & 0xff, (R[3] >>> 8) & 0xff]);
94
+ }
95
+
96
+ // ECB block encrypt/decrypt (used by the KAT; CBC wraps them).
97
+ function encryptBlock(key, effectiveBits, block) { return _wordsToBytes(_encBlock(_expandKey(key, effectiveBits), block, 0)); }
98
+ function decryptBlock(key, effectiveBits, block) { return _wordsToBytes(_decBlock(_expandKey(key, effectiveBits), block, 0)); }
99
+
100
+ // RC2-CBC decrypt with PKCS#7 unpadding. `E`/`code` are the caller's typed-error factory + code; a structural
101
+ // fault (bad length, invalid pad) throws E(code) so the caller can collapse it to its uniform decrypt verdict.
102
+ function cbcDecrypt(key, effectiveBits, iv, ct, E, code) {
103
+ if (ct.length === 0 || ct.length % 8 !== 0) throw E(code, "RC2-CBC ciphertext length must be a non-zero multiple of 8");
104
+ var K = _expandKey(key, effectiveBits), out = Buffer.alloc(ct.length), prev = iv;
105
+ for (var off = 0; off < ct.length; off += 8) {
106
+ var dec = _wordsToBytes(_decBlock(K, ct, off));
107
+ for (var i = 0; i < 8; i++) out[off + i] = dec[i] ^ prev[i];
108
+ prev = ct.subarray(off, off + 8);
109
+ }
110
+ var pad = out[out.length - 1];
111
+ if (pad < 1 || pad > 8 || pad > out.length) throw E(code, "RC2-CBC invalid PKCS#7 padding");
112
+ for (i = 0; i < pad; i++) if (out[out.length - 1 - i] !== pad) throw E(code, "RC2-CBC invalid PKCS#7 padding");
113
+ return out.subarray(0, out.length - pad);
114
+ }
115
+
116
+ // RC2-CBC encrypt with PKCS#7 padding (for the round-trip KAT; pkcs12.open only decrypts).
117
+ function cbcEncrypt(key, effectiveBits, iv, pt) {
118
+ var padLen = 8 - (pt.length % 8), padded = Buffer.concat([pt, Buffer.alloc(padLen, padLen)]);
119
+ var K = _expandKey(key, effectiveBits), out = Buffer.alloc(padded.length), prev = iv;
120
+ for (var off = 0; off < padded.length; off += 8) {
121
+ var x = Buffer.alloc(8);
122
+ for (var i = 0; i < 8; i++) x[i] = padded[off + i] ^ prev[i];
123
+ var enc = _wordsToBytes(_encBlock(K, x, 0));
124
+ enc.copy(out, off);
125
+ prev = enc;
126
+ }
127
+ return out;
128
+ }
129
+
130
+ module.exports = { encryptBlock: encryptBlock, decryptBlock: decryptBlock, cbcDecrypt: cbcDecrypt, cbcEncrypt: cbcEncrypt };
package/lib/smime.js CHANGED
@@ -228,32 +228,195 @@ function _declaresHp(content) {
228
228
  return false;
229
229
  }
230
230
 
231
+ // A fresh not-protected surface (returned when no header protection is present or detection fails soft). A NEW
232
+ // object each call -- the caller Object.assign()s it onto the verify/decrypt result, so it must not be shared.
233
+ // `present` + `protectedHeaders` describe ONLY cryptographically-declared (hp=) protection; `legacy` is null
234
+ // unless an opt-in legacy RFC8551HP inference succeeded (then it carries its own headers/mode/etc. -- see below).
235
+ function _noneSurface() { return { protectedHeaders: null, headerProtection: { present: false, mode: null, fromMismatch: false, confidential: [], legacy: null } }; }
236
+
237
+ // Count the Content-Type fields in a parsed header list. A structure with more than one is AMBIGUOUS: mime.parse
238
+ // surfaces only the FIRST via contentType, so an hp= parameter or a Cryptographic-Layer media type on a LATER
239
+ // field would be invisible to the classification -- both the standard HP path and the legacy path fail closed on it.
240
+ function _contentTypeCount(headers) { var n = 0; headers.forEach(function (h) { if (h.lname === "content-type") n++; }); return n; }
241
+
242
+ // Header fields that must not sensibly repeat: the RFC 5322 sec. 3.6 max-1 originator/destination/identification
243
+ // fields, plus Return-Path (the envelope sender -- RFC 5322 sec. 3.6 groups it under trace, but RFC 5321 sec. 4.4
244
+ // restricts a delivered message to a single one). A duplicate of one of these is a malformed / ambiguous set (a
245
+ // consumer could pick a different sender / subject); a duplicate of any OTHER field (Received and the other
246
+ // trace fields, Resent-*, Comments, Keywords, and optional / X-* fields) is legitimately repeatable in a real
247
+ // message. A Set (not a plain object) so an attacker-chosen field name like "constructor" / "__proto__" cannot
248
+ // match an inherited Object.prototype member and false-trip the singleton reject.
249
+ var RFC5322_SINGLETON = new Set(["date", "from", "sender", "reply-to", "to", "cc", "bcc", "message-id", "in-reply-to", "references", "subject", "return-path"]);
250
+
251
+ // Extract the Non-Structural protected fields from a recovered inner header list. Shared by the standard HP path
252
+ // (over the Cryptographic Payload root) and the legacy path (over the message/rfc822 inner message, sec. 4.10).
253
+ // Returns { protectedHeaders (UTF-8 map, last-wins -- the ergonomic single-value view surfaced by the standard
254
+ // path), entries (an ORDERED array of every { name, value, raw } occurrence -- a real message repeats trace
255
+ // fields, and a last-wins collapse would drop all but one), innerFrom, refouter (HP-Outer records), dup (any
256
+ // duplicate field name), dupSingleton (a duplicate of an RFC 5322 singleton field) }.
257
+ function _extractProtected(headerList) {
258
+ // mime.parse decodes the header block as latin1 (byte-preserving), so a protected value emitted as UTF-8
259
+ // (RFC 6532, the guard permits it) is re-decoded latin1->UTF-8 here to round-trip intact. protectedHeaders
260
+ // surfaces the exact authenticated field body (rawValue: leading/trailing whitespace preserved), NOT a
261
+ // trimmed value that would diverge from the signed octets.
262
+ var protectedHeaders = Object.create(null), innerFrom = null, seen = Object.create(null), dup = null, dupSingleton = null;
263
+ var entries = []; // every Non-Structural { name, value } occurrence, in order -- retains legally-repeated fields
264
+ var refouter = []; // RFC 9788 sec. 4.2.1: the HP-Outer records (name + the value the field had in the outer section)
265
+ headerList.forEach(function (h) {
266
+ if (_isStructural(h.lname)) return;
267
+ if (h.lname === "hp-outer") {
268
+ // RFC 9788 sec. 4.2.1 step 4.i: split the HP-Outer value on the FIRST colon into (name, outer-value)
269
+ // -> refouter. The value is kept BYTE-PRESERVING (latin1), never UTF-8-decoded, because the sec. 4.3.1
270
+ // confidentiality comparison must be octet-exact (a lossy decode maps distinct invalid octets 0x80/0x81
271
+ // to one replacement char, which would mis-classify an obscured field as exposed and drop it from the
272
+ // confidential set). HP-Outer is NEVER surfaced as a protected header nor counted toward the duplicate
273
+ // check (sec. 2.2: it "can appear multiple times"). A valueless HP-Outer (no inner colon) is ignored.
274
+ var ci = h.rawValue.indexOf(":");
275
+ if (ci >= 0) refouter.push({ name: h.rawValue.slice(0, ci).trim().toLowerCase(), value: h.rawValue.slice(ci + 1).trim() });
276
+ return;
277
+ }
278
+ var val = _utf8Header(h.rawValue); // the exact authenticated field body (RFC 6532 UTF-8)
279
+ entries.push({ name: h.name, value: val, raw: h.rawValue }); // EVERY occurrence: `value` (UTF-8) for surfacing, `raw` (latin1) for the octet-exact confidentiality match
280
+ if (seen[h.lname]) { dup = h.name; if (RFC5322_SINGLETON.has(h.lname)) dupSingleton = h.name; }
281
+ seen[h.lname] = 1;
282
+ protectedHeaders[h.name] = val; // SURFACE (map, last-wins -- the ergonomic single-value view; `entries` keeps every occurrence + the octet-exact `raw`)
283
+ // innerFrom uses the byte-preserving value: a lossy UTF-8 decode maps distinct invalid 8-bit sequences (0x80
284
+ // vs 0x81) to the same replacement char, which would let an attacker alter the displayed From without
285
+ // tripping fromMismatch. Compare the raw octets instead.
286
+ if (h.lname === "from") innerFrom = h.value;
287
+ });
288
+ return { protectedHeaders: protectedHeaders, entries: entries, innerFrom: innerFrom, refouter: refouter, dup: dup, dupSingleton: dupSingleton };
289
+ }
290
+
291
+ // RFC 9788 sec. 4.3.1: a protected field occurrence is end-to-end confidential (encrypted-only) unless a
292
+ // `refouter` record carries its EXACT name + value -- i.e. that value was copied verbatim to the visible outer
293
+ // section. The match is per-OCCURRENCE and MULTISET (each outer record accounts for at most one inside
294
+ // occurrence), so a legally-repeated field with one obscured value and one exposed value (Keywords: secret then
295
+ // Keywords: public, outer exposing only "public") still reports the name confidential -- a last-wins collapse
296
+ // would drop the hidden "secret" and let a caller leak it. A field name is confidential if ANY of its
297
+ // occurrences is unexposed. The comparison is octet-exact (latin1 raw), never lossy-decoded. For the standard
298
+ // path `refouter` is the HP-Outer records; for the legacy path it is the actual outer Header Section (part A).
299
+ function _computeConfidential(entries, refouter) {
300
+ // Index the outer occurrences by (normalized name -> value -> available count) so the multiset comparison is
301
+ // O(N + M), not a per-occurrence rescan of the outer array: matching N identical exposed occurrences would
302
+ // otherwise be N(N+1)/2 comparisons -- a DoS an attacker could drive toward the 16 MiB section cap. A two-level
303
+ // Map (never a plain object) so an attacker-chosen name/value like "__proto__" cannot confuse the lookup.
304
+ var byName = new Map();
305
+ refouter.forEach(function (r) {
306
+ var m = byName.get(r.name); if (!m) { m = new Map(); byName.set(r.name, m); }
307
+ m.set(r.value, (m.get(r.value) || 0) + 1);
308
+ });
309
+ var confidential = [], seen = Object.create(null);
310
+ entries.forEach(function (e) {
311
+ var m = byName.get(e.name.toLowerCase()), val = e.raw.trim(), n = (m && m.get(val)) || 0;
312
+ if (n > 0) { m.set(val, n - 1); return; } // an outer occurrence accounts for (exposes) this one
313
+ if (!seen[e.name]) { seen[e.name] = 1; confidential.push(e.name); }
314
+ });
315
+ return confidential;
316
+ }
317
+
318
+ // Inspect EVERY outer From (outerEnt.header returns only the FIRST; an attacker may append a second, forged From
319
+ // that a MUA displays). A mismatch is ANYTHING but exactly one outer From equal to the protected one: a removed
320
+ // outer From, a duplicate, or a differing value all flag tampering of the displayed sender.
321
+ function _computeFromMismatch(innerFrom, outerEnt) {
322
+ var outerFroms = [];
323
+ outerEnt.headers.forEach(function (h) { if (h.lname === "from") outerFroms.push(h.value.trim()); }); // byte-preserving (latin1)
324
+ return innerFrom != null && (outerFroms.length !== 1 || outerFroms[0] !== innerFrom.trim());
325
+ }
326
+
327
+ // Is this (lowercased) media type a Cryptographic Layer (RFC 9788 sec. 4.10.1)? Conservative: any
328
+ // application/pkcs7-mime (incl. an OpenSSL x-pkcs7-mime, or a compressed-data layer) plus multipart/signed and
329
+ // multipart/encrypted. Mis-classifying part D as a layer only degrades legacy detection to none -- the safe direction.
330
+ function _isCryptoLayer(type) {
331
+ // `type` is the already-lowercased media type from mime.parse's _parseStructured; _isPkcs7 guards the
332
+ // null/undefined case internally, so no local `|| ""` fallback is needed (and no dead branch is introduced).
333
+ return _isPkcs7(type, "mime") || type === "multipart/signed" || type === "multipart/encrypted";
334
+ }
335
+
336
+ // The legacy `refouter`: the actual visible outer Header Section (part A) as name+value records, so the sec.
337
+ // 4.3.1 confidentiality comparison runs against the real outer values (a legacy message carries no HP-Outer).
338
+ function _outerRefouter(outerEnt) {
339
+ var r = [];
340
+ outerEnt.headers.forEach(function (h) { if (!_isStructural(h.lname) && h.lname !== "hp-outer") r.push({ name: h.lname, value: h.rawValue.trim() }); });
341
+ return r;
342
+ }
343
+
344
+ // RFC 9788 sec. 4.10 backward compatibility: identify + surface a LEGACY RFC8551HP message -- a Cryptographic
345
+ // Envelope whose payload is a bare message/rfc822 object carrying the real headers, with NO hp= parameter. This
346
+ // is DETECT-ONLY (sec. 4.10: "An MUA MUST NOT generate an RFC8551HP message ... MAY try to render") and OPT-IN
347
+ // (`enabled`). Identification (sec. 4.10.1) is the four conjunctive conditions C1-C4; every failure -- an
348
+ // unparseable inner message, a non-message/rfc822 payload, a nested Cryptographic Layer, an hp= on part D, or an
349
+ // ambiguous/empty protected set -- fails SOFT to none (never throws), because the message is not PRECISELY
350
+ // identified. On identification (sec. 4.10.2) the inner message's (part D's) Non-Structural fields are surfaced,
351
+ // the mode is inferred from the envelope (clear for signed, cipher for encrypted), and the confidential set is
352
+ // derived from the actual outer section (part A). CRUCIAL: a legacy RFC8551HP message is structurally
353
+ // INDISTINGUISHABLE from an ordinary signed/forwarded message/rfc822 (sec. 4.10.2: the inference is "not based
354
+ // on any strong end-to-end guarantees"), so the inferred set is NEVER placed in `protectedHeaders` and NEVER
355
+ // sets `present:true` -- those describe only cryptographically-declared hp= protection a caller may trust. The
356
+ // inference is surfaced ONLY under `headerProtection.legacy` (its own { headers, mode, fromMismatch, confidential }
357
+ // object), so a caller keying trust off `present` / `protectedHeaders` can never mistake an opted-in heuristic --
358
+ // possibly a forwarded attachment -- for this message's authenticated headers; consuming it is an explicit choice.
359
+ function _legacyHpSurface(canon, outerEnt, mode, enabled) {
360
+ if (!enabled) return _noneSurface();
361
+ var partC, partD;
362
+ try {
363
+ // C1 (outermost object is a Cryptographic Layer) holds by construction: _hpSurface only reaches here after a
364
+ // successful, AUTHENTICATED verify/decrypt. C2: the Cryptographic Payload must be a SINGLE message/rfc822 object.
365
+ partC = mime.parse(canon, SmimeError, "smime/bad-header-protection");
366
+ if (partC.contentType.type !== "message/rfc822") return _noneSurface();
367
+ partD = mime.parse(partC.body, SmimeError, "smime/bad-header-protection");
368
+ } catch (_e) { return _noneSurface(); } // an unparseable legacy candidate is not precisely identified -- fail soft
369
+ // The C2-C4 classification reads only the FIRST Content-Type of each part (mime.parse surfaces that one). A
370
+ // duplicate Content-Type on part C or part D is ambiguous -- a later field could carry hp= or a Cryptographic
371
+ // Layer media type the checks below would miss -- so fail soft, matching the standard path's duplicate reject.
372
+ if (_contentTypeCount(partC.headers) > 1 || _contentTypeCount(partD.headers) > 1) return _noneSurface();
373
+ // C4: an hp= on part D means the message is NOT legacy (sec. 4.1: a consumer MUST ignore hp outside the payload
374
+ // root). C3: part D must not itself be a Cryptographic Layer (a genuinely nested signed/encrypted message).
375
+ if (mime.hasParam(partD.contentType.value, "hp")) return _noneSurface();
376
+ if (_isCryptoLayer(partD.contentType.type)) return _noneSurface();
377
+ var ex = _extractProtected(partD.headers);
378
+ // Only a duplicate SINGLETON field (RFC 5322 sec. 3.6) is the ambiguous case -- part D is an ordinary received
379
+ // message whose trace fields (Received, ...) and other repeatable fields legitimately recur, so a repeatable
380
+ // duplicate must NOT reject the inference (else legacy detection fails on essentially all delivered mail).
381
+ if (ex.dupSingleton) return _noneSurface();
382
+ if (!ex.entries.length) return _noneSurface(); // no Non-Structural fields -- nothing to surface
383
+ var confidential = mode === "cipher" ? _computeConfidential(ex.entries, _outerRefouter(outerEnt)) : [];
384
+ // present stays false + protectedHeaders stays null: the inferred set is surfaced ONLY under `legacy` so a
385
+ // caller cannot mistake an opt-in heuristic (indistinguishable from a forwarded message/rfc822) for authenticated
386
+ // headers. `headers` is the ORDERED [{ name, value }] list (every occurrence -- a real message repeats trace
387
+ // fields); the internal `raw` per occurrence is dropped from the public shape.
388
+ var headers = ex.entries.map(function (e) { return { name: e.name, value: e.value }; });
389
+ return { protectedHeaders: null, headerProtection: { present: false, mode: null, fromMismatch: false, confidential: [], legacy: { headers: headers, mode: mode, fromMismatch: _computeFromMismatch(ex.innerFrom, outerEnt), confidential: confidential } } };
390
+ }
391
+
231
392
  // Detect + surface RFC 9788 header protection on a recovered inner entity, FAIL-CLOSED. A payload that does
232
- // not declare hp surfaces protectedHeaders:null. A payload that DECLARES hp is validated: a malformed block,
233
- // an invalid hp value, or an hp mode that CONTRADICTS the cryptographic envelope (expectedMode "clear" for a
234
- // signed message, "cipher" for a decrypted one) throws smime/bad-header-protection -- never a silent
235
- // downgrade. Otherwise the inline Non-Structural fields ARE the authenticated set; a From that differs from
236
- // the untrusted OUTER From is flagged fromMismatch.
237
- function _hpSurface(content, outerEnt, expectedMode, authenticated) {
238
- var none = { protectedHeaders: null, headerProtection: { present: false, mode: null, fromMismatch: false, confidential: [] } };
393
+ // not declare hp surfaces protectedHeaders:null -- UNLESS opts.legacyHeaderProtection is set and it is a legacy
394
+ // RFC8551HP message/rfc822 wrap (sec. 4.10), which _legacyHpSurface then surfaces (opt-in, fail-soft). A payload
395
+ // that DECLARES hp is validated: a malformed block, an invalid hp value, or an hp mode that CONTRADICTS the
396
+ // cryptographic envelope (expectedMode "clear" for a signed message, "cipher" for a decrypted one) throws
397
+ // smime/bad-header-protection -- never a silent downgrade. Otherwise the inline Non-Structural fields ARE the
398
+ // authenticated set; a From that differs from the untrusted OUTER From is flagged fromMismatch.
399
+ function _hpSurface(content, outerEnt, expectedMode, authenticated, legacyEnabled) {
239
400
  // Header protection is an AUTHENTICATED property: surface the inner headers as protected ONLY when the
240
401
  // cryptographic verdict succeeded (a valid signature, or an authenticated-encryption decrypt). An invalid
241
402
  // signature or an unauthenticated (AES-CBC, no integrity) decrypt yields attacker-influenced bytes -- they
242
403
  // are never marked or exposed as protected (a caller must not treat protectedHeaders as a trust signal
243
404
  // unless integrity held).
244
- if (!authenticated) return none;
405
+ if (!authenticated) return _noneSurface();
245
406
  // Detect + parse the CANONICAL entity -- the exact bytes the signature covers. A transport may rewrite a
246
407
  // CRLF fold OR the header/body separator to bare CR/LF; canonicalize repairs them (so verification still
247
408
  // succeeds), and BOTH the hp detection and the parse must run on the repaired bytes, or they diverge from
248
409
  // the signed content -- stripping the signal, or false-rejecting a valid message as an unparseable block.
249
410
  // The returned `content` stays the raw recovered bytes; only this HP inspection uses the canonical copy.
250
411
  var canon = mime.canonicalizeText(content);
251
- if (!_declaresHp(canon)) return none;
412
+ // A payload that does not declare hp is either non-protected (protectedHeaders:null) or a legacy RFC8551HP
413
+ // message/rfc822 wrap the caller opted into detecting (sec. 4.10). Both are mutually exclusive with the
414
+ // standard path below by construction: it runs ONLY when a real hp= is present on the payload root.
415
+ if (!_declaresHp(canon)) return _legacyHpSurface(canon, outerEnt, expectedMode, legacyEnabled);
252
416
  var inner = mime.parse(canon, SmimeError, "smime/bad-header-protection"); // a malformed HP block fails closed
253
417
  // A duplicate Content-Type makes the hp declaration ambiguous (a parser reads params from the FIRST field,
254
418
  // but an hp= may sit on a later one) -- a malformed wrap that fails closed, never a silent downgrade.
255
- var ctCount = 0;
256
- inner.headers.forEach(function (h) { if (h.lname === "content-type") ctCount++; });
419
+ var ctCount = _contentTypeCount(inner.headers);
257
420
  if (ctCount > 1) throw _err("smime/bad-header-protection", "a header-protected payload must carry exactly one Content-Type field (found " + ctCount + ")");
258
421
  // A duplicate hp attribute is ambiguous (mime.parse keeps the LAST value, but a recipient honoring the
259
422
  // first would see a different mode) -- fail closed, like the duplicate Content-Type. Counted by attribute
@@ -275,58 +438,14 @@ function _hpSurface(content, outerEnt, expectedMode, authenticated) {
275
438
  var hp = raw.toLowerCase();
276
439
  if (hp !== "clear" && hp !== "cipher") throw _err("smime/bad-header-protection", "the header-protected payload declares an invalid hp value " + JSON.stringify(raw) + " (only clear / cipher)");
277
440
  if (hp !== expectedMode) throw _err("smime/bad-header-protection", "the payload hp=" + JSON.stringify(hp) + " contradicts the cryptographic envelope (a " + (expectedMode === "cipher" ? "decrypted" : "signed") + " message requires hp=" + JSON.stringify(expectedMode) + ")");
278
- // mime.parse decodes the header block as latin1 (byte-preserving), so a protected value emitted as UTF-8
279
- // (RFC 6532, the guard permits it) is re-decoded latin1->UTF-8 here to round-trip intact. protectedHeaders
280
- // surfaces the exact authenticated field body (rawValue: leading/trailing whitespace preserved), NOT a
281
- // trimmed value that would diverge from the signed octets.
282
- var protectedHeaders = Object.create(null), protectedRaw = Object.create(null), innerFrom = null, seen = Object.create(null), dup = null;
283
- var refouter = []; // RFC 9788 sec. 4.2.1: the HP-Outer records (name + the value the field had in the outer section)
284
- inner.headers.forEach(function (h) {
285
- if (_isStructural(h.lname)) return;
286
- if (h.lname === "hp-outer") {
287
- // RFC 9788 sec. 4.2.1 step 4.i: split the HP-Outer value on the FIRST colon into (name, outer-value)
288
- // -> refouter. The value is kept BYTE-PRESERVING (latin1), never UTF-8-decoded, because the sec. 4.3.1
289
- // confidentiality comparison must be octet-exact (a lossy decode maps distinct invalid octets 0x80/0x81
290
- // to one replacement char, which would mis-classify an obscured field as exposed and drop it from the
291
- // confidential set). HP-Outer is NEVER surfaced as a protected header nor counted toward the duplicate
292
- // check (sec. 2.2: it "can appear multiple times"). A valueless HP-Outer (no inner colon) is ignored.
293
- var ci = h.rawValue.indexOf(":");
294
- if (ci >= 0) refouter.push({ name: h.rawValue.slice(0, ci).trim().toLowerCase(), value: h.rawValue.slice(ci + 1).trim() });
295
- return;
296
- }
297
- if (seen[h.lname]) dup = h.name;
298
- seen[h.lname] = 1;
299
- protectedHeaders[h.name] = _utf8Header(h.rawValue); // SURFACE the exact authenticated field body (RFC 6532 UTF-8)
300
- protectedRaw[h.name] = h.rawValue; // ...but retain the BYTE-PRESERVING (latin1) body for the octet-exact confidentiality comparison
301
- // ...and for the mismatch comparison: a lossy UTF-8 decode maps distinct invalid 8-bit sequences (0x80 vs
302
- // 0x81) to the same replacement char, which would let an attacker alter the displayed From without
303
- // tripping fromMismatch. Compare the raw octets instead.
304
- if (h.lname === "from") innerFrom = h.value;
305
- });
441
+ var ex = _extractProtected(inner.headers);
306
442
  // A duplicate protected field is ambiguous (the last-wins overwrite hides an earlier value a different
307
443
  // parser might select) -- fail closed rather than surface an ambiguous authenticated set.
308
- if (dup) throw _err("smime/bad-header-protection", "a header-protected payload has a duplicate protected header field " + JSON.stringify(dup));
309
- // RFC 9788 sec. 4.3.1: for an ENCRYPTED payload (hp="cipher"), a protected field is end-to-end confidential
310
- // (encrypted-only) unless an HP-Outer record carries its EXACT name + value -- i.e. it was copied verbatim
311
- // to the visible outer section. A field with no matching HP-Outer (removed, or obscured to a different outer
312
- // value) was made confidential. This authenticated set lets a caller reply/forward without leaking it
313
- // (sec. 6.1). Signed-only (clear) payloads carry no HP-Outer, so nothing is confidential.
314
- var confidential = [];
315
- if (hp === "cipher") {
316
- Object.keys(protectedHeaders).forEach(function (name) {
317
- var ln = name.toLowerCase(), val = protectedRaw[name].trim(), exposed = false; // octet-exact (latin1), not lossy-decoded
318
- for (var i = 0; i < refouter.length; i++) { if (refouter[i].name === ln && refouter[i].value === val) { exposed = true; break; } }
319
- if (!exposed) confidential.push(name);
320
- });
321
- }
322
- // Inspect EVERY outer From (outerEnt.header returns only the FIRST; an attacker may append a second, forged
323
- // From that a MUA displays) -- a duplicate outer From, or any that disagrees with the protected From, is flagged.
324
- var outerFroms = [];
325
- outerEnt.headers.forEach(function (h) { if (h.lname === "from") outerFroms.push(h.value.trim()); }); // byte-preserving (latin1), compared octet-for-octet against the protected From
326
- // A mismatch is ANYTHING but exactly one outer From equal to the protected one: a removed outer From (a
327
- // transport/attacker strips it), a duplicate, or a differing value all flag tampering of the displayed sender.
328
- var fromMismatch = innerFrom != null && (outerFroms.length !== 1 || outerFroms[0] !== innerFrom.trim());
329
- return { protectedHeaders: protectedHeaders, headerProtection: { present: true, mode: hp, fromMismatch: fromMismatch, confidential: confidential } };
444
+ if (ex.dup) throw _err("smime/bad-header-protection", "a header-protected payload has a duplicate protected header field " + JSON.stringify(ex.dup));
445
+ // RFC 9788 sec. 4.3.1: for an ENCRYPTED payload (hp="cipher") the confidential set is the fields not copied
446
+ // verbatim to the outer section via an HP-Outer record. Signed-only (clear) payloads carry no HP-Outer.
447
+ var confidential = hp === "cipher" ? _computeConfidential(ex.entries, ex.refouter) : [];
448
+ return { protectedHeaders: ex.protectedHeaders, headerProtection: { present: true, mode: hp, fromMismatch: _computeFromMismatch(ex.innerFrom, outerEnt), confidential: confidential, legacy: null } };
330
449
  }
331
450
 
332
451
  // Map smime opts to cms.sign opts (the S/MIME layer is algorithm-agnostic -- it forwards any signer).
@@ -459,15 +578,22 @@ function _capped(msg) {
459
578
  * chaining a signer certificate to a trust anchor is the caller's `pki.path.validate` step. A `micalg`
460
579
  * that disagrees with the actual digest is advisory unless `opts.strictMicalg` (then `smime/micalg-mismatch`).
461
580
  * If the message is header-protected (RFC 9788), `protectedHeaders` is the AUTHENTICATED inner header set (a
462
- * tampered outer header cannot alter it) and `headerProtection` is `{ present, mode, fromMismatch, confidential }`
463
- * -- `fromMismatch` flags an outer From differing from the protected one; `confidential` lists the protected
464
- * fields the composer kept end-to-end confidential (per the authenticated HP-Outer records, RFC 9788 sec. 4.3;
465
- * only populated for an encrypted `hp="cipher"` payload). A non-protected message reports `protectedHeaders: null`.
466
- * A payload whose declared `hp` is malformed, invalid, or contradicts the envelope fails closed
467
- * (`smime/bad-header-protection`), never a silent downgrade.
581
+ * tampered outer header cannot alter it) and `headerProtection` is `{ present, mode, fromMismatch, confidential, legacy }`
582
+ * -- `present` is `true` only for cryptographically-DECLARED (`hp=`) protection you may trust; `fromMismatch` flags
583
+ * an outer From differing from the protected one; `confidential` lists the protected fields the composer kept
584
+ * end-to-end confidential (per the authenticated HP-Outer records, RFC 9788 sec. 4.3; only for an encrypted
585
+ * `hp="cipher"` payload). A non-protected message reports `protectedHeaders: null`, `present: false`. A payload
586
+ * whose declared `hp` is malformed, invalid, or contradicts the envelope fails closed (`smime/bad-header-protection`),
587
+ * never a silent downgrade. `legacy` is `null` unless `opts.legacyHeaderProtection` detected a legacy RFC 8551
588
+ * `message/rfc822` wrap (see that option), in which case it is its own `{ headers, mode, fromMismatch, confidential }`
589
+ * object (`headers` an ordered `[{ name, value }]` array that retains legally-repeated fields like `Received`) -- a
590
+ * legacy inference is NEVER placed in `protectedHeaders` and never sets `present: true`, because it is
591
+ * indistinguishable from an ordinary forwarded `message/rfc822`, so a caller keying trust off `present` /
592
+ * `protectedHeaders` cannot mistake the opt-in heuristic for authenticated headers.
468
593
  *
469
594
  * @opts certs extra signer certificates (DER `Buffer`s) to match, forwarded to `cms.verify`.
470
595
  * @opts strictMicalg reject a `multipart/signed` whose `micalg` disagrees with the SignerInfo digest.
596
+ * @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.
471
597
  * @example
472
598
  * var res = await pki.smime.verify(smimeMessageBytes);
473
599
  * if (res.valid) { res.content; res.signers[0].sid; }
@@ -485,7 +611,7 @@ async function verify(message, opts) {
485
611
  var inner;
486
612
  try { inner = _toBuf(schemaCms.parse(p7m).encapContentInfo.eContent); }
487
613
  catch (e) { throw _err("smime/bad-mime", "the pkcs7-mime SignedData has no encapsulated content", e); }
488
- return Object.assign({ valid: res.valid, signers: res.signers, form: "pkcs7-mime", content: inner, micalg: null }, _hpSurface(inner, ent, "clear", res.valid));
614
+ 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));
489
615
  }
490
616
  if (ct.type === "multipart/signed") {
491
617
  if (ct.params.protocol && !_isPkcs7(ct.params.protocol, "signature")) throw _err("smime/bad-multipart", "multipart/signed protocol must be application/pkcs7-signature");
@@ -512,7 +638,7 @@ async function verify(message, opts) {
512
638
  if (opts.strictMicalg && micalg && _micalgSet(micalg) !== (_micalgOf(p7s) || "")) {
513
639
  throw _err("smime/micalg-mismatch", "the multipart/signed micalg " + JSON.stringify(micalg) + " disagrees with the SignerInfo digests");
514
640
  }
515
- return Object.assign({ valid: res2.valid, signers: res2.signers, form: "multipart/signed", content: parts[0], micalg: micalg }, _hpSurface(parts[0], ent, "clear", res2.valid));
641
+ 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));
516
642
  }
517
643
  throw _err("smime/unsupported-type", "not a signed S/MIME message (Content-Type " + JSON.stringify(ct.type) + ")");
518
644
  }
@@ -624,14 +750,18 @@ async function encrypt(content, recipients, opts) {
624
750
  * returned as-is for the caller to feed back to `pki.smime.verify` (no auto-recursion). Accepts OpenSSL's
625
751
  * legacy `application/x-pkcs7-mime` and a missing `smime-type`. If the decrypted payload is header-protected
626
752
  * (RFC 9788, `hp="cipher"`), `protectedHeaders` is the recovered REAL inner header set (the values the outer
627
- * Header Confidentiality Policy hid) and `headerProtection` is `{ present, mode, fromMismatch, confidential }`,
628
- * where `confidential` names the fields the composer kept end-to-end confidential (via the authenticated
629
- * HP-Outer records, RFC 9788 sec. 4.3) -- so a caller can reply/forward without leaking them (sec. 6.1); a
630
- * payload whose `hp` is malformed or contradicts the envelope fails closed (`smime/bad-header-protection`).
753
+ * Header Confidentiality Policy hid) and `headerProtection` is `{ present, mode, fromMismatch, confidential, legacy }`,
754
+ * where `present` is `true` only for a declared `hp=` payload, and `confidential` names the fields the composer
755
+ * kept end-to-end confidential (via the authenticated HP-Outer records, RFC 9788 sec. 4.3) -- so a caller can
756
+ * reply/forward without leaking them (sec. 6.1); a payload whose `hp` is malformed or contradicts the envelope
757
+ * fails closed (`smime/bad-header-protection`). `legacy` is `null` unless `opts.legacyHeaderProtection` detected a
758
+ * legacy RFC 8551 `message/rfc822` wrap (its own `{ headers, mode, fromMismatch, confidential }` object -- `headers`
759
+ * an ordered `[{ name, value }]` array -- never merged into `protectedHeaders` / `present`).
631
760
  *
632
761
  * @opts recipientIndex forwarded to cms.decrypt: explicitly select the recipient by index.
633
762
  * @opts maxIterations forwarded to cms.decrypt: lower the PBKDF2 iteration cap (downward only).
634
763
  * @opts strictSmimeType reject a header `smime-type` that disagrees with the CMS body (`smime/smime-type-mismatch`).
764
+ * @opts legacyHeaderProtection opt in to detecting a LEGACY RFC 8551 header-protected message (RFC 9788 sec. 4.10): an encrypted Cryptographic Payload that is a bare `message/rfc822` wrap with no `hp=` parameter. When set, a precisely-identified legacy message surfaces the inner headers under `headerProtection.legacy = { headers, mode: "cipher", fromMismatch, confidential }` -- `headers` an ordered `[{ name, value }]` array (retaining repeated fields), the `confidential` set derived from the actual visible outer Header Section -- NOT under `protectedHeaders`, and `present` stays `false`, since a legacy message is structurally indistinguishable from a forwarded `message/rfc822` (a heuristic; cross-check `legacy.fromMismatch`). Anything not precisely identified reports `legacy: null`. Off by default.
635
765
  * @example
636
766
  * var res = await pki.smime.decrypt(smimeMessageBytes, { key: recipientKeyPkcs8, cert: recipientCertDer });
637
767
  * res.content; // the recovered inner MIME entity
@@ -658,7 +788,7 @@ async function decrypt(message, keyMaterial, opts) {
658
788
  content: res.content, smimeType: smimeType, authenticated: res.authenticated,
659
789
  recipientType: res.recipientType, recipientIndex: res.recipientIndex,
660
790
  contentEncryptionAlgorithm: res.contentEncryptionAlgorithm,
661
- }, _hpSurface(res.content, ent, "cipher", res.authenticated));
791
+ }, _hpSurface(res.content, ent, "cipher", res.authenticated, opts.legacyHeaderProtection === true));
662
792
  }
663
793
 
664
794
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.3.22",
3
+ "version": "0.3.24",
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:68d6e12b-83f8-4815-ae69-9eb16caaf22f",
5
+ "serialNumber": "urn:uuid:342eb0a5-bbfa-46f5-9b51-912a21d97f53",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-26T15:28:15.572Z",
8
+ "timestamp": "2026-07-26T21:06:05.598Z",
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.3.22",
22
+ "bom-ref": "@blamejs/pki@0.3.24",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.3.22",
25
+ "version": "0.3.24",
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.3.22",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.3.24",
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.3.22",
57
+ "ref": "@blamejs/pki@0.3.24",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]