@blamejs/pki 0.3.20 → 0.3.22

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,7 +4,26 @@ All notable changes to `@blamejs/pki` are documented here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
5
5
  project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## v0.3.20 — 2026-07-26
7
+ ## v0.3.22 — 2026-07-26
8
+
9
+ S/MIME header protection ships -- cover the message headers (Subject, From, To, ...) under the CMS signature or encryption with pki.smime, RFC 9788.
10
+
11
+ ### Added
12
+
13
+ - pki.smime.sign / pki.smime.encrypt gain opts.protectHeaders (RFC 9788 header protection) with opts.headers -- an object { Name: value } or an array [{ name, value }] of the Non-Structural fields to protect (Subject / From / To / Date / ...). Signed protection marks the payload hp="clear" and copies the fields to the outer display headers; encrypted protection marks it hp="cipher", inlines the REAL values inside the ciphertext, emits the Header-Confidentiality-Policy-processed outer copies, and embeds the authenticated HP-Outer records that document which fields were left visible outside -- opts.hcp selects "hcp_baseline" (default: obscure Subject to [...], remove Comments/Keywords) or "hcp_no_confidentiality". The CMS crypto is unchanged, so any signer / recipient algorithm carries through. RFC 9788.
14
+ - pki.smime.verify / pki.smime.decrypt return protectedHeaders (the authenticated inner header set, or null when the message is not header-protected) and headerProtection { present, mode, fromMismatch, confidential }. The inner protected headers are surfaced distinctly from the untrusted outer headers, fromMismatch flags an outer From that disagrees with the protected one, and confidential lists the fields the composer kept end-to-end confidential -- computed from the authenticated HP-Outer records so a caller can reply or forward without leaking them. A payload declaring hp that is malformed, carries an invalid value, or contradicts the cryptographic envelope (a signed message claiming hp="cipher") fails closed with smime/bad-header-protection -- never a silent downgrade. RFC 9788.
15
+ - Every MIME header field pki.smime emits routes through a fail-closed header-field guard: a CR / LF / NUL in a field value, or a field name outside RFC 5322 ftext, is rejected with smime/bad-header, so a caller-supplied Subject can never inject a Bcc header or split the message (CWE-93).
16
+
17
+ ## v0.3.21 — 2026-07-26
18
+
19
+ A Certificate Transparency log-list live-fetch client ships -- pki.ct.fetchLogList fetches and verifies the CT log list over HTTPS before trusting a single log.
20
+
21
+ ### Added
22
+
23
+ - pki.ct.fetchLogList(opts) -- fetch the Certificate Transparency log list live and return the trusted-log set only after the detached signature verifies against the caller-pinned distributor key. It GETs opts.url (the log_list.json) and the detached opts.sigUrl (the log_list.sig, by default opts.url with a .json path suffix rewritten to .sig) over the shared pki.transport (or an injected opts.transport), verifies pki.ct.verifyLogListSignature over the raw JSON bytes against opts.signerKey, and only on a valid signature ingests the same bytes through pki.ct.parseLogList -- returning { logs, byLogId, version, timestamp, raw, status, contentType, tls }. No baked-in vendor URL and no baked-in key (both are caller-pinned); explicit TLS trust (an anchor set or an opts.tls.useSystemStore opt-in, rejectUnauthorized always on); each GET is size-capped before verify/parse; every fetch / verify / parse failure is a typed CtError. RFC 6962.
24
+ - pki.ct.parseLogList now also returns the document's version (a string or null) and timestamp (the parsed log_list_timestamp as a Date, or null when absent/unparseable) -- the freshness surface a caller polices, read leniently from the same document. Existing callers of the { logs, byLogId } shape are unaffected.
25
+
26
+ ## v0.3.20 — 2026-07-25
8
27
 
9
28
  PKCS#12 public-key privacy ships -- encrypt a store's contents to a recipient public key with pki.pkcs12.build/open, plus a webcrypto RSA algorithm-name fix.
10
29
 
