@blamejs/pki 0.4.13 → 0.4.14
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 +32 -1
- package/README.md +1 -1
- package/lib/cms-decrypt.js +123 -60
- package/lib/cms-encrypt.js +198 -139
- package/lib/hpke.js +94 -21
- package/lib/key.js +24 -6
- package/lib/pbes2.js +36 -7
- package/lib/pkcs12-build.js +69 -16
- package/lib/webcrypto.js +154 -62
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,38 @@ 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.4.
|
|
7
|
+
## v0.4.14 — 2026-08-10
|
|
8
|
+
|
|
9
|
+
Every key-establishment secret this library allocates is now wiped when it stops being needed -- the classical ones too, not only the post-quantum ones.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Raw secret key material is now reached through a single path that clears the copy it hands out, so a new operation cannot obtain that material without the wipe. Behaviour of the public API is unchanged.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- The raw shared secret of an ECDH / X25519 / X448 key agreement is cleared once the derived bits have been produced, including on the exit where the caller asks for the whole secret and on the error when more bits are requested than the curve provides. It was previously left readable for the process lifetime after every key-agreement operation.
|
|
18
|
+
- The AES content-encryption key is cleared after every encrypt and decrypt. The key material was exported into a fresh buffer on each call and never cleared, so an application that encrypted or decrypted repeatedly accumulated a readable copy of each content key. This covers GCM, CBC and CTR in both directions.
|
|
19
|
+
- A key-derivation function now clears the copy it makes of its input keying material. This was already done for HKDF; the X9.63 and PBKDF2 derivations on the same dispatch did not, and the X9.63 one holds the ECDH shared secret of an RFC 5753 key-agreement recipient.
|
|
20
|
+
- The content-encryption key of an enveloped message is cleared once the message is built, and the recovered one is cleared once the content is open. Because that key is wrapped for every recipient, it is cleared exactly once at the end rather than per recipient -- a message with several recipients still opens correctly for each of them.
|
|
21
|
+
- The password-derived key-encryption key of a password recipient, and the password-derived content key of a password-protected EncryptedData, are cleared on both the producing and consuming sides -- including when the password is wrong, which is the path an attacker repeats.
|
|
22
|
+
- When a PKCS#1 v1.5 key-transport unwrap hits a decode fault, the decryptor continues with a fresh random substitute content key so the failure stays indistinguishable from any other bad-key path (RFC 3218). That substitute is now cleared too -- it is allocated only on the failing path, which is the one an attacker drives repeatedly.
|
|
23
|
+
- The message-authentication key of an AuthenticatedData is cleared on both sides -- generated, wrapped for every recipient and then cleared once by the producer, and cleared by the consumer after the MAC and message-digest checks, including when a tampered message fails them.
|
|
24
|
+
- Password-based private-key protection clears the key it derives. pki.key.encrypt / pki.key.decrypt and the shared PBES2 encrypt / decrypt used by PKCS#12 each left the password-derived key readable after use -- the key guarding a private key, which is the most sensitive thing this library encrypts.
|
|
25
|
+
- PKCS#12 integrity clears the password-derived MAC key on both sides -- when a store is built and when its MAC is recomputed to verify it -- and the legacy-PBE decryption arm clears its derived key, which its PBES2 sibling on the same dispatch already did. The PBMAC1 key, shared by both, is cleared as well.
|
|
26
|
+
- HPKE clears the raw Diffie-Hellman output on every DHKEM arm -- base and authenticated, sealing and opening -- including the concatenated form the authenticated modes build from two agreements.
|
|
27
|
+
- A key-derivation function returns an exact-sized buffer the caller wholly owns rather than a window onto a larger accumulator. Where the requested key size is not a multiple of the digest length -- an RC2 key from a SHA-1 block, an X9.63 or HPKE derivation of an odd length -- clearing the returned key previously left the unused tail of the final derived block readable behind it.
|
|
28
|
+
- Key-derivation intermediates are cleared as they are superseded: the HPKE extract and key-schedule pseudorandom keys, and each digest round and input block of the PKCS#12 derivation, whose accumulator is now allocated once at its final size rather than regrown each round (which abandoned an unreachable password-derived copy per iteration).
|
|
29
|
+
- An HPKE recipient clears the shared secret it derives once the key schedule has consumed it, and the single-shot seal / open clear the encryption context they build and discard -- its AEAD key, base nonce and exporter secret. A context obtained from setupS / setupR belongs to the caller and is untouched, so a multi-message exchange is unaffected.
|
|
30
|
+
- A derivation or decryption result is cleared once it has been copied out to the caller. The PBKDF2 and X9.63 outputs, and the RSA-OAEP decryption output -- which for a key-transport recipient is the recovered content key -- were each copied into the returned buffer and then abandoned, leaving key material readable that no caller could reach to clear.
|
|
31
|
+
- A password supplied as a string or Uint8Array is encoded into a buffer this library allocates, and that credential encoding is now cleared once the derivation has consumed it -- previously only a caller-supplied Buffer was handled, and it was handled by leaving it alone, so the common case left the encoded password readable. A caller-supplied Buffer is still borrowed and never written to.
|
|
32
|
+
- The RFC 3211 password key-wrap clears its plaintext intermediates. Both the formatting block built around the content key when wrapping, and the recovered block when unwrapping, held a complete copy of that key and were abandoned -- on the unwrap side including the two validation rejects, which are the paths an attacker induces by tampering with the wrapped key.
|
|
33
|
+
- Wrapping a key clears the plaintext serialization it makes of that key -- the very material the wrap protects -- on the delegated RSA-OAEP / AES-GCM branch as well as AES-KW. HPKE clears the labeled input copy its extract step builds around a shared secret or PSK, and clears the sender secret when setup itself rejects.
|
|
34
|
+
- A password is encoded only after its options validate, so a rejected iteration count or salt cannot abandon a credential copy; the PKCS#12 derivation clears the block-repeated salt and password fills it builds; and the HPKE expand clears each round feedback input, which carries the previous output block.
|
|
35
|
+
- The PKCS#12 password encoding is cleared at every site that builds one -- store integrity on both sides and legacy-PBE decryption -- but only when this library allocated it. A password supplied as a Buffer is passed through that encoder unchanged, so it stays borrowed and is never written to, exactly as on the CMS paths.
|
|
36
|
+
- Deriving a key clears the transient bits it derives once they have been imported into the key object, including when the import itself rejects.
|
|
37
|
+
|
|
38
|
+
## v0.4.13 — 2026-08-09
|
|
8
39
|
|
|
9
40
|
A KEM shared secret and the key it derives are now wiped as soon as they stop being needed -- on the failing path as well as the succeeding one, which is the path an attacker chooses.
|
|
10
41
|
|
package/README.md
CHANGED
|
@@ -232,7 +232,7 @@ is callable today; nothing below is a stub.
|
|
|
232
232
|
| `pki.crl` | RFC 5280 §5 certificate revocation list issuance — `sign(spec, issuer, opts)` builds and signs a `CertificateList`: a `spec` of `thisUpdate` / `nextUpdate`, an optional `crlNumber`, a `revoked` array (each entry a `serialNumber` + `revocationDate` with an optional `reason` or `invalidityDate`), and an optional `extensions` object (authority key identifier, issuing distribution point, delta-CRL indicator, freshest CRL, authority information access) or an array of pre-encoded Extension DER; an `issuer` of `{ cert, key }` or `{ name, publicKey, key }`. The signature algorithm is resolved from the issuer key, so RSA (PKCS#1 v1.5 / PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch. The version is derived from the extension set (v2 when any CRL or entry extension is present, else v1), the outer `signatureAlgorithm` matches `tbsCertList.signature`, an empty revocation list omits the field rather than emitting an empty SEQUENCE, `reasonCode` is an ENUMERATED and `invalidityDate` is always GeneralizedTime, per-extension criticality is fixed by the RFC, and the produced signature is verified under the issuer key before return. `verify(crl, issuer)` checks a CRL signature through the one path-validation signature engine (algorithm-confusion and EdDSA low-order gates included), and `isRevoked(crl, serialNumber)` looks a serial up in the revocation list. Returns DER, or a PEM `X509 CRL` with `opts.pem`; malformed input throws a typed `CrlError`. Parsing stays at `pki.schema.crl.parse` — `sign` / `verify` / `isRevoked` |
|
|
233
233
|
| `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 -- the key may come from the platform's WebCrypto or from a separately-installed copy of this toolkit, and is exported through whichever holds its material (a non-extractable key, or one whose implementation keeps its material out of reach, is refused with that as the reason) -- 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` |
|
|
234
234
|
| `pki.pkcs12` | RFC 7292 / RFC 9579 PKCS#12 (.p12/.pfx) issuance — `build(spec, opts)` assembles a password-integrity store. `spec` is the OpenSSL-style `{ key, cert, ca?, friendlyName?, localKeyId? }` or the full `{ safeContents: [...] }`, where each element is a plaintext or PBES2-encrypted `SafeContents` of key / shroudedKey / cert / crl / secret / nested `safeContents` bags. Keys and certs are validated before wrapping; `friendlyName` (BMPString) and `localKeyId` attributes are single-value. The store is protected by a classic Appendix B HMAC (default, max interop) or an RFC 9579 PBMAC1 (`opts.mac.algorithm`), over SHA-256/384/512, with the shrouded keys and cert safes encrypted under RFC 8018 PBES2 (AES-128/192/256-CBC). Every password is encoded the PKCS#12 way — BMPString+NULL for the classic MAC, UTF-8 for the PBES2 bags and PBMAC1 (what OpenSSL and NSS consume) — so a file it emits opens in OpenSSL and NSS, cross-checked bidirectionally. The MAC is computed over the exact AuthenticatedSafe byte range, a DEFAULT-1 `MacData.iterations` is rejected up front, and the store is re-parsed before return. `verifyMac(pfx, password, opts)` recomputes a store's classic or PBMAC1 MAC over `macedBytes` and constant-time-compares it, throwing on a MAC-less or public-key-integrity store. **Public-key integrity** (`opts.integrity.mode: "public-key"`) wraps the AuthenticatedSafe in a CMS SignedData instead of a MAC — a signature from any `pki.cms.sign` signer (RSA / ECDSA / EdDSA / ML-DSA / SLH-DSA / composite), no MacData (RFC 7292 §4); privacy stays independent, so the `password` still PBES2-encrypts the bags. **Public-key privacy** — per-safe `recipients` (or the `opts.recipientCerts` convenience) wraps a SafeContents as a CMS EnvelopedData (AES-CBC, `id-envelopedData` — never GCM) encrypting it to recipient public keys through the shipped `pki.cms.encrypt` recipient model, restricted to certificate recipients (RSA-OAEP / ECDH / X25519 / X448 / ML-KEM — a password or KEK recipient, which `open` could not reopen, is rejected); all four integrity × privacy combinations are permitted (RFC 7292 §3.1). **Legacy-PBE read** — `open` decrypts the RFC 7292 Appendix C 3DES and RC2 bags an `openssl pkcs12 -legacy` / NSS store uses (RC2 via an in-tree RFC 2268 cipher), so an older store opens; the legacy RC4 schemes are refused. Returns DER or a PEM `PKCS12`; fail-closed with typed `Pkcs12Error`. `open(pfx, password, opts)` reads a store back: it verifies the MAC **first** (a wrong password is the MAC verdict, not a decrypt error), then PBES2-decrypts every privacy safe and shrouded key bag and returns `{ integrityMode, macVerified, signers, keys, certs, crls, secrets }` — keys as re-validated PKCS#8 DER, certs/CRLs/secrets as raw DER, all with `friendlyName`/`localKeyId`, nested safes recursively. A MAC-less store is refused unless `opts.allowUnauthenticated`; a **public-key-integrity store is verified through its CMS SignedData signature first** (`pkcs12/signature-invalid` on failure, the signer surfaced in `signers` but never trust-chained — the caller's `pki.path.validate` step); a legacy-PBE (App. C) store's 3DES / RC2 bags are decrypted (RC4 refused); an `id-envelopedData` (public-key privacy) safe is decrypted with `opts.recipientKey` after the integrity gate (`pkcs12/no-recipient-key` if absent, every recipient-side fault the uniform `pkcs12/decrypt-failed`); a post-integrity decrypt failure is the uniform `pkcs12/decrypt-failed`, and `opts.keys: 'crypto'` imports each key to a `CryptoKey`; it reads what OpenSSL and NSS produce. Parsing stays at `pki.schema.pkcs12.parse` — `build` / `verifyMac` / `open` |
|
|
235
|
-
| `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.
|
|
235
|
+
| `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. Every key-establishment secret the toolkit allocates is wiped once used, on the failing path as well as the succeeding one -- the KEM shared secret and its derived key-encryption key, the raw ECDH / X25519 / X448 agreement secret, a password-derived key-encryption key, and the content-encryption key itself (cleared once the message is complete, since all recipients share it). Caller-supplied key material is never written to (best-effort; NIST SP 800-227 sec. 4.2, RFC 9629 sec. 7). **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` |
|
|
236
236
|
| `pki.smime` | RFC 8551 S/MIME message assembly, verification, encryption, and compression over the CMS layer — `sign(content, signers, opts)` wraps a MIME entity as a signed S/MIME message in either form: `multipart/signed` (clear-signed — the content stays readable in any MUA, a detached CMS SignedData rides alongside as `application/pkcs7-signature` with a matching `micalg`) or `application/pkcs7-mime; smime-type=signed-data` (opaque — the whole entity is a base64 CMS SignedData). The signed bytes are the entity's RFC 8551 §3.1.1 canonical form (CRLF line endings); `verify(message, opts)` unwraps both forms and recomputes over the same canonicalizer, so a transport that re-wraps line endings still verifies and a tampered part fails. `encrypt(content, recipients, opts)` envelopes a MIME entity as an opaque `application/pkcs7-mime` message and `decrypt(message, keyMaterial, opts)` opens one — `smime-type=authEnveloped-data` (AES-GCM, confidentiality and integrity, the default) or `smime-type=enveloped-data` (AES-CBC, confidentiality only, so `decrypt` reports `authenticated: false`, the §3.3 no-integrity caveat); the `smime-type` is derived from the CMS body, not the header, and decryption is fail-closed and oracle-free. The crypto is entirely `pki.cms.sign` / `verify` / `encrypt` / `decrypt` — any RSA / RSASSA-PSS / ECDSA / EdDSA / ML-DSA / SLH-DSA signer and any RSA-OAEP / ECDH / X25519 / X448 / AES-KW / PBKDF2 / ML-KEM recipient carries through (algorithm-agnostic). Like `cms.verify`, `verify` returns the per-signer cryptographic verdict plus the recovered content; chaining a signer to a trust anchor is the caller's `pki.path.validate` step. `compress(content, opts)` / `decompress(message, opts)` add the opaque `application/pkcs7-mime; smime-type=compressed-data; name=smime.p7z` frame (RFC 8551 §3.6, RFC 3274) — a size transform with no integrity/confidentiality (§2.4.5), decompress bounded against a bomb; the recovered content, which may itself be signed or enveloped, is returned for the caller to re-verify. **Header protection** (RFC 9788): `sign` / `encrypt` gain `opts.protectHeaders` — the caller's `opts.headers` are inlined on the Cryptographic Payload root (its Content-Type gains `hp="clear"` signed / `hp="cipher"` encrypted) so the CMS signature/encryption covers them, defeating a transport that rewrites or reads Subject/From/… `verify` / `decrypt` surface the AUTHENTICATED inner set as `protectedHeaders` + `headerProtection { present, mode, fromMismatch, confidential, legacy }` (a tampered outer header cannot alter it; `fromMismatch` flags an outer From that disagrees). Encryption applies a Header Confidentiality Policy — the default `hcp_baseline` obscures the outer Subject to `[...]` and removes Comments/Keywords, so the real values live only in the ciphertext; `decrypt` recovers them. Every emitted header routes through a fail-closed injection guard (a CR/LF/NUL value or a non-ftext name is rejected), and a malformed/contradictory `hp` wrap fails closed (`smime/bad-header-protection`), never a silent downgrade; the CMS crypto is unchanged. Inbound **legacy** RFC 8551 header protection is recognized opt-in: `verify` / `decrypt` with `opts.legacyHeaderProtection` detect a legacy `message/rfc822`-wrapped payload (the RFC 9788 §4.10.1 four-condition identification) and surface the inner message's headers under `headerProtection.legacy = { headers, mode, fromMismatch, confidential }` (`headers` an ordered `[{ name, value }]` array that retains legally-repeated fields like `Received`) — never in `protectedHeaders` and never setting `present: true`. Because a legacy message is structurally indistinguishable from an ordinary forwarded `message/rfc822`, this is an explicit heuristic (§4.10.2, "no strong end-to-end guarantees"): a caller keying trust off `present`/`protectedHeaders` is never misled, and only one that explicitly reads `headerProtection.legacy.headers` (cross-checking `legacy.fromMismatch`) consumes it. Off by default; a nested crypto layer, an inner `hp=`, a non-`message/rfc822` payload, or a duplicate Content-Type reports `legacy: null`. Bidirectionally interoperable with `openssl smime` / `openssl cms`. Fail-closed with typed `smime/*` errors — `sign`, `verify`, `encrypt`, `decrypt`, `compress`, `decompress` |
|
|
237
237
|
| `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` |
|
|
238
238
|
| `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` |
|
package/lib/cms-decrypt.js
CHANGED
|
@@ -78,14 +78,21 @@ async function decryptEnvelopedData(parsed, keyMaterial, opts, contentTypeName)
|
|
|
78
78
|
try {
|
|
79
79
|
_assertSupported(candidates[ci].ri, keyMaterial); // distinct-code reject (MQV / non-KEM ori)
|
|
80
80
|
var cek = await _acquireCek(candidates[ci].ri, keyMaterial, opts); // stage 2 (uniform)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
81
|
+
// The recovered CEK is cleared only AFTER the content is open -- it is the key the next stage
|
|
82
|
+
// needs, so an earlier wipe would break decryption rather than protect it. The returned object
|
|
83
|
+
// is built before the `finally` runs and holds the plaintext, not the key.
|
|
84
|
+
try {
|
|
85
|
+
var content = await _openContent(parsed, eci, cek, ct); // stage 3 (uniform)
|
|
86
|
+
return {
|
|
87
|
+
content: content,
|
|
88
|
+
contentType: eci.contentType, contentTypeName: oid.name(eci.contentType) || eci.contentType,
|
|
89
|
+
recipientType: candidates[ci].ri.type, recipientIndex: candidates[ci].index,
|
|
90
|
+
contentEncryptionAlgorithm: eci.contentEncryptionAlgorithm.name || eci.contentEncryptionAlgorithm.oid,
|
|
91
|
+
authenticated: ct === "authEnvelopedData",
|
|
92
|
+
};
|
|
93
|
+
} finally {
|
|
94
|
+
guard.secret.zeroize(cek, CmsError, "cms/bad-input", "the recovered content-encryption key");
|
|
95
|
+
}
|
|
89
96
|
} catch (e) {
|
|
90
97
|
if (candidates.length === 1) throw e; // one candidate: preserve its exact (distinct or uniform) verdict
|
|
91
98
|
}
|
|
@@ -235,34 +242,40 @@ async function _kariCek(ri, km) {
|
|
|
235
242
|
var kekBytes = WRAP_KEK_LENGTHS[wrapAlg.oid];
|
|
236
243
|
if (!kekBytes) throw _err("cms/unsupported-algorithm", "unsupported kari key-wrap");
|
|
237
244
|
var ukm = ri.ukm || null;
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
245
|
+
// The agreement secret and the KEK derived from it are both allocated here; one `finally` clears
|
|
246
|
+
// whichever branch produced them, including when the unwrap below throws on a tampered key.
|
|
247
|
+
var kek, z, mz;
|
|
248
|
+
try {
|
|
249
|
+
if (_isMont(origSpki)) {
|
|
250
|
+
var mont = _montName(origSpki);
|
|
251
|
+
var recipPriv = await subtle.importKey("pkcs8", keyDer, { name: mont.name }, false, ["deriveBits"]);
|
|
252
|
+
var origPub = await subtle.importKey("spki", origSpki, { name: mont.name }, false, []);
|
|
253
|
+
mz = Buffer.from(await subtle.deriveBits({ name: mont.name, public: origPub }, recipPriv, null));
|
|
254
|
+
if (mz.every(function (x) { return x === 0; })) throw _fail();
|
|
255
|
+
var mzKey = await subtle.importKey("raw", mz, { name: "HKDF" }, false, ["deriveBits"]);
|
|
256
|
+
// RFC 8418 sec. 2.2: a present ukm is used BOTH as the HKDF salt AND as the ECC-CMS-SharedInfo
|
|
257
|
+
// entityUInfo -- mirror the producer so both sides derive the same KEK as a conformant peer.
|
|
258
|
+
kek = Buffer.from(await subtle.deriveBits({ name: "HKDF", hash: mont.hkdf, salt: ukm || Buffer.alloc(0), info: _eccSharedInfo(wrapAlg.name, ukm, kekBytes) }, mzKey, kekBytes * 8));
|
|
259
|
+
} else {
|
|
260
|
+
// RFC 5753 sec. 7.1 permits the originator EC key to omit its curve parameters, inheriting the
|
|
261
|
+
// curve from the recipient's certificate; resolve the curve from the recipient (authoritative)
|
|
262
|
+
// and rebuild the originator SPKI with explicit parameters so importKey can consume it.
|
|
263
|
+
var origAlg = asn1.decode(origSpki).children[0];
|
|
264
|
+
var origHasParams = origAlg.children.length > 1;
|
|
265
|
+
var curveOid = (km.cert != null && _ecCurveFromCert(km.cert)) || (origHasParams ? asn1.read.oid(origAlg.children[1]) : null);
|
|
266
|
+
var curve = curveOid ? CURVE[curveOid] : null;
|
|
267
|
+
if (!curve) throw _err("cms/unsupported-algorithm", "unsupported or missing originator EC curve");
|
|
268
|
+
var origSpkiFull = origHasParams ? origSpki : _withEcCurveParams(origSpki, curveOid);
|
|
269
|
+
var recipEc = await subtle.importKey("pkcs8", keyDer, { name: "ECDH", namedCurve: curve.curve }, false, ["deriveBits"]);
|
|
270
|
+
var origEc = await subtle.importKey("spki", origSpkiFull, { name: "ECDH", namedCurve: curve.curve }, false, []);
|
|
271
|
+
z = Buffer.from(await subtle.deriveBits({ name: "ECDH", public: origEc }, recipEc, null));
|
|
272
|
+
var zKey = await subtle.importKey("raw", z, { name: "X963KDF" }, false, ["deriveBits"]);
|
|
273
|
+
kek = Buffer.from(await subtle.deriveBits({ name: "X963KDF", hash: _x963Hash(scheme), info: _eccSharedInfo(wrapAlg.name, ukm, kekBytes) }, zKey, kekBytes * 8));
|
|
274
|
+
}
|
|
275
|
+
return await _aesKwUnwrap(kek, rek.encryptedKey);
|
|
276
|
+
} finally {
|
|
277
|
+
guard.secret.zeroizeAll([z, mz, kek], CmsError, "cms/bad-input", "the key-agreement shared secret");
|
|
264
278
|
}
|
|
265
|
-
return await _aesKwUnwrap(kek, rek.encryptedKey);
|
|
266
279
|
}
|
|
267
280
|
|
|
268
281
|
// kekri: AES-KW unwrap under the caller-supplied KEK.
|
|
@@ -286,8 +299,21 @@ async function _pwriCek(ri, km, opts) {
|
|
|
286
299
|
// override a built-in name, and a name-matched mode check would then admit a non-CBC inner cipher.
|
|
287
300
|
if (!innerBits || CONTENT_MODE[innerOid] !== "cbc") throw _err("cms/unsupported-algorithm", "unsupported pwri inner cipher");
|
|
288
301
|
var iv = asn1.read.octetString(inner.children[1]);
|
|
289
|
-
|
|
290
|
-
|
|
302
|
+
// The derived KEK is ours and is cleared once the unwrap has consumed it -- on the failing path
|
|
303
|
+
// too, which is the one an attacker induces by tampering with the wrapped key. The caller's
|
|
304
|
+
// password is not touched: passwordBytes returns a caller-supplied Buffer as-is.
|
|
305
|
+
// A string / Uint8Array password is encoded into a buffer THIS toolkit allocated -- a credential
|
|
306
|
+
// copy -- so it is cleared once the derivation has consumed it. A caller-supplied Buffer is
|
|
307
|
+
// borrowed and left intact.
|
|
308
|
+
var pw = pbes2.passwordBytesOwned(km.password, _err, "cms");
|
|
309
|
+
var kek;
|
|
310
|
+
try { kek = nodeCrypto.pbkdf2Sync(pw.bytes, pb.salt, pb.iterations, innerBits / 8, pb.prfNode); }
|
|
311
|
+
finally { if (pw.owned) guard.secret.zeroize(pw.bytes, CmsError, "cms/bad-input", "the password encoding"); }
|
|
312
|
+
try {
|
|
313
|
+
return _pwriUnwrap(kek, ri.encryptedKey, iv, innerBits);
|
|
314
|
+
} finally {
|
|
315
|
+
guard.secret.zeroize(kek, CmsError, "cms/bad-input", "the password-derived key-encryption key");
|
|
316
|
+
}
|
|
291
317
|
}
|
|
292
318
|
|
|
293
319
|
// kemri (ML-KEM ori): decapsulate -> ss, HKDF(CMSORIforKEMOtherInfo) -> KEK, AES-KW unwrap.
|
|
@@ -357,7 +383,14 @@ async function _openContent(parsed, eci, cek, ct) {
|
|
|
357
383
|
if (eci.encryptedContent == null) throw _err("cms/no-encrypted-content", "the message has no encryptedContent (detached; supply it out of band)");
|
|
358
384
|
// A null CEK (v1.5 implicit rejection) or a wrong-length CEK -> a fresh random key of the right
|
|
359
385
|
// length, so the failure surfaces here as the uniform verdict, never earlier.
|
|
360
|
-
|
|
386
|
+
//
|
|
387
|
+
// The substitute is THIS function's allocation, so this function clears it: reassigning the
|
|
388
|
+
// parameter does not change the caller's variable, so the caller's `finally` would wipe the
|
|
389
|
+
// original null / wrong-length value and leave the substitute behind. It is held separately
|
|
390
|
+
// rather than wiping `cek` here, because on the normal path `cek` is the CALLER's recovered key
|
|
391
|
+
// and is still in use after this returns.
|
|
392
|
+
var substitute = null;
|
|
393
|
+
if (cek == null || cek.length !== keyBits / 8) { substitute = nodeCrypto.randomBytes(keyBits / 8); cek = substitute; }
|
|
361
394
|
try {
|
|
362
395
|
if (ct === "authEnvelopedData") {
|
|
363
396
|
var aad = parsed.authAttrsBytes != null ? _explicitSetOf(parsed.authAttrsBytes) : Buffer.alloc(0);
|
|
@@ -369,6 +402,8 @@ async function _openContent(parsed, eci, cek, ct) {
|
|
|
369
402
|
} catch (e) {
|
|
370
403
|
if (e instanceof CmsError && e.code !== "cms/decrypt-failed") throw e;
|
|
371
404
|
throw _fail();
|
|
405
|
+
} finally {
|
|
406
|
+
guard.secret.zeroize(substitute, CmsError, "cms/bad-input", "the implicit-rejection substitute content key");
|
|
372
407
|
}
|
|
373
408
|
}
|
|
374
409
|
function _gcmOpen(cek, nonce, ct, tag, aad, keyBits, icvLen) {
|
|
@@ -416,9 +451,18 @@ async function _decryptPbes2(parsed, eci, km, opts) {
|
|
|
416
451
|
}
|
|
417
452
|
var keyBits = CONTENT_KEYBITS[encOid];
|
|
418
453
|
if (!keyBits) throw _err("cms/unsupported-algorithm", "unsupported PBES2 content cipher " + encOid);
|
|
419
|
-
|
|
454
|
+
// The password-derived content key is ours; the caller's password buffer is not (passwordBytes
|
|
455
|
+
// passes a supplied Buffer straight through) and is left intact.
|
|
456
|
+
// A string / Uint8Array password is encoded into a buffer THIS toolkit allocated -- a credential
|
|
457
|
+
// copy -- so it is cleared once the derivation has consumed it. A caller-supplied Buffer is
|
|
458
|
+
// borrowed and left intact.
|
|
459
|
+
var pwE = pbes2.passwordBytesOwned(km.password, _err, "cms");
|
|
460
|
+
var key;
|
|
461
|
+
try { key = nodeCrypto.pbkdf2Sync(pwE.bytes, pb.salt, pb.iterations, keyBits / 8, pb.prfNode); }
|
|
462
|
+
finally { if (pwE.owned) guard.secret.zeroize(pwE.bytes, CmsError, "cms/bad-input", "the password encoding"); }
|
|
420
463
|
try { return { content: pbes2.cbcDecrypt(key, iv, eci.encryptedContent, keyBits), contentType: eci.contentType, contentTypeName: oid.name(eci.contentType) || eci.contentType, recipientType: "password", recipientIndex: -1, contentEncryptionAlgorithm: oid.name(encOid) || encOid, authenticated: false }; }
|
|
421
464
|
catch (_e) { throw _fail(); }
|
|
465
|
+
finally { guard.secret.zeroize(key, CmsError, "cms/bad-input", "the password-derived content-encryption key"); }
|
|
422
466
|
}
|
|
423
467
|
|
|
424
468
|
// ---- shared helpers (mirror cms-encrypt's builders) ------------------------
|
|
@@ -474,14 +518,21 @@ function _pwriUnwrap(kek, wrapped, iv, keyBits) {
|
|
|
474
518
|
var d1 = nodeCrypto.createDecipheriv(alg, kek, iv2); d1.setAutoPadding(false);
|
|
475
519
|
var pass1 = Buffer.concat([d1.update(wrapped), d1.final()]);
|
|
476
520
|
var d2 = nodeCrypto.createDecipheriv(alg, kek, iv); d2.setAutoPadding(false);
|
|
521
|
+
// body is the recovered plaintext block: it holds the CEK. The caller receives an independent
|
|
522
|
+
// COPY, so the block is cleared on every exit -- including the two validation rejects, which are
|
|
523
|
+
// the paths an attacker induces by tampering with the wrapped key.
|
|
477
524
|
var body = Buffer.concat([d2.update(pass1), d2.final()]);
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
525
|
+
try {
|
|
526
|
+
var count = body[0];
|
|
527
|
+
if (count < 1 || count + 4 > body.length) throw _fail();
|
|
528
|
+
var cek = body.subarray(4, 4 + count);
|
|
529
|
+
var bad = 0;
|
|
530
|
+
for (var j = 0; j < 3; j++) bad |= (body[1 + j] ^ 0xff) ^ cek[j]; // complement check bytes
|
|
531
|
+
if (bad !== 0) throw _fail();
|
|
532
|
+
return Buffer.from(cek);
|
|
533
|
+
} finally {
|
|
534
|
+
guard.secret.zeroizeAll([body, pass1], CmsError, "cms/bad-input", "the PWRI plaintext block");
|
|
535
|
+
}
|
|
485
536
|
}
|
|
486
537
|
// Coverage residual (the unsupported-algorithm throw arm of each lookup below -- _hashW3c,
|
|
487
538
|
// _x963Hash, and _originatorSpki that follow): reachable only from a fully well-formed recipient that
|
|
@@ -607,21 +658,33 @@ async function _verifyAuthenticatedData(parsed, km, opts) {
|
|
|
607
658
|
// ktri/pwri could convey a below-floor (128-bit) key; in EITHER case substitute a fresh random
|
|
608
659
|
// key so the MAC verify still RUNS and fails uniformly -- never a fast-path that distinguishes an
|
|
609
660
|
// invalid/short unwrap from a MAC mismatch (Bleichenbacher / weak-key oracle freedom).
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
661
|
+
// The substitute is held SEPARATELY rather than overwriting macKey: a hostile recipient can
|
|
662
|
+
// unwrap to a non-null key that is merely too short, and assigning over it would drop the only
|
|
663
|
+
// reference to that recovered secret, leaving it readable while the cleanup cleared the random
|
|
664
|
+
// replacement instead. Both are this module's allocations and both are cleared below.
|
|
665
|
+
var macSubstitute = null;
|
|
666
|
+
if (macKey == null || macKey.length < MAC_KEY_MIN_OCTETS) macSubstitute = nodeCrypto.randomBytes(MAC_KEY_MIN_OCTETS);
|
|
667
|
+
// The MAC key is this path's equivalent of the content-encryption key: whether recovered from
|
|
668
|
+
// the recipient entry or substituted, it is cleared once the verify and digest checks are done
|
|
669
|
+
// -- on the failing path too, and the returned object holds a copy of the content, never the key.
|
|
670
|
+
try {
|
|
671
|
+
var key = await subtle.importKey("raw", macSubstitute || macKey, { name: "HMAC", hash: hash }, false, ["verify"]);
|
|
672
|
+
if (!(await subtle.verify({ name: "HMAC" }, key, Buffer.from(parsed.mac), preimage))) throw _fail();
|
|
673
|
+
if (mdCheck) {
|
|
674
|
+
var actual = Buffer.from(await subtle.digest(mdCheck.hash, content));
|
|
675
|
+
if (!actual.equals(mdCheck.declared)) throw _fail(); // sec. 9.3: recompute, never trust the originator's digest
|
|
676
|
+
}
|
|
677
|
+
return {
|
|
678
|
+
content: Buffer.from(content),
|
|
679
|
+
contentType: parsed.encapContentInfo.eContentType, contentTypeName: oid.name(parsed.encapContentInfo.eContentType) || parsed.encapContentInfo.eContentType,
|
|
680
|
+
recipientType: candidates[ci].ri.type, recipientIndex: candidates[ci].index,
|
|
681
|
+
macAlgorithm: macAlg.name || macAlg.oid,
|
|
682
|
+
digestAlgorithm: parsed.digestAlgorithm ? (parsed.digestAlgorithm.name || parsed.digestAlgorithm.oid) : null,
|
|
683
|
+
authenticated: true,
|
|
684
|
+
};
|
|
685
|
+
} finally {
|
|
686
|
+
guard.secret.zeroizeAll([macKey, macSubstitute], CmsError, "cms/bad-input", "the message-authentication key");
|
|
616
687
|
}
|
|
617
|
-
return {
|
|
618
|
-
content: Buffer.from(content),
|
|
619
|
-
contentType: parsed.encapContentInfo.eContentType, contentTypeName: oid.name(parsed.encapContentInfo.eContentType) || parsed.encapContentInfo.eContentType,
|
|
620
|
-
recipientType: candidates[ci].ri.type, recipientIndex: candidates[ci].index,
|
|
621
|
-
macAlgorithm: macAlg.name || macAlg.oid,
|
|
622
|
-
digestAlgorithm: parsed.digestAlgorithm ? (parsed.digestAlgorithm.name || parsed.digestAlgorithm.oid) : null,
|
|
623
|
-
authenticated: true,
|
|
624
|
-
};
|
|
625
688
|
} catch (e) {
|
|
626
689
|
if (candidates.length === 1) throw e; // one candidate: preserve its exact (distinct or uniform) verdict
|
|
627
690
|
}
|