package/README.md CHANGED
@@ -232,10 +232,10 @@ is callable today; nothing below is a stub.
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
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` |
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. 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 }` (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` |
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
- | `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. Structure decoded, crypto fail-closed — `parseSctList`, `reconstructSignedData`, `verifySct`, `encodeSctList`, `signSct`, `parseLogList`, `verifySctWithLogList`, `verifyLogListSignature` |
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` |
239
239
  | `pki.merkle` | RFC 6962 / RFC 9162 Merkle-tree proof verification — `leafHash` / `nodeHash` / `emptyRootHash` build the domain-separated (0x00 leaf / 0x01 node) SHA-256 tree hashes; `verifyInclusion` folds an audit proof back to a root and `verifyConsistency` reconstructs both the old and new root (the append-only guarantee), each constant-time-compared to a trusted checkpoint root. Fail-closed on bad geometry, sync hashing, transport-free — `leafHash`, `nodeHash`, `emptyRootHash`, `verifyInclusion`, `verifyConsistency` |
240
240
  | `pki.trust` | Mozilla / CCADB trust-store ingestion — `parseCertdata` reads the NSS `certdata.txt` object stream and `parseCcadbCsv` the CCADB CSV export into one identical constraint-carrying anchor shape: the per-purpose trust bits (only `CKT_NSS_TRUSTED_DELEGATOR` grants) and the per-purpose distrust-after dates the bare root list omits. Certificate and trust objects pair by byte-exact issuer + serial (never adjacency) and are cross-checked against the parsed DER, so metadata can never attach to the wrong root; `anchor()` hands an entry to `pki.path.validate({ trustAnchor, checkPurpose })`. Offline, fail-closed, bounded — `parseCertdata`, `parseCcadbCsv`, `anchor` |
241
241
  | `pki.shbs` | Stateful hash-based signature **verification** — HSS/LMS (RFC 8554), carried in X.509 by RFC 9802 and CMS by RFC 9708, profiled by NIST SP 800-208 (CNSA 2.0 firmware signing). `verify` checks an HSS signature (every level must pass) and `verifyLms` a single-tree LMS, over the raw public-key / signature blobs the parsers already surface. Pure public-input SHA-256 / SHAKE256 hashing, a data-driven typecode registry, bounds-before-slice reads; a malformed blob throws a typed `ShbsError`, a well-formed-but-wrong signature returns `false`. **Verify only by design** — stateful signing needs atomic one-time-key state that belongs in an HSM — `verify`, `verifyLms` |
package/lib/constants.js CHANGED
@@ -257,6 +257,10 @@ var LIMITS = {
257
257
  PBKDF2_MAX_SALT: 1024,
258
258
  // A MIME entity / S/MIME message size ceiling -- bounds the header + body decode before a copy.
259
259
  MIME_MAX_BYTES: BYTES.mib(16),
260
+ // RFC 5322 sec. 2.1.1: a header line MUST be <= 998 octets excluding the CRLF. A composer that emits a
261
+ // longer unfolded field line risks a relay re-folding or rejecting the message -- for multipart/signed
262
+ // that re-fold changes the signed bytes and breaks verification -- so an emitted field line is capped here.
263
+ HEADER_LINE_MAX_OCTETS: 998,
260
264
  // The decompressed-output ceiling for CMS CompressedData (RFC 3274) -- bounds the inflate BEFORE the
261
265
  // output is materialized (a decompression-bomb / resource-exhaustion defense, CWE-409); a caller may
262
266
  // tighten it DOWNWARD via opts.maxOutputBytes, never loosen it.
package/lib/ct.js CHANGED
@@ -49,14 +49,17 @@ var oid = require("./oid.js");
49
49
  var webcrypto = require("./webcrypto.js");
50
50
  var validator = require("./validator-all.js");
51
51
  var rfc3339 = require("./rfc3339.js");
52
+ var httpTransport = require("./http-transport.js");
52
53
  var subtle = webcrypto.webcrypto.subtle;
53
54
 
54
55
  var CtError = frameworkError.CtError;
56
+ var PkiError = frameworkError.PkiError;
55
57
  var C = constants;
56
58
 
57
- // (code, message) -> CtError, the factory the composed guards throw through so a
58
- // malformed value keeps the ct/* typed verdict.
59
- function _ctErr(c, m) { return new CtError(c, m); }
59
+ // (code, message[, cause]) -> CtError, the factory the composed guards + the shared transport throw
60
+ // through so a malformed value keeps the ct/* typed verdict (the optional cause carries the underlying
61
+ // transport / evaluation error; existing 2-arg callers pass undefined -- behavior-preserving).
62
+ function _ctErr(c, m, cause) { return new CtError(c, m, cause); }
60
63
 
61
64
  // RFC 5246 sec. 7.4.1.4.1 code points -- 1-byte, NOT OIDs. Surfaced named; an
62
65
  // unknown code surfaces as its numeric byte with a null name (never rejected --
@@ -664,7 +667,7 @@ function _logsAgree(a, b) {
664
667
 
665
668
  /**
666
669
  * @primitive pki.ct.parseLogList
667
- * @signature pki.ct.parseLogList(json, opts?) -> { logs, byLogId }
670
+ * @signature pki.ct.parseLogList(json, opts?) -> { logs, byLogId, version, timestamp }
668
671
  * @since 0.2.28
669
672
  * @status experimental
670
673
  * @spec RFC 6962
@@ -678,9 +681,11 @@ function _logsAgree(a, b) {
678
681
  * `SHA-256(SPKI)` and fail-closed **requires** it equal the stated `log_id` (RFC 6962 sec. 3.2 -- a log
679
682
  * whose stated id disagrees with its key is refused as `ct/log-id-mismatch`), and decodes the `state`
680
683
  * (exactly one of pending/qualified/usable/readonly/retired/rejected) and `temporal_interval`. Returns
681
- * `{ logs, byLogId }` where each log is `{ logId, logIdHex, key, description, url, mmd, operator, state:
682
- * { name, since, trusted, conditional }, temporalInterval, trusted }` and `byLogId` is a null-proto
683
- * `{ logIdHex: log }` map. Every malformed / oversized / mis-bound input is a typed `CtError`.
684
+ * `{ logs, byLogId, version, timestamp }` where each log is `{ logId, logIdHex, key, description, url, mmd,
685
+ * operator, state: { name, since, trusted, conditional }, temporalInterval, trusted }`, `byLogId` is a
686
+ * null-proto `{ logIdHex: log }` map, `version` is the document's version string (or null), and `timestamp`
687
+ * is the parsed `log_list_timestamp` `Date` (or null when absent/unparseable -- the staleness surface, read
688
+ * leniently, never a throw). Every malformed / oversized / mis-bound input is a typed `CtError`.
684
689
  *
685
690
  * @example
686
691
  * var logList = pki.ct.parseLogList(logListJsonBytes);
@@ -716,7 +721,14 @@ function parseLogList(json, opts) {
716
721
  }
717
722
  }
718
723
  }
719
- return { logs: logs, byLogId: byLogId };
724
+ // Surface the document's own version + timestamp additively (the staleness surface the live-fetch client
725
+ // returns for the caller to police freshness). Read LENIENTLY -- a missing / non-string / unparseable
726
+ // scalar yields null, never a new throw, so every existing caller of the { logs, byLogId } shape is
727
+ // behavior-preserving.
728
+ var version = typeof doc.version === "string" ? doc.version : null;
729
+ var timestamp = (typeof doc.log_list_timestamp === "string" && rfc3339.isValid(doc.log_list_timestamp))
730
+ ? rfc3339.parse(doc.log_list_timestamp, _ctErr, "ct/bad-date", "log_list_timestamp") : null;
731
+ return { logs: logs, byLogId: byLogId, version: version, timestamp: timestamp };
720
732
  }
721
733
 
722
734
  // The covered certificate's notAfter for the temporal gate: an explicit opts.certNotAfter, else derived
@@ -843,6 +855,171 @@ async function verifyLogListSignature(json, signature, publicKey) {
843
855
  }
844
856
  }
845
857
 
858
+ // ---- CT log-list live-fetch client (RFC 6962 sec. 3.2; the Chrome/Apple detached log_list.sig model) ----
859
+
860
+ var DEFAULT_FETCH_TIMEOUT = C.TIME.seconds(30);
861
+ var MAX_FETCH_TIMEOUT = C.TIME.seconds(600);
862
+ var KNOWN_FETCH_OPTS = { url: 1, signerKey: 1, sigUrl: 1, transport: 1, tls: 1, headers: 1, timeout: 1, maxResponseBytes: 1, requireJsonContentType: 1 };
863
+
864
+ // Map opts.tls (operator-facing) to the transport request.tls shape. rejectUnauthorized is NOT here -- the
865
+ // transport forces it on unconditionally; the verb never disables server verification.
866
+ function _tlsForFetch(opts) {
867
+ var t = opts.tls || {};
868
+ return { anchors: t.anchors, useSystemStore: t.useSystemStore, cert: t.cert, key: t.key, minVersion: t.minVersion, servername: t.servername, checkServerIdentity: t.checkServerIdentity };
869
+ }
870
+
871
+ // Do the two URLs share an origin (scheme + host + port)? Both args are already-validated absolute URL
872
+ // strings (each came from `new URL(...).href` after the https gate), so the parse cannot fail here.
873
+ function _sameOrigin(a, b) { return new URL(a).origin === new URL(b).origin; }
874
+
875
+ // The response content-type token (before any ;-parameters), lowercased.
876
+ function _fetchCtypeToken(headers) { return String((headers || {})["content-type"] || "").split(";")[0].trim().toLowerCase(); }
877
+
878
+ // Parse a URL and require https BEFORE any request -- runs even when a transport is INJECTED, so an insecure
879
+ // URL never reaches the wire (the CT log list is fetched over TLS; the detached signature is the authenticity
880
+ // gate, but the fetch stays confined to https, consistent with the est/acme clients). A parse failure is
881
+ // ct/bad-url; a non-https scheme is ct/insecure-url.
882
+ function _parseHttpsUrl(u, label) {
883
+ var parsed;
884
+ try { parsed = new URL(String(u)); }
885
+ catch (e) { throw _ctErr("ct/bad-url", "the CT log-list " + label + " did not parse: " + String(u), e); }
886
+ if (parsed.protocol !== "https:") throw _ctErr("ct/insecure-url", "the CT log-list " + label + " must be https, got " + parsed.protocol + " for " + String(u));
887
+ return parsed;
888
+ }
889
+
890
+ // Derive the detached-signature URL from the (already parsed, https) log_list.json URL by the Chrome/Apple
891
+ // convention: the final ".json" path suffix rewritten to ".sig". A path that does NOT end in ".json" cannot
892
+ // yield an unambiguous sig URL, so the caller must pass opts.sigUrl explicitly (guards never guess a value).
893
+ // The derived URL inherits the json URL's https origin.
894
+ function _sigUrlFor(parsed) {
895
+ if (!/\.json$/.test(parsed.pathname)) throw _ctErr("ct/bad-input", "cannot derive the detached-signature URL from a non-.json path (" + JSON.stringify(parsed.pathname) + "); pass opts.sigUrl explicitly");
896
+ var sig = new URL(parsed.href);
897
+ sig.pathname = parsed.pathname.replace(/\.json$/, ".sig");
898
+ return sig.href;
899
+ }
900
+
901
+ // One bounded GET returning { body: Buffer, status, contentType, tls }. The body is re-viewed through the
902
+ // byte guard (a detached backing buffer fails as ct/bad-input, never a silent empty body); a string body
903
+ // falls back to a UTF-8 encode -- the width a real socket already counts -- so the size cap and verify/parse
904
+ // see the same bytes, never a typed array stringified to "48,130,..." garbage. An oversized body ->
905
+ // ct/response-too-large (the post-hoc twin of the transport's streaming abort); a non-200 -> ct/http-error
906
+ // carrying the status; an empty 200 body -> ct/empty-response. Both GETs must succeed before verify.
907
+ function _fetchBody(transport, url, req, label) {
908
+ // Wrap the transport call in a Promise.resolve().then so a SYNCHRONOUS throw from an injected transport
909
+ // becomes a rejection the catch below can type (not an uncaught throw crossing the API boundary).
910
+ return Promise.resolve().then(function () {
911
+ return transport({ method: "GET", url: url, headers: req.headers, tls: req.tls, timeout: req.timeout, maxResponseBytes: req.maxResponseBytes });
912
+ }).then(function (res) {
913
+ res = res || {};
914
+ var h = {};
915
+ Object.keys(res.headers || {}).forEach(function (k) { h[k.toLowerCase()] = res.headers[k]; });
916
+ var body = (Buffer.isBuffer(res.body) || res.body instanceof Uint8Array)
917
+ ? guard.bytes.view(res.body, CtError, "ct/bad-input", label)
918
+ : Buffer.from(String(res.body == null ? "" : res.body), "utf8");
919
+ if (body.length > req.maxResponseBytes) throw _ctErr("ct/response-too-large", "the " + label + " (" + body.length + " bytes) exceeds the " + req.maxResponseBytes + "-byte cap");
920
+ if (res.status !== 200) throw _ctErr("ct/http-error", "the CT server returned HTTP " + JSON.stringify(res.status) + " for the " + label);
921
+ if (body.length === 0) throw _ctErr("ct/empty-response", "the CT server returned a 200 with an empty " + label);
922
+ return { body: body, status: res.status, contentType: _fetchCtypeToken(h), tls: res.tls || null };
923
+ }).catch(function (e) {
924
+ // An already-typed PkiError (the default transport's ct/*, this function's own ct/* verdicts, or a
925
+ // caller-thrown typed error) propagates UNCHANGED; any other throw from an injected transport -- a raw
926
+ // network Error, a synchronous throw -- is wrapped as a typed ct/transport-error so a fetch failure never
927
+ // crosses the API boundary untyped (the documented typed-error contract).
928
+ if (e instanceof PkiError) throw e;
929
+ throw _ctErr("ct/transport-error", "the CT " + label + " request failed in the transport", e);
930
+ });
931
+ }
932
+
933
+ /**
934
+ * @primitive pki.ct.fetchLogList
935
+ * @signature pki.ct.fetchLogList(opts) -> Promise<{ logs, byLogId, version, timestamp, raw, status, contentType, tls }>
936
+ * @since 0.3.21
937
+ * @status experimental
938
+ * @spec RFC 6962
939
+ * @related pki.ct.parseLogList, pki.ct.verifyLogListSignature, pki.ct.verifySctWithLogList
940
+ *
941
+ * Fetch the Certificate Transparency log list live and return the trusted-log set ONLY after the detached
942
+ * signature verifies against the caller-pinned distributor key. It GETs `opts.url` (the `log_list.json`)
943
+ * and the detached `opts.sigUrl` (the `log_list.sig`, by default `opts.url` with a `.json` path suffix
944
+ * rewritten to `.sig`) over the shared, fail-closed `pki.transport` (or an injected `opts.transport`), then
945
+ * verifies the detached signature over the RAW fetched JSON bytes against `opts.signerKey` and only on a
946
+ * strict `true` verdict ingests the SAME bytes through `parseLogList` -- so the client never parses, reads,
947
+ * caches, or surfaces any field of an unverified document (verify-before-parse). No baked-in vendor URL and
948
+ * no baked-in key: the caller pins both out-of-band. Trust is EXPLICIT -- an `opts.tls.anchors` set or an
949
+ * `opts.tls.useSystemStore` opt-in, `rejectUnauthorized` always on. The returned `timestamp` is surfaced
950
+ * (never policed) so the caller enforces its own freshness policy; chaining a resolved log to an SCT is the
951
+ * caller's `verifySctWithLogList` step. Every fetch / verify / parse failure is a typed `CtError`.
952
+ *
953
+ * @opts url REQUIRED -- the `log_list.json` URL; must be https (no baked-in vendor URL).
954
+ * @opts signerKey REQUIRED, PINNED -- the distributor SubjectPublicKeyInfo as a DER Buffer; no baked-in key.
955
+ * @opts sigUrl OPTIONAL -- the detached `log_list.sig` URL (https, must share the log-list URL's origin); default `url` with `.json` -> `.sig` (a non-.json url requires an explicit sigUrl).
956
+ * @opts transport OPTIONAL injectable `transport(request) -> Promise<{status,headers,body}>` (default `pki.transport.https`); the test seam.
957
+ * @opts tls OPTIONAL `{ anchors, useSystemStore, cert, key, minVersion, servername, checkServerIdentity }` threaded to the default transport (ignored when a transport is injected); `rejectUnauthorized` is always on.
958
+ * @opts headers OPTIONAL extra request headers (the request-framing headers are stripped; the verb owns the GET method).
959
+ * @opts timeout OPTIONAL ms budget, default 30s (cap-validated).
960
+ * @opts maxResponseBytes OPTIONAL per-GET size cap, default 4 MiB, tightenable DOWNWARD only.
961
+ * @opts requireJsonContentType OPTIONAL boolean, default false -- opt in to a strict `ct/bad-content-type` gate on the JSON GET.
962
+ * @example
963
+ * // a live distributor uses the default pki.transport.https; here an injected transport returns the pair
964
+ * var r = await pki.ct.fetchLogList({ url: "https://ct.example/log_list.json", signerKey: googleSignerSpki,
965
+ * transport: function (req) {
966
+ * var isSig = /\.sig$/.test(req.url);
967
+ * return Promise.resolve({ status: 200, headers: { "content-type": isSig ? "application/octet-stream" : "application/json" }, body: isSig ? logListSig : logListJsonBytes });
968
+ * } });
969
+ * r.logs[0] && r.logs[0].trusted; // the verified, trusted-log set (the detached signature checked first)
970
+ */
971
+ async function fetchLogList(opts) {
972
+ opts = opts || {};
973
+ Object.keys(opts).forEach(function (k) { if (!KNOWN_FETCH_OPTS[k]) throw _ctErr("ct/bad-input", "unknown opts field " + JSON.stringify(k)); });
974
+ if (opts.signerKey == null) throw _ctErr("ct/bad-input", "opts.signerKey is required -- the caller-pinned CT log-list distributor SPKI (there is no baked-in key)");
975
+ if (opts.url == null) throw _ctErr("ct/bad-input", "opts.url is required -- the log_list.json URL (there is no baked-in vendor URL)");
976
+ // Parse + https-gate both URLs BEFORE any transport call -- the gate runs across the injectable seam so an
977
+ // http URL never reaches even an injected transport (the documented HTTPS guarantee holds either way).
978
+ var jsonParsed = _parseHttpsUrl(opts.url, "URL");
979
+ var jsonUrl = jsonParsed.href;
980
+ var sigUrl = opts.sigUrl != null ? _parseHttpsUrl(opts.sigUrl, "signature URL").href : _sigUrlFor(jsonParsed);
981
+ // The detached signature MUST share the log-list URL's origin. The log-list trust rests on the pinned-key
982
+ // signature, not on where the signature is hosted; requiring one origin means both GETs carry the same
983
+ // per-origin credentials + TLS identity with NO cross-origin leak (an Authorization/Cookie header or an
984
+ // mTLS client certificate configured for the log-list host can never reach a different signature host). A
985
+ // caller with a genuinely separate signature host fetches it itself and composes verifyLogListSignature +
986
+ // parseLogList directly.
987
+ if (!_sameOrigin(jsonUrl, sigUrl)) throw _ctErr("ct/bad-input", "opts.sigUrl must share the log-list URL's origin (" + jsonParsed.origin + "); a cross-origin detached-signature host is not supported");
988
+ var transport = opts.transport;
989
+ if (!transport) {
990
+ var t = opts.tls || {};
991
+ var hasAnchors = t.anchors !== undefined && t.anchors !== null && !(Array.isArray(t.anchors) && t.anchors.length === 0);
992
+ if (!hasAnchors && t.useSystemStore !== true) throw _ctErr("ct/no-trust-anchors", "no explicit trust anchor and tls.useSystemStore not set to true -- refusing an unpinned CT server (RFC 6962 sec. 3.2)");
993
+ transport = httpTransport.https({ E: _ctErr, errPrefix: "ct" });
994
+ }
995
+ var timeout = guard.limits.cap(opts.timeout, "timeout", DEFAULT_FETCH_TIMEOUT, { E: _ctErr, code: "ct/bad-input", min: 1, max: MAX_FETCH_TIMEOUT });
996
+ var maxResponseBytes = guard.limits.cap(opts.maxResponseBytes, "maxResponseBytes", C.LIMITS.CT_LOG_LIST_MAX_BYTES, { E: _ctErr, code: "ct/bad-input", min: 1, max: C.LIMITS.CT_LOG_LIST_MAX_BYTES });
997
+ // Forward custom opts.headers but STRIP the request-framing headers so a caller cannot desync the request
998
+ // framing; the verb owns the GET method (no body).
999
+ var headers = {};
1000
+ Object.keys(opts.headers || {}).forEach(function (k) {
1001
+ var lk = k.toLowerCase();
1002
+ if (lk !== "content-length" && lk !== "transfer-encoding") headers[k] = opts.headers[k];
1003
+ });
1004
+ var req = { headers: headers, tls: _tlsForFetch(opts), timeout: timeout, maxResponseBytes: maxResponseBytes };
1005
+ // Two bounded GETs; BOTH must succeed before verify. The JSON is fetched first (its content-type is the one
1006
+ // opts.requireJsonContentType polices). Both requests carry the same per-origin credentials + TLS identity,
1007
+ // which is sound because the signature shares the log-list origin (gated above).
1008
+ var jsonRes = await _fetchBody(transport, jsonUrl, req, "CT log-list JSON");
1009
+ if (opts.requireJsonContentType === true && jsonRes.contentType !== "application/json") throw _ctErr("ct/bad-content-type", "the CT log-list JSON GET returned content-type " + JSON.stringify(jsonRes.contentType || null) + " (opts.requireJsonContentType is set)");
1010
+ var sigRes = await _fetchBody(transport, sigUrl, req, "CT log-list signature");
1011
+ // VERIFY BEFORE PARSE: the fetched JSON is untrusted until the detached signature verifies over the RAW
1012
+ // bytes against the caller-pinned key. parseLogList runs ONLY on a strict === true verdict, over the SAME
1013
+ // Buffer -- nothing re-serialized between verify and parse.
1014
+ var ok = await verifyLogListSignature(jsonRes.body, sigRes.body, opts.signerKey);
1015
+ if (ok !== true) throw _ctErr("ct/log-list-untrusted", "the CT log-list detached signature did not verify against the pinned distributor key -- the fetched list is untrusted and was not parsed");
1016
+ var parsed = parseLogList(jsonRes.body);
1017
+ return {
1018
+ logs: parsed.logs, byLogId: parsed.byLogId, version: parsed.version, timestamp: parsed.timestamp,
1019
+ raw: { json: jsonRes.body, sig: sigRes.body }, status: jsonRes.status, contentType: jsonRes.contentType, tls: jsonRes.tls,
1020
+ };
1021
+ }
1022
+
846
1023
  module.exports = {
847
1024
  parseSctList: parseSctList,
848
1025
  reconstructSignedData: reconstructSignedData,
@@ -852,6 +1029,7 @@ module.exports = {
852
1029
  parseLogList: parseLogList,
853
1030
  verifySctWithLogList: verifySctWithLogList,
854
1031
  verifyLogListSignature: verifyLogListSignature,
1032
+ fetchLogList: fetchLogList,
855
1033
  HASH_ALGORITHMS: HASH_ALGORITHMS,
856
1034
  SIGNATURE_ALGORITHMS: SIGNATURE_ALGORITHMS,
857
1035
  };
package/lib/guard-all.js CHANGED
@@ -34,6 +34,9 @@
34
34
  // guard.identifier.assertCanonicalOid
35
35
  // -- canonical dotted-decimal OID string form
36
36
  // (canonicalization-divergence defence)
37
+ // guard.header.assertField -- emitted MIME/RFC 5322 header field name +
38
+ // value integrity (CR/LF/NUL header-injection
39
+ // defence, CWE-93)
37
40
  //
38
41
  // Each shape is enforced by a codebase-patterns detector: the characteristic
39
42
  // token of a guard (the Buffer.from(x.buffer, byteOffset) re-view, the
@@ -51,6 +54,7 @@ var name = require("./guard-name");
51
54
  var encoding = require("./guard-encoding");
52
55
  var json = require("./guard-json");
53
56
  var identifier = require("./guard-identifier");
57
+ var header = require("./guard-header");
54
58
 
55
59
  module.exports = {
56
60
  bytes: bytes,
@@ -63,4 +67,5 @@ module.exports = {
63
67
  encoding: encoding,
64
68
  json: json,
65
69
  identifier: identifier,
70
+ header: header,
66
71
  };
@@ -0,0 +1,52 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ //
5
+ // @internal -- no operator-facing namespace. The documented surface is the
6
+ // consumer whose emitted-header integrity composes this guard (pki.smime header
7
+ // protection, RFC 9788).
8
+ //
9
+ // guard-header -- fail-closed header-field integrity for a MIME / RFC 5322 header
10
+ // a composer EMITS. Defends the HEADER INJECTION class (CWE-93): a caller-supplied
11
+ // header field name or value carrying a CR (0x0d) / LF (0x0a) / NUL (0x00) could
12
+ // inject an extra header (a Bcc, an HP-Outer record), split the message, or forge
13
+ // a multipart boundary; a field name outside RFC 5322 ftext could break the
14
+ // "Name: value" header grammar. It also caps the serialized line length (RFC 5322
15
+ // sec. 2.1.1, 998 octets) so an over-long emitted field cannot be re-folded by a
16
+ // relay into different signed bytes. Every field a composer inlines routes through
17
+ // this one guard, so an emission site cannot re-inline a weaker check and
18
+ // reintroduce the injection.
19
+
20
+ var C = require("./constants");
21
+
22
+ // assertField(name, value, E, code) -> value | throws new E(code, ...)
23
+ // name: RFC 5322 ftext -- printable ASCII [0x21, 0x7e] except ':' (0x3a), non-empty
24
+ // (a space, control byte, or ':' in a field name breaks the "Name: value" grammar).
25
+ // value: reject CR (0x0d) / LF (0x0a) / NUL (0x00) -- the injection bytes; TAB
26
+ // (0x09, folding whitespace) and printable non-ASCII (a UTF-8 header value, RFC
27
+ // 6532) pass. E is the caller's typed-error CLASS, thrown `new E(code, message)`
28
+ // -- the same convention lib/mime.js (this guard's caller) and guard.bytes/text use.
29
+ // @enforced-by behavioral -- the CR/LF/NUL value reject + the ftext name check have
30
+ // no rename-proof code shape distinct from the guard-name control-byte loop / the
31
+ // ASN.1 charset readers; the header-injection RED vectors (a CR / LF / NUL value
32
+ // or a non-ftext field name rejects smime/bad-header) are the guard.
33
+ function assertField(name, value, E, code) {
34
+ if (typeof name !== "string" || name.length === 0) throw new E(code, "a header field name must be a non-empty string");
35
+ for (var i = 0; i < name.length; i++) {
36
+ var nc = name.charCodeAt(i);
37
+ if (nc < 0x21 || nc > 0x7e || nc === 0x3a) throw new E(code, "a header field name must be RFC 5322 ftext (printable ASCII, no space / ':' / control): " + JSON.stringify(name));
38
+ }
39
+ var v = typeof value === "string" ? value : String(value);
40
+ for (var j = 0; j < v.length; j++) {
41
+ var vc = v.charCodeAt(j);
42
+ if (vc === 0x00 || vc === 0x0d || vc === 0x0a) throw new E(code, "a header field value must not contain CR / LF / NUL (header injection) in " + JSON.stringify(name));
43
+ }
44
+ // RFC 5322 sec. 2.1.1: the serialized "Name: value" line (UTF-8 octets, excluding CRLF) must not exceed
45
+ // the line limit, or a relay may re-fold it -- silently changing the signed bytes of a protected header.
46
+ if (Buffer.byteLength(name, "utf8") + 2 + Buffer.byteLength(v, "utf8") > C.LIMITS.HEADER_LINE_MAX_OCTETS) {
47
+ throw new E(code, "a header field line exceeds RFC 5322's " + C.LIMITS.HEADER_LINE_MAX_OCTETS + "-octet limit: " + JSON.stringify(name));
48
+ }
49
+ return v;
50
+ }
51
+
52
+ module.exports = { assertField: assertField };
package/lib/mime.js CHANGED
@@ -54,26 +54,53 @@ function _unfoldHeaders(headerText) {
54
54
  // (lowercased) plus its parameters (names lowercased, quotes stripped). Tolerant of extra whitespace.
55
55
  function _parseStructured(headerValue) {
56
56
  var parts = _splitSemicolons(headerValue); // always >= 1 element
57
- var type = parts[0].trim().toLowerCase();
57
+ var type = _stripComments(parts[0]).trim().toLowerCase();
58
58
  var params = {};
59
59
  for (var i = 1; i < parts.length; i++) {
60
- var eq = parts[i].indexOf("=");
60
+ // RFC 5322 CFWS: a comment around the name/value is folding whitespace, not part of either -- strip it
61
+ // so "(note) hp" resolves to the "hp" parameter and a trailing comment does not leak into the value.
62
+ var p = _stripComments(parts[i]);
63
+ var eq = p.indexOf("=");
61
64
  if (eq < 0) continue;
62
- var name = parts[i].slice(0, eq).trim().toLowerCase();
63
- var val = parts[i].slice(eq + 1).trim();
65
+ var name = p.slice(0, eq).trim().toLowerCase();
66
+ var val = p.slice(eq + 1).trim();
64
67
  if (val.length >= 2 && val[0] === '"' && val[val.length - 1] === '"') val = val.slice(1, -1);
65
68
  params[name] = val;
66
69
  }
67
70
  return { value: headerValue.trim(), type: type, params: params };
68
71
  }
69
72
 
73
+ // Remove RFC 5322 comments -- a "(...)" span (nesting; a backslash quoted-pair escapes the next byte) OUTSIDE
74
+ // a double-quoted string. A "(" inside a quoted string is literal qtext, not a comment. Used to drop CFWS
75
+ // from a structured-header token before the name/value is read, so a comment cannot forge or hide a parameter.
76
+ function _stripComments(s) {
77
+ var out = "", inQ = false, depth = 0;
78
+ for (var i = 0; i < s.length; i++) {
79
+ var ch = s[i];
80
+ if (inQ && ch === "\\" && i + 1 < s.length) { out += ch + s[i + 1]; i++; } // quoted-pair inside a quoted string -> keep both
81
+ else if (depth > 0 && ch === "\\" && i + 1 < s.length) { i++; } // quoted-pair inside a comment -> drop both
82
+ else if (ch === '"' && depth === 0) { inQ = !inQ; out += ch; }
83
+ else if (ch === "(" && !inQ) { depth++; }
84
+ else if (ch === ")" && !inQ && depth > 0) { depth--; }
85
+ else if (depth === 0) { out += ch; }
86
+ }
87
+ return out;
88
+ }
89
+
70
90
  // Split on ";" while respecting double-quoted spans (a boundary/protocol value may be quoted).
71
91
  function _splitSemicolons(s) {
72
- var out = [], cur = "", inQ = false;
92
+ var out = [], cur = "", inQ = false, depth = 0;
73
93
  for (var i = 0; i < s.length; i++) {
74
94
  var ch = s[i];
75
- if (ch === '"') { inQ = !inQ; cur += ch; }
76
- else if (ch === ";" && !inQ) { out.push(cur); cur = ""; }
95
+ // RFC 5322 quoted-pair: inside a quoted-string OR a comment a backslash escapes the next char (so an
96
+ // escaped `"` / `)` does not end the span, and a `;` in an escaped value does not split) -- keep both bytes.
97
+ if ((inQ || depth > 0) && ch === "\\" && i + 1 < s.length) { cur += ch + s[i + 1]; i++; }
98
+ else if (ch === '"' && depth === 0) { inQ = !inQ; cur += ch; }
99
+ // RFC 5322 comment: parentheses (outside a quoted-string) delimit a nesting comment -- a `;` or `=` inside
100
+ // one is CFWS, not structural, so a Content-Type comment like `charset=x (note; hp=y)` cannot forge a parameter.
101
+ else if (ch === "(" && !inQ) { depth++; cur += ch; }
102
+ else if (ch === ")" && !inQ && depth > 0) { depth--; cur += ch; }
103
+ else if (ch === ";" && !inQ && depth === 0) { out.push(cur); cur = ""; }
77
104
  else cur += ch;
78
105
  }
79
106
  out.push(cur);
@@ -97,7 +124,13 @@ function parse(input, E, code) {
97
124
  for (var i = 0; i < rawHeaders.length; i++) {
98
125
  var colon = rawHeaders[i].indexOf(":");
99
126
  if (colon < 0) throw new E(code, "a MIME header line has no colon: " + JSON.stringify(rawHeaders[i].slice(0, 40)));
100
- headers.push({ name: rawHeaders[i].slice(0, colon).trim(), lname: rawHeaders[i].slice(0, colon).trim().toLowerCase(), value: rawHeaders[i].slice(colon + 1).trim() });
127
+ var nm = rawHeaders[i].slice(0, colon).trim();
128
+ var body = rawHeaders[i].slice(colon + 1);
129
+ // value: the trimmed field body (structured-header parsing wants it trimmed). rawValue: the field body
130
+ // with only the single conventional ": " delimiter space removed -- leading/trailing whitespace INSIDE
131
+ // the body is preserved, so a consumer that must reproduce the exact authenticated field body (S/MIME
132
+ // header protection) is not handed a lossily-trimmed value.
133
+ headers.push({ name: nm, lname: nm.toLowerCase(), value: body.trim(), rawValue: body.replace(/^ /, "") });
101
134
  }
102
135
  function header(name) { var l = name.toLowerCase(); for (var j = 0; j < headers.length; j++) if (headers[j].lname === l) return headers[j].value; return null; }
103
136
  var ctv = header("content-type");
@@ -182,10 +215,68 @@ function _afterLine(buf, from, E, code) {
182
215
  throw new E(code, "a multipart boundary line is not terminated");
183
216
  }
184
217
 
218
+ // Serialize an ordered list of header fields + a body into a canonical MIME entity: each field as
219
+ // "Name: value\r\n" (routed through the header-injection guard so a CR/LF/NUL in a value can never inject a
220
+ // header or split the message), then the blank-line separator, then the body -- all CRLF-canonicalized so
221
+ // the bytes match a CMS signer's/verifier's digest. `fields` is [{ name, value }]; `body` is a byte source
222
+ // (or null/empty). This is the single header/entity emitter -- an inline "Content-Type: ...\r\n" string
223
+ // concatenation is the shape this replaces, so a composer cannot re-inline an unguarded header.
224
+ function buildEntity(fields, body, E, code) {
225
+ var head = "";
226
+ for (var i = 0; i < fields.length; i++) {
227
+ var f = fields[i];
228
+ // Serialize the VALUE the guard validated + returned -- never a second coercion of f.value (a stateful
229
+ // toString() could return CRLF on the second call and inject a header past the check).
230
+ var v = guard.header.assertField(f.name, f.value, E, code);
231
+ head += f.name + ": " + v + "\r\n";
232
+ }
233
+ var bodyBuf = (body == null) ? Buffer.alloc(0) : _buf(body, E, code, "the MIME body");
234
+ // Buffer the head as UTF-8 (an RFC 6532 internationalized header value survives); canonicalizeText is
235
+ // byte-preserving except CR/LF normalization (latin1 round-trip), so those UTF-8 bytes pass through.
236
+ return canonicalizeText(Buffer.concat([Buffer.from(head + "\r\n", "utf8"), bodyBuf]));
237
+ }
238
+
239
+ // Count how many times a parameter named `name` (case-insensitive) appears in a structured header value,
240
+ // QUOTE-AWARE (a `name=` inside a double-quoted value is not a parameter). _parseStructured collapses a
241
+ // duplicate parameter to its last value, so a consumer that must fail closed on a duplicate counts it here.
242
+ function paramCount(headerValue, name) {
243
+ var ln = name.toLowerCase(), n = 0;
244
+ _splitSemicolons(headerValue).forEach(function (part) {
245
+ var p = _stripComments(part); // CFWS around the name (e.g. "(note) hp") is not part of the parameter name
246
+ var eq = p.indexOf("=");
247
+ if (eq >= 0 && p.slice(0, eq).trim().toLowerCase() === ln) n++;
248
+ });
249
+ return n;
250
+ }
251
+
252
+ // Count parameters with this attribute name -- WITH OR WITHOUT a value. paramCount only counts "name=value"
253
+ // parts, so a bare/valueless attribute (e.g. "Content-Type: text/plain; hp") is invisible to it;
254
+ // paramNameCount still sees the attribute name, so a consumer can fail closed on a malformed bare parameter --
255
+ // or on a bare+valued duplicate ("hp; hp=x") that paramCount undercounts -- rather than silently ignore it.
256
+ // Comment- + quoted-string-aware, like paramCount; part 0 is the media type (not a parameter), so attributes
257
+ // start at index 1.
258
+ function paramNameCount(headerValue, name) {
259
+ var ln = name.toLowerCase(), n = 0;
260
+ var parts = _splitSemicolons(headerValue);
261
+ for (var i = 1; i < parts.length; i++) {
262
+ var p = _stripComments(parts[i]);
263
+ var eq = p.indexOf("=");
264
+ if ((eq >= 0 ? p.slice(0, eq) : p).trim().toLowerCase() === ln) n++;
265
+ }
266
+ return n;
267
+ }
268
+
269
+ // Does the structured header value name this parameter attribute at all (with or without a value)?
270
+ function hasParam(headerValue, name) { return paramNameCount(headerValue, name) > 0; }
271
+
185
272
  module.exports = {
186
273
  parse: parse,
187
274
  canonicalize: canonicalize,
188
275
  canonicalizeText: canonicalizeText,
189
276
  splitMultipart: splitMultipart,
277
+ buildEntity: buildEntity,
278
+ paramCount: paramCount,
279
+ hasParam: hasParam,
280
+ paramNameCount: paramNameCount,
190
281
  CRLF: CRLF,
191
282
  };
package/lib/smime.js CHANGED
@@ -62,15 +62,273 @@ var MICALG = { sha1: "sha-1", sha224: "sha-224", sha256: "sha-256", sha384: "sha
62
62
  function _entityBytes(content, opts) {
63
63
  var raw = guard.bytes.view(content, SmimeError, "smime/bad-input", "content");
64
64
  if (opts.entity) return mime.canonicalize(raw, SmimeError, "smime/bad-mime");
65
- var ct = opts.contentType || "text/plain; charset=utf-8";
66
65
  // Declare the honest Content-Transfer-Encoding: "7bit" requires every byte <= 127 (RFC 2045); a body
67
66
  // with any 8-bit byte is "8bit". Declaring 7bit for 8-bit content is a false claim a transport could act on.
68
67
  var cte = _is7bit(raw) ? "7bit" : "8bit";
69
- var head = Buffer.from("Content-Type: " + ct + "\r\nContent-Transfer-Encoding: " + cte + "\r\n\r\n", "latin1");
70
- return mime.canonicalize(Buffer.concat([head, raw]), SmimeError, "smime/bad-mime");
68
+ // Build through the guarded serializer so a CRLF / NUL in opts.contentType cannot inject a header field.
69
+ return mime.buildEntity([{ name: "Content-Type", value: opts.contentType || "text/plain; charset=utf-8" }, { name: "Content-Transfer-Encoding", value: cte }], raw, SmimeError, "smime/bad-header");
71
70
  }
72
71
  function _is7bit(buf) { for (var i = 0; i < buf.length; i++) if (buf[i] > 0x7f) return false; return true; }
73
72
 
73
+ // ---- RFC 9788 header protection (an opts.protectHeaders-gated MIME transform over the shipped CMS path) ----
74
+ // The protected header fields are inlined on the Cryptographic Payload root (its Content-Type gains an `hp`
75
+ // parameter: "clear" for signed-only, "cipher" for encrypted); the outer display headers are copied as-is
76
+ // (signed) or passed through a Header Confidentiality Policy (encrypted). The CMS crypto is UNCHANGED.
77
+
78
+ // Is this (lowercased) header field name Structural? RFC 9787 sec. 1.1.1 defines a Structural Header Field as
79
+ // MIME-Version OR any field whose name begins with "Content-" (Content-Type / -Transfer-Encoding /
80
+ // -Disposition / -ID / -Description / -Language / -Location / ...). These describe the specific MIME part --
81
+ // the library sets them -- so they are never protected as Non-Structural fields (rejected at the producer) nor
82
+ // surfaced in the authenticated protectedHeaders set on verify. The prefix rule is the RFC definition itself,
83
+ // so it matches EVERY Content-* field (not just an enumerated subset) and is not a prototype-key lookup (a
84
+ // field named "constructor" / "toString" is simply not Structural).
85
+ function _isStructural(lname) {
86
+ return lname === "mime-version" || lname.indexOf("content-") === 0;
87
+ }
88
+
89
+ // Normalize opts.headers (an object { Name: value } or an array [{ name, value }]) to an ordered list. A
90
+ // malformed shape is a config-time smime/bad-input (the value's byte integrity is guarded at emission).
91
+ function _hpHeaderList(headers) {
92
+ if (headers == null) return [];
93
+ var out = [];
94
+ if (Array.isArray(headers)) {
95
+ headers.forEach(function (h) {
96
+ if (!h || typeof h !== "object") throw _err("smime/bad-input", "each opts.headers entry must be { name, value }");
97
+ // Read name + value ONCE each -- an accessor / Proxy could return a different value on a second read, so
98
+ // the snapshot (not the live property) is what both the signed inner header and the outer display copy use.
99
+ var name = h.name, v = h.value;
100
+ if (typeof name !== "string") throw _err("smime/bad-input", "each opts.headers entry must be { name, value }");
101
+ out.push({ name: name, value: v == null ? "" : String(v) });
102
+ });
103
+ } else if (typeof headers === "object") {
104
+ Object.keys(headers).forEach(function (k) { var v = headers[k]; out.push({ name: k, value: v == null ? "" : String(v) }); });
105
+ } else {
106
+ throw _err("smime/bad-input", "opts.headers must be an object or an array of { name, value }");
107
+ }
108
+ // Only Non-Structural fields are protected (RFC 9787): a Structural header (Content-Type, MIME-Version, ...)
109
+ // describes the MIME structure -- the library sets it -- so a caller passing one would duplicate it on the
110
+ // payload root. And a REPEATED field name is rejected here at the PRODUCER: a repeated protected field is an
111
+ // unsupported shape (verify fails closed on the ambiguity), so the library never emits a message it cannot
112
+ // re-consume. Reject both rather than emit a malformed / self-inconsistent entity.
113
+ var seen = Object.create(null);
114
+ out.forEach(function (h) {
115
+ var ln = h.name.toLowerCase();
116
+ if (_isStructural(ln)) throw _err("smime/bad-input", "opts.headers must not include the Structural header " + JSON.stringify(h.name) + " (RFC 9787); only Non-Structural fields (Subject / From / To / ...) are protected");
117
+ // HP-Outer is a reserved field the library emits itself (RFC 9788 sec. 2.2) to document outer-header
118
+ // confidentiality; a caller-supplied one would be mistaken for that machinery, so reject it.
119
+ if (ln === "hp-outer") throw _err("smime/bad-input", "opts.headers must not include the reserved HP-Outer field (RFC 9788 sec. 2.2); the library emits it");
120
+ if (seen[ln]) throw _err("smime/bad-input", "opts.headers must not repeat a protected field name (" + JSON.stringify(h.name) + "); a repeated protected field is not supported");
121
+ seen[ln] = 1;
122
+ });
123
+ return out;
124
+ }
125
+
126
+ // The Header Confidentiality Policy for an OUTER header of an encrypted-HP message. hcp_baseline (RFC 9788
127
+ // sec. 3.2.1, the default) obscures Subject to "[...]" and REMOVES Comments / Keywords (null);
128
+ // hcp_no_confidentiality leaves every field visible (opt-in only). This is the sec. 3.2.1 pseudocode
129
+ // VERBATIM: Bcc is intentionally NOT stripped -- RFC 9788 sec. 11.4 explains that removing Bcc during
130
+ // encryption can break deliverability to a Bcc'd recipient, so the choice is left to the caller (omit Bcc
131
+ // from opts.headers to keep it out of the plaintext outer headers).
132
+ function _applyHcp(name, value, hcp) {
133
+ if (hcp === "hcp_no_confidentiality") return value;
134
+ var ln = name.toLowerCase();
135
+ if (ln === "subject") return "[...]";
136
+ if (ln === "comments" || ln === "keywords") return null;
137
+ return value;
138
+ }
139
+
140
+ // The outer display header list: for "clear" (signed) the fields are copied verbatim; for "cipher"
141
+ // (encrypted) each passes through the HCP -- a null result removes the field (it lives only in the ciphertext).
142
+ function _outerHeaderList(list, mode, hcp) {
143
+ var policy = hcp == null ? "hcp_baseline" : hcp;
144
+ if (policy !== "hcp_baseline" && policy !== "hcp_no_confidentiality") throw _err("smime/bad-input", "unknown opts.hcp policy " + JSON.stringify(hcp) + " (only \"hcp_baseline\" or \"hcp_no_confidentiality\")");
145
+ var out = [];
146
+ list.forEach(function (h) {
147
+ if (mode === "cipher") {
148
+ var v = _applyHcp(h.name, h.value, policy);
149
+ if (v !== null) out.push({ name: h.name, value: v });
150
+ } else {
151
+ out.push({ name: h.name, value: h.value });
152
+ }
153
+ });
154
+ return out;
155
+ }
156
+
157
+ // Build the inner Cryptographic Payload for a header-protected message: the caller's body wrapped with its
158
+ // Content-Type (gaining ; hp="<mode>") + Content-Transfer-Encoding + the protected fields inlined, canonical.
159
+ // hpList is the ONE normalized snapshot of opts.headers (see the caller) -- the SAME list feeds the signed/
160
+ // encrypted inner header set here and the outer display copy, so an accessor / Proxy cannot make them diverge.
161
+ // outerList is the HCP-processed outer set (_outerHeaderList) -- for a cipher payload it also drives the
162
+ // HP-Outer records embedded here.
163
+ function _protectedInnerEntity(content, opts, mode, hpList, outerList) {
164
+ // Header protection inlines the protected fields on the payload ROOT it builds, so a caller-supplied
165
+ // complete entity (opts.entity) is an unsupported combination -- reject it rather than silently bury the
166
+ // caller's entity (its own Content-Type + headers) beneath a new default text/plain wrapper.
167
+ if (opts.entity) throw _err("smime/bad-input", "opts.entity is not supported with protectHeaders -- pass raw content + opts.contentType");
168
+ var raw = guard.bytes.view(content, SmimeError, "smime/bad-input", "content");
169
+ // Header protection sets the hp parameter itself; a caller hp in opts.contentType (with OR without a value)
170
+ // would emit two hp attributes (Content-Type: ...; hp; hp="clear"), an unconsumable non-interoperable
171
+ // message -- reject it here (hasParam catches a bare "hp" that paramCount would miss).
172
+ if (opts.contentType != null && mime.hasParam(String(opts.contentType), "hp")) throw _err("smime/bad-input", "opts.contentType must not set the hp parameter -- header protection sets it (hp=" + JSON.stringify(mode) + ")");
173
+ var ct = (opts.contentType || "text/plain; charset=utf-8") + "; hp=\"" + mode + "\"";
174
+ var fields = [{ name: "Content-Type", value: ct }, { name: "Content-Transfer-Encoding", value: _is7bit(raw) ? "7bit" : "8bit" }];
175
+ hpList.forEach(function (h) { fields.push(h); });
176
+ // RFC 9788 sec. 2.2 / sec. 5.2.1 step 5.v: an ENCRYPTED header-protected payload documents inside the
177
+ // ciphertext, as HP-Outer records, every Non-Structural field it deliberately left visible in the outer
178
+ // (unprotected) header section -- "HP-Outer: <name>: <outer-value>". A field the HCP removed has NO HP-Outer
179
+ // record; its absence authenticates that it was made confidential by removal. Signed-only (clear) payloads
180
+ // carry no HP-Outer (sec. 2.2: "not relevant for signed-only messages").
181
+ if (mode === "cipher") {
182
+ outerList.forEach(function (h) { fields.push({ name: "HP-Outer", value: h.name + ": " + h.value }); });
183
+ }
184
+ return mime.buildEntity(fields, raw, SmimeError, "smime/bad-header");
185
+ }
186
+
187
+ // The outer header prefix (display copies + MIME-Version) that precedes the Cryptographic Envelope's
188
+ // Content-Type in a header-protected message. Each field is guard-validated (no CR/LF/NUL injection). list is
189
+ // the SAME HCP-processed outer set the inner entity's HP-Outer records were built from.
190
+ function _outerPrefix(list) {
191
+ var s = "";
192
+ for (var i = 0; i < list.length; i++) {
193
+ guard.header.assertField(list[i].name, list[i].value, SmimeError, "smime/bad-header");
194
+ s += list[i].name + ": " + list[i].value + "\r\n";
195
+ }
196
+ return s + "MIME-Version: 1.0\r\n";
197
+ }
198
+
199
+ // Does the payload header region declare an hp Content-Type parameter? A cheap raw scan (unfold the header
200
+ // block, then look for a Content-Type line carrying an hp= parameter) so a NON-HP payload is never parsed --
201
+ // no swallow -- and only a payload that CLAIMS hp is validated (a malformed such payload then fails closed,
202
+ // never a silent downgrade to unprotected).
203
+ // Re-decode a header value that mime.parse latin1-decoded (byte-preserving) as UTF-8 (RFC 6532), so a
204
+ // non-ASCII protected value round-trips intact -- a latin1 string maps 1:1 to bytes, recovering the emitted UTF-8.
205
+ function _utf8Header(s) { return Buffer.from(s, "latin1").toString("utf8"); }
206
+
207
+ function _declaresHp(content) {
208
+ // content is the CANONICAL entity bytes -- the caller (_hpSurface) runs mime.canonicalizeText ONCE so
209
+ // detection here and the subsequent mime.parse see the identical normalized form (bare CR / LF -> CRLF).
210
+ // Detecting hp on the canonical form -- the bytes the signature covers -- is what stops a fold/line-ending
211
+ // transport rewrite from stripping the hp signal from a still-valid signed message.
212
+ var s = content.toString("latin1");
213
+ var m = s.match(/\r\n\r\n/);
214
+ var head = (m ? s.slice(0, m.index) : s).replace(/\r\n[ \t]+/g, " ");
215
+ // Detect hp by composing mime.paramCount -- the SAME comment- + quoted-string-aware tokenizer the parser
216
+ // uses -- over each Content-Type field, so the probe never diverges from mime.parse: a leading-whitespace
217
+ // or before-colon field name is matched (the parser trims it), while an hp= inside a MIME comment or a
218
+ // quoted value is NOT a parameter (so an ordinary Content-Type cannot opt a message into HP processing). A
219
+ // real hp on ANY Content-Type line is detected; a duplicate Content-Type is rejected downstream.
220
+ var cts = head.match(/^[ \t]*content-type[ \t]*:[^\r\n]*/gim);
221
+ if (!cts) return false;
222
+ for (var i = 0; i < cts.length; i++) {
223
+ // hasParam (not paramCount): a payload that names the hp attribute WITHOUT a value ("...; hp") still
224
+ // CLAIMS header protection -- detect it so _hpSurface fails closed on the malformed declaration rather
225
+ // than silently downgrading it to unprotected.
226
+ if (mime.hasParam(cts[i].replace(/^[ \t]*content-type[ \t]*:/i, ""), "hp")) return true;
227
+ }
228
+ return false;
229
+ }
230
+
231
+ // 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: [] } };
239
+ // Header protection is an AUTHENTICATED property: surface the inner headers as protected ONLY when the
240
+ // cryptographic verdict succeeded (a valid signature, or an authenticated-encryption decrypt). An invalid
241
+ // signature or an unauthenticated (AES-CBC, no integrity) decrypt yields attacker-influenced bytes -- they
242
+ // are never marked or exposed as protected (a caller must not treat protectedHeaders as a trust signal
243
+ // unless integrity held).
244
+ if (!authenticated) return none;
245
+ // Detect + parse the CANONICAL entity -- the exact bytes the signature covers. A transport may rewrite a
246
+ // CRLF fold OR the header/body separator to bare CR/LF; canonicalize repairs them (so verification still
247
+ // succeeds), and BOTH the hp detection and the parse must run on the repaired bytes, or they diverge from
248
+ // the signed content -- stripping the signal, or false-rejecting a valid message as an unparseable block.
249
+ // The returned `content` stays the raw recovered bytes; only this HP inspection uses the canonical copy.
250
+ var canon = mime.canonicalizeText(content);
251
+ if (!_declaresHp(canon)) return none;
252
+ var inner = mime.parse(canon, SmimeError, "smime/bad-header-protection"); // a malformed HP block fails closed
253
+ // A duplicate Content-Type makes the hp declaration ambiguous (a parser reads params from the FIRST field,
254
+ // 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++; });
257
+ if (ctCount > 1) throw _err("smime/bad-header-protection", "a header-protected payload must carry exactly one Content-Type field (found " + ctCount + ")");
258
+ // A duplicate hp attribute is ambiguous (mime.parse keeps the LAST value, but a recipient honoring the
259
+ // first would see a different mode) -- fail closed, like the duplicate Content-Type. Counted by attribute
260
+ // name (bare OR valued) so a bare+valued pair ("hp; hp=x") is caught too, and quote-/comment-aware (an hp=
261
+ // inside a quoted value or comment is not the hp parameter, so a message we emit is never self-rejected).
262
+ if (mime.paramNameCount(inner.contentType.value, "hp") > 1) throw _err("smime/bad-header-protection", "a header-protected payload declares the hp parameter more than once");
263
+ // _declaresHp composes the SAME comment/quoted-string-aware tokenizer (mime.paramCount) mime.parse uses, so
264
+ // reaching here means the single Content-Type carries a real hp parameter (a duplicate is rejected above) --
265
+ // hp is defined. A malformed value fails CLOSED at the mode checks below, never a silent downgrade.
266
+ // The hp keyword values clear / cipher are an enumerated Content-Type parameter -- per RFC 2045 sec. 5.1 a
267
+ // value of this kind is compared case-insensitively for its intended use, so a peer that emits hp="Clear"
268
+ // or hp=CIPHER is still recognized. (We always EMIT lowercase; we ACCEPT any case. The value is inside the
269
+ // signed/encrypted payload, so normalizing it enables no attack -- the mode-contradiction check still runs.)
270
+ // _declaresHp detected the hp attribute (hasParam); a bare "hp" with no value is a malformed HP declaration
271
+ // -- fail closed rather than silently downgrade (mime.parse skips a valueless parameter, so params.hp is
272
+ // undefined here only for that malformed case).
273
+ var raw = inner.contentType.params.hp;
274
+ if (raw === undefined) throw _err("smime/bad-header-protection", "a header-protected payload declares a bare hp parameter with no value (expected hp=\"clear\" or hp=\"cipher\")");
275
+ var hp = raw.toLowerCase();
276
+ 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
+ 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
+ });
306
+ // A duplicate protected field is ambiguous (the last-wins overwrite hides an earlier value a different
307
+ // 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 } };
330
+ }
331
+
74
332
  // Map smime opts to cms.sign opts (the S/MIME layer is algorithm-agnostic -- it forwards any signer).
75
333
  function _cmsSignOpts(opts, detached) {
76
334
  var o = { detached: detached };
@@ -132,29 +390,41 @@ function _base64Body(der) {
132
390
  * - `"pkcs7-mime"` (opaque): one `application/pkcs7-mime; smime-type=signed-data` entity whose base64
133
391
  * body is an ATTACHED CMS SignedData over the canonical entity.
134
392
  * The signed bytes are the entity's RFC 8551 sec. 3.1.1 canonical form (CRLF line endings); the SAME
135
- * canonicalizer runs on verify. Returns the assembled message bytes. Fail-closed with `SmimeError`.
393
+ * canonicalizer runs on verify. With `opts.protectHeaders`, the message is header-protected (RFC 9788): the
394
+ * caller's `opts.headers` are inlined on the Cryptographic Payload root -- its Content-Type gains `hp="clear"`
395
+ * -- so the signature covers them, and copied to the outer display headers; `verify` surfaces the
396
+ * authenticated inner set. Returns the assembled message bytes. Fail-closed with `SmimeError`.
136
397
  *
137
398
  * @opts form `"multipart"` (default) or `"pkcs7-mime"`.
138
399
  * @opts entity treat `content` as a complete MIME entity (default: wrap it as text/plain).
139
400
  * @opts contentType the wrapped entity's Content-Type (default `text/plain; charset=utf-8`).
140
401
  * @opts signingTime a `Date` for the CMS signing-time attribute, or false to omit it.
402
+ * @opts protectHeaders enable RFC 9788 header protection (`hp="clear"`) -- inline `opts.headers` on the signed payload + the outer display headers.
403
+ * @opts headers the Non-Structural fields to protect + display: an object `{ Name: value }` or an array `[{ name, value }]` (Subject / From / To / Date / ...); used with `protectHeaders`.
141
404
  * @example
142
405
  * var msg = await pki.smime.sign(Buffer.from("hello"), [{ cert: signerCertDer, key: signerKeyPkcs8 }]);
143
406
  */
144
407
  async function sign(content, signers, opts) {
145
408
  opts = opts || {};
146
- var entity = _entityBytes(content, opts);
409
+ // RFC 9788 header protection (opts.protectHeaders): the inner Cryptographic Payload gains hp="clear" +
410
+ // the inlined protected fields, and the outer frame carries the display copies. Off by default => the
411
+ // shipped path, byte-for-byte.
412
+ var hp = opts.protectHeaders === true;
413
+ var hpList = hp ? _hpHeaderList(opts.headers) : null; // ONE snapshot for both the signed inner + outer display copies
414
+ var outerList = hp ? _outerHeaderList(hpList, "clear", opts.hcp) : null;
415
+ var entity = hp ? _protectedInnerEntity(content, opts, "clear", hpList, outerList) : _entityBytes(content, opts);
416
+ var outer = hp ? _outerPrefix(outerList) : "";
147
417
  var form = opts.form || "multipart";
148
418
  if (form === "pkcs7-mime") {
149
419
  var p7m = await cms.sign(entity, signers, _cmsSignOpts(opts, false));
150
- var head = Buffer.from("Content-Type: application/pkcs7-mime; smime-type=signed-data; name=smime.p7m\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename=smime.p7m\r\n\r\n", "latin1");
420
+ var head = Buffer.from(outer + "Content-Type: application/pkcs7-mime; smime-type=signed-data; name=smime.p7m\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename=smime.p7m\r\n\r\n", "utf8");
151
421
  return _capped(Buffer.concat([head, _base64Body(_toBuf(p7m)), mime.CRLF]));
152
422
  }
153
423
  if (form !== "multipart") throw _err("smime/bad-input", "form must be \"multipart\" or \"pkcs7-mime\"");
154
424
  var p7s = _toBuf(await cms.sign(entity, signers, _cmsSignOpts(opts, true)));
155
425
  var micalg = _micalgOf(p7s) || "sha-256";
156
426
  var boundary = _boundary();
157
- var head2 = Buffer.from("Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=" + micalg + "; boundary=\"" + boundary + "\"\r\n\r\n", "latin1");
427
+ var head2 = Buffer.from(outer + "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=" + micalg + "; boundary=\"" + boundary + "\"\r\n\r\n", "utf8");
158
428
  var sigPart = Buffer.concat([
159
429
  Buffer.from("Content-Type: application/pkcs7-signature; name=smime.p7s\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename=smime.p7s\r\n\r\n", "latin1"),
160
430
  _base64Body(p7s),
@@ -174,10 +444,10 @@ function _capped(msg) {
174
444
 
175
445
  /**
176
446
  * @primitive pki.smime.verify
177
- * @signature pki.smime.verify(message, opts?) -> Promise<{ valid, signers, form, content, micalg }>
447
+ * @signature pki.smime.verify(message, opts?) -> Promise<{ valid, signers, form, content, micalg, protectedHeaders, headerProtection }>
178
448
  * @since 0.2.25
179
449
  * @status experimental
180
- * @spec RFC 8551, RFC 5652
450
+ * @spec RFC 8551, RFC 5652, RFC 9788
181
451
  * @related pki.smime.sign, pki.cms.verify, pki.path.validate
182
452
  *
183
453
  * Unwrap and verify a signed S/MIME message (RFC 8551), both `multipart/signed` and
@@ -188,6 +458,13 @@ function _capped(msg) {
188
458
  * entity bytes), and the `micalg`. Like `cms.verify`, this returns the cryptographic verdict only --
189
459
  * chaining a signer certificate to a trust anchor is the caller's `pki.path.validate` step. A `micalg`
190
460
  * that disagrees with the actual digest is advisory unless `opts.strictMicalg` (then `smime/micalg-mismatch`).
461
+ * 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.
191
468
  *
192
469
  * @opts certs extra signer certificates (DER `Buffer`s) to match, forwarded to `cms.verify`.
193
470
  * @opts strictMicalg reject a `multipart/signed` whose `micalg` disagrees with the SignerInfo digest.
@@ -208,7 +485,7 @@ async function verify(message, opts) {
208
485
  var inner;
209
486
  try { inner = _toBuf(schemaCms.parse(p7m).encapContentInfo.eContent); }
210
487
  catch (e) { throw _err("smime/bad-mime", "the pkcs7-mime SignedData has no encapsulated content", e); }
211
- return { valid: res.valid, signers: res.signers, form: "pkcs7-mime", content: inner, micalg: null };
488
+ return Object.assign({ valid: res.valid, signers: res.signers, form: "pkcs7-mime", content: inner, micalg: null }, _hpSurface(inner, ent, "clear", res.valid));
212
489
  }
213
490
  if (ct.type === "multipart/signed") {
214
491
  if (ct.params.protocol && !_isPkcs7(ct.params.protocol, "signature")) throw _err("smime/bad-multipart", "multipart/signed protocol must be application/pkcs7-signature");
@@ -235,7 +512,7 @@ async function verify(message, opts) {
235
512
  if (opts.strictMicalg && micalg && _micalgSet(micalg) !== (_micalgOf(p7s) || "")) {
236
513
  throw _err("smime/micalg-mismatch", "the multipart/signed micalg " + JSON.stringify(micalg) + " disagrees with the SignerInfo digests");
237
514
  }
238
- return { valid: res2.valid, signers: res2.signers, form: "multipart/signed", content: parts[0], micalg: micalg };
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));
239
516
  }
240
517
  throw _err("smime/unsupported-type", "not a signed S/MIME message (Content-Type " + JSON.stringify(ct.type) + ")");
241
518
  }
@@ -289,11 +566,18 @@ function _cmsEncryptOpts(opts) {
289
566
  * has ONE form -- opaque `application/pkcs7-mime` with the whole entity base64-encoded. The `smime-type`
290
567
  * is derived from the produced CMS: AES-GCM (the default) yields an AuthEnvelopedData with
291
568
  * `smime-type=authEnveloped-data` (confidentiality AND integrity); a CBC choice yields an EnvelopedData
292
- * with `smime-type=enveloped-data` (confidentiality only -- no integrity, RFC 8551 sec. 3.3). Returns the
293
- * assembled message bytes. Fail-closed with `SmimeError`.
569
+ * with `smime-type=enveloped-data` (confidentiality only -- no integrity, RFC 8551 sec. 3.3). With
570
+ * `opts.protectHeaders`, the message is header-protected (RFC 9788): the REAL `opts.headers` are inlined
571
+ * inside the ciphertext (the payload Content-Type gains `hp="cipher"`), and only the Header-Confidentiality-
572
+ * Policy-processed display copies appear outside -- the default `hcp_baseline` obscures Subject to `[...]`
573
+ * and removes Comments / Keywords, so those values live only in the ciphertext; `decrypt` recovers the real
574
+ * inner set. Returns the assembled message bytes. Fail-closed with `SmimeError`.
294
575
  *
295
576
  * @opts entity treat `content` as a complete MIME entity (default: wrap it as text/plain).
296
577
  * @opts contentType the wrapped entity's MIME Content-Type (default `text/plain; charset=utf-8`).
578
+ * @opts protectHeaders enable RFC 9788 header protection (`hp="cipher"`) -- inline `opts.headers` inside the ciphertext, emit HCP-processed outer copies, and embed the authenticated HP-Outer records (RFC 9788 sec. 2.2) documenting which fields were left visible outside.
579
+ * @opts headers the Non-Structural fields to protect (object `{ Name: value }` or array `[{ name, value }]`); the real values, hidden by the HCP.
580
+ * @opts hcp the Header Confidentiality Policy: `"hcp_baseline"` (default -- obscure Subject, remove Comments/Keywords) or `"hcp_no_confidentiality"` (leave all outer values visible). Per RFC 9788 sec. 3.2.1 / sec. 11.4, `hcp_baseline` deliberately does NOT strip `Bcc` (removing it can break deliverability to a Bcc'd recipient); to keep a blind recipient out of the plaintext outer headers, omit `Bcc` from `opts.headers`.
297
581
  * @opts contentEncryptionAlgorithm forwarded to cms.encrypt: `"aes-256-gcm"` (default) / `"aes-128-gcm"` / `"aes-256-cbc"` / `"aes-128-cbc"`.
298
582
  * @opts oaepHash forwarded: the RSAES-OAEP hash for ktri recipients.
299
583
  * @opts keyIdentifier forwarded: `"issuerAndSerial"` (default) or `"subjectKeyIdentifier"`.
@@ -303,21 +587,30 @@ function _cmsEncryptOpts(opts) {
303
587
  */
304
588
  async function encrypt(content, recipients, opts) {
305
589
  opts = opts || {};
306
- var entity = _entityBytes(content, opts);
590
+ // RFC 9788 header protection (opts.protectHeaders): the inner payload carries hp="cipher" + the REAL
591
+ // headers (inside the ciphertext); the outer frame carries only the Header-Confidentiality-Policy-processed
592
+ // display copies (hcp_baseline obscures Subject to [...], removes Comments/Keywords). Off => shipped path.
593
+ var hp = opts.protectHeaders === true;
594
+ var hpList = hp ? _hpHeaderList(opts.headers) : null; // ONE snapshot for both the encrypted inner + outer display copies
595
+ var outerList = hp ? _outerHeaderList(hpList, "cipher", opts.hcp) : null;
596
+ var entity = hp ? _protectedInnerEntity(content, opts, "cipher", hpList, outerList) : _entityBytes(content, opts);
597
+ // Build the outer prefix (opts.headers + opts.hcp were validated when outerList was computed) BEFORE the
598
+ // expensive cms.encrypt, so a bad policy / header fails closed without doing the crypto.
599
+ var outer = hp ? Buffer.from(_outerPrefix(outerList), "utf8") : Buffer.alloc(0);
307
600
  // Normalize a single descriptor to an array so cms.encrypt always takes the ENVELOPED path (an array of
308
601
  // RecipientInfos), never bare EncryptedData -- which is not an S/MIME construct (no smime-type maps to it).
309
602
  var recips = Array.isArray(recipients) ? recipients : [recipients];
310
603
  var der = _toBuf(await cms.encrypt(entity, recips, _cmsEncryptOpts(opts)));
311
604
  var head = _pkcs7MimeHead(_envelopedTypeOf(der, "smime/bad-mime"), "smime.p7m");
312
- return _capped(Buffer.concat([head, _base64Body(der), mime.CRLF]));
605
+ return _capped(Buffer.concat([outer, head, _base64Body(der), mime.CRLF]));
313
606
  }
314
607
 
315
608
  /**
316
609
  * @primitive pki.smime.decrypt
317
- * @signature pki.smime.decrypt(message, keyMaterial, opts?) -> Promise<{ content, smimeType, authenticated, recipientType, recipientIndex, contentEncryptionAlgorithm }>
610
+ * @signature pki.smime.decrypt(message, keyMaterial, opts?) -> Promise<{ content, smimeType, authenticated, recipientType, recipientIndex, contentEncryptionAlgorithm, protectedHeaders, headerProtection }>
318
611
  * @since 0.2.26
319
612
  * @status experimental
320
- * @spec RFC 8551, RFC 5652, RFC 5083
613
+ * @spec RFC 8551, RFC 5652, RFC 5083, RFC 9788
321
614
  * @related pki.smime.encrypt, pki.cms.decrypt, pki.smime.verify
322
615
  *
323
616
  * Open an encrypted S/MIME message (RFC 8551 sec. 3.3 / sec. 3.4) -- an `application/pkcs7-mime` entity
@@ -329,7 +622,12 @@ async function encrypt(content, recipients, opts) {
329
622
  * oracle-free: every secret-dependent failure collapses to the uniform `cms/decrypt-failed` the CMS layer
330
623
  * emits (this layer only propagates it). A recovered `content` that is itself a signed S/MIME message is
331
624
  * returned as-is for the caller to feed back to `pki.smime.verify` (no auto-recursion). Accepts OpenSSL's
332
- * legacy `application/x-pkcs7-mime` and a missing `smime-type`.
625
+ * legacy `application/x-pkcs7-mime` and a missing `smime-type`. If the decrypted payload is header-protected
626
+ * (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`).
333
631
  *
334
632
  * @opts recipientIndex forwarded to cms.decrypt: explicitly select the recipient by index.
335
633
  * @opts maxIterations forwarded to cms.decrypt: lower the PBKDF2 iteration cap (downward only).
@@ -356,11 +654,11 @@ async function decrypt(message, keyMaterial, opts) {
356
654
  if (opts.recipientIndex !== undefined) cmsOpts.recipientIndex = opts.recipientIndex;
357
655
  if (opts.maxIterations !== undefined) cmsOpts.maxIterations = opts.maxIterations;
358
656
  var res = await cms.decrypt(der, keyMaterial, cmsOpts);
359
- return {
657
+ return Object.assign({
360
658
  content: res.content, smimeType: smimeType, authenticated: res.authenticated,
361
659
  recipientType: res.recipientType, recipientIndex: res.recipientIndex,
362
660
  contentEncryptionAlgorithm: res.contentEncryptionAlgorithm,
363
- };
661
+ }, _hpSurface(res.content, ent, "cipher", res.authenticated));
364
662
  }
365
663
 
366
664
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.3.20",
3
+ "version": "0.3.22",
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:f49c13d7-73ab-43d3-87b6-44d1c4d4d9bf",
5
+ "serialNumber": "urn:uuid:68d6e12b-83f8-4815-ae69-9eb16caaf22f",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-26T05:42:56.294Z",
8
+ "timestamp": "2026-07-26T15:28:15.572Z",
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.20",
22
+ "bom-ref": "@blamejs/pki@0.3.22",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.3.20",
25
+ "version": "0.3.22",
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.20",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.3.22",
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.20",
57
+ "ref": "@blamejs/pki@0.3.22",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]