@blamejs/pki 0.4.12 → 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 +50 -0
- package/README.md +1 -1
- package/lib/cms-decrypt.js +162 -65
- package/lib/cms-encrypt.js +223 -150
- package/lib/guard-all.js +2 -0
- package/lib/guard-secret.js +82 -0
- package/lib/hpke.js +94 -21
- package/lib/key.js +24 -6
- package/lib/lint.js +3 -1
- package/lib/oid.js +35 -0
- package/lib/pbes2.js +36 -7
- package/lib/pkcs12-build.js +69 -16
- package/lib/schema-cms.js +3 -3
- package/lib/webcrypto.js +220 -48
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,56 @@ 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.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
|
|
39
|
+
|
|
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.
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- A KEM shared secret and the key-encryption key derived from it are wiped as soon as they stop being needed, satisfying NIST SP 800-227 RS5 / sec. 4.2 and RFC 9629 sec. 7. The wipe runs in a finally, so a decryption that FAILS clears the same buffers a successful one does -- a wipe on the success path alone would preserve the secret in exactly the case an attacker can force. Only buffers this library allocated are cleared; a caller's key material, certificate, and the returned plaintext are never written to, and the plaintext remains usable after the wipe.
|
|
45
|
+
- This is best-effort and is documented as such rather than overstated. The runtime copies a shared secret into places no code can reach -- the decapsulation result on its way out, and again when it is imported as key material -- and may relocate a buffer's backing store. Wiping the copies the library holds shortens the window in which a secret is readable; it does not mean a secret never persists in memory.
|
|
46
|
+
- pki.oid.kemParams resolves an ML-KEM parameter set to its FIPS 203 Table 3 sizes, by dotted OID or by registered name.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- The ML-KEM ciphertext-length check FIPS 203 sec. 7.3 requires of a decapsulating party is now performed by the crypto engine, so a caller reaching decapsulateBits directly is covered rather than only the CMS path that happens to call it today. It reports webcrypto/bad-kem-ciphertext, naming the parameter set and both lengths, where the failure was previously indistinguishable from any other decapsulation fault; a ciphertext whose length is valid for a DIFFERENT parameter set is refused as such rather than treated as merely short. The check is on length ONLY: a correct-length ciphertext that has been tampered with still resolves to a pseudo-random shared secret, because turning that into an error would give an attacker a decryption oracle. No engine detail reaches a CMS caller: a structurally valid message whose decryption fails for any secret-dependent reason still reports the single uniform cms/decrypt-failed verdict. A message whose ML-KEM ciphertext length does not match the parameter set the message itself declares is a separate case and always was -- including a length that would be valid for a different set -- because the strict parser rejects it up front and names it: the mismatch is a structural fault, decidable from the message alone, with nothing about it depending on a key.
|
|
51
|
+
- The ML-KEM parameter sizes resolve from one registry instead of three separate tables in three modules. The encapsulation-key lengths were already duplicated verbatim in two of them, and each new consumer meant another copy that could drift; a parameter set is a property of the algorithm identifier, so it now lives beside the registry that resolves one. Behaviour is unchanged.
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- The roadmap attributed two rules to NIST SP 800-227 that it does not state: implicit rejection and re-encapsulation are FIPS 203's, reached through SP 800-227's requirement to comply with the KEM's own standard, and SP 800-227 sec. 4.3 explicitly permits a shared secret to be used directly, truncated, or split into segments -- the unconditional key-derivation requirement comes from RFC 9629 sec. 5. The entry now states what each document requires.
|
|
56
|
+
|
|
7
57
|
## v0.4.12 — 2026-08-09
|
|
8
58
|
|
|
9
59
|
A CMS message can no longer declare one content cipher and be opened with another: the declared algorithm's mode is now bound to the container that carries it, so an EnvelopedData naming an authenticated cipher is refused rather than opened, unauthenticated, under a result that reported it as authenticated.
|
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. **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.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.
|
|
@@ -308,10 +334,29 @@ async function _kemriCek(ri, km) {
|
|
|
308
334
|
var wrapAlg = k.wrap;
|
|
309
335
|
if (WRAP_KEK_LENGTHS[wrapAlg.oid] !== kekBytes) throw _fail(); // M29 re-check on the consumer path
|
|
310
336
|
var priv = await subtle.importKey("pkcs8", _normKeyDer(km.key), { name: wcName }, false, ["decapsulateBits"]);
|
|
311
|
-
var ss =
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
337
|
+
var ss = null, kek = null, ssAb = null, kekAb = null;
|
|
338
|
+
try {
|
|
339
|
+
// The engine hands back an ArrayBuffer it allocated, and the Buffer below is a copy of it. Both
|
|
340
|
+
// hold the secret, so both are wiped: clearing only the copy this function made would leave the
|
|
341
|
+
// engine's return value readable, which is the same omission one level up.
|
|
342
|
+
ssAb = await subtle.decapsulateBits({ name: wcName }, priv, kemct);
|
|
343
|
+
ss = Buffer.from(ssAb);
|
|
344
|
+
var ssKey = await subtle.importKey("raw", ss, { name: "HKDF" }, false, ["deriveBits"]);
|
|
345
|
+
kekAb = await subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: Buffer.alloc(0), info: _kemOtherInfo(wrapAlg.name, kekBytes, k.ukm || null) }, ssKey, kekBytes * 8);
|
|
346
|
+
kek = Buffer.from(kekAb);
|
|
347
|
+
return await _aesKwUnwrap(kek, k.encryptedKey);
|
|
348
|
+
} finally {
|
|
349
|
+
// NIST SP 800-227 RS5 / sec. 4.2 and RFC 9629 sec. 7: the shared secret and the KEK it derives
|
|
350
|
+
// are destroyed as soon as they stop being needed. In a `finally`, so an unwrap that FAILS --
|
|
351
|
+
// the common case under a wrong key or a tampered encryptedKey -- leaves nothing behind either;
|
|
352
|
+
// a wipe on the success path alone would keep the secret exactly when an attacker made it fail.
|
|
353
|
+
// Only buffers allocated here are wiped: km.key, the message bytes and the returned CEK are the
|
|
354
|
+
// caller's or the caller's to consume.
|
|
355
|
+
// A Uint8Array view aliases the ArrayBuffer's own bytes, so wiping the view wipes the buffer
|
|
356
|
+
// the engine returned -- no further copy is made in order to destroy one.
|
|
357
|
+
guard.secret.zeroizeAll([ss, kek, ssAb ? new Uint8Array(ssAb) : null, kekAb ? new Uint8Array(kekAb) : null],
|
|
358
|
+
CmsError, "cms/bad-input", "the KEM shared secret");
|
|
359
|
+
}
|
|
315
360
|
}
|
|
316
361
|
|
|
317
362
|
// The declared cipher MODE must match the container's authentication model: an AEAD cipher belongs in
|
|
@@ -338,7 +383,14 @@ async function _openContent(parsed, eci, cek, ct) {
|
|
|
338
383
|
if (eci.encryptedContent == null) throw _err("cms/no-encrypted-content", "the message has no encryptedContent (detached; supply it out of band)");
|
|
339
384
|
// A null CEK (v1.5 implicit rejection) or a wrong-length CEK -> a fresh random key of the right
|
|
340
385
|
// length, so the failure surfaces here as the uniform verdict, never earlier.
|
|
341
|
-
|
|
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; }
|
|
342
394
|
try {
|
|
343
395
|
if (ct === "authEnvelopedData") {
|
|
344
396
|
var aad = parsed.authAttrsBytes != null ? _explicitSetOf(parsed.authAttrsBytes) : Buffer.alloc(0);
|
|
@@ -350,6 +402,8 @@ async function _openContent(parsed, eci, cek, ct) {
|
|
|
350
402
|
} catch (e) {
|
|
351
403
|
if (e instanceof CmsError && e.code !== "cms/decrypt-failed") throw e;
|
|
352
404
|
throw _fail();
|
|
405
|
+
} finally {
|
|
406
|
+
guard.secret.zeroize(substitute, CmsError, "cms/bad-input", "the implicit-rejection substitute content key");
|
|
353
407
|
}
|
|
354
408
|
}
|
|
355
409
|
function _gcmOpen(cek, nonce, ct, tag, aad, keyBits, icvLen) {
|
|
@@ -397,9 +451,18 @@ async function _decryptPbes2(parsed, eci, km, opts) {
|
|
|
397
451
|
}
|
|
398
452
|
var keyBits = CONTENT_KEYBITS[encOid];
|
|
399
453
|
if (!keyBits) throw _err("cms/unsupported-algorithm", "unsupported PBES2 content cipher " + encOid);
|
|
400
|
-
|
|
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"); }
|
|
401
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 }; }
|
|
402
464
|
catch (_e) { throw _fail(); }
|
|
465
|
+
finally { guard.secret.zeroize(key, CmsError, "cms/bad-input", "the password-derived content-encryption key"); }
|
|
403
466
|
}
|
|
404
467
|
|
|
405
468
|
// ---- shared helpers (mirror cms-encrypt's builders) ------------------------
|
|
@@ -410,7 +473,22 @@ async function _aesKwUnwrap(kek, wrapped) {
|
|
|
410
473
|
// length, so an AuthenticatedData MAC key that is not 16/24/32 octets (e.g. a 64-octet HMAC-SHA-512
|
|
411
474
|
// key from another implementation) is recovered instead of rejected before the MAC is even checked.
|
|
412
475
|
var raw = await subtle.unwrapKey("raw", wrapped, kekKey, { name: "AES-KW" }, { name: "HMAC", hash: "SHA-256" }, true, ["sign"]);
|
|
413
|
-
|
|
476
|
+
// exportKey allocates an ArrayBuffer holding the unwrapped key; the Buffer below is a copy of it,
|
|
477
|
+
// and the original would otherwise stay readable with nothing referencing it. That is a
|
|
478
|
+
// controllable allocation, not one of the runtime-internal copies the best-effort caveat covers.
|
|
479
|
+
// Only the intermediate is wiped -- the returned Buffer IS the key the caller must use to open
|
|
480
|
+
// the content, so wiping it here would destroy the result rather than protect it.
|
|
481
|
+
var rawAb = await subtle.exportKey("raw", raw);
|
|
482
|
+
try {
|
|
483
|
+
// An explicit COPY, not Buffer.from(arrayBuffer) -- that form returns a VIEW sharing the
|
|
484
|
+
// ArrayBuffer's memory, so wiping the intermediate below would zero the key being returned.
|
|
485
|
+
var view = new Uint8Array(rawAb);
|
|
486
|
+
var out = Buffer.alloc(view.length);
|
|
487
|
+
out.set(view);
|
|
488
|
+
return out;
|
|
489
|
+
} finally {
|
|
490
|
+
guard.secret.zeroize(new Uint8Array(rawAb), CmsError, "cms/bad-input", "the unwrapped content key");
|
|
491
|
+
}
|
|
414
492
|
}
|
|
415
493
|
function _eccSharedInfo(wrapName, ukm, kekBytes) {
|
|
416
494
|
var kids = [b.sequence([b.oid(O(wrapName))])];
|
|
@@ -440,14 +518,21 @@ function _pwriUnwrap(kek, wrapped, iv, keyBits) {
|
|
|
440
518
|
var d1 = nodeCrypto.createDecipheriv(alg, kek, iv2); d1.setAutoPadding(false);
|
|
441
519
|
var pass1 = Buffer.concat([d1.update(wrapped), d1.final()]);
|
|
442
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.
|
|
443
524
|
var body = Buffer.concat([d2.update(pass1), d2.final()]);
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
+
}
|
|
451
536
|
}
|
|
452
537
|
// Coverage residual (the unsupported-algorithm throw arm of each lookup below -- _hashW3c,
|
|
453
538
|
// _x963Hash, and _originatorSpki that follow): reachable only from a fully well-formed recipient that
|
|
@@ -573,21 +658,33 @@ async function _verifyAuthenticatedData(parsed, km, opts) {
|
|
|
573
658
|
// ktri/pwri could convey a below-floor (128-bit) key; in EITHER case substitute a fresh random
|
|
574
659
|
// key so the MAC verify still RUNS and fails uniformly -- never a fast-path that distinguishes an
|
|
575
660
|
// invalid/short unwrap from a MAC mismatch (Bleichenbacher / weak-key oracle freedom).
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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");
|
|
582
687
|
}
|
|
583
|
-
return {
|
|
584
|
-
content: Buffer.from(content),
|
|
585
|
-
contentType: parsed.encapContentInfo.eContentType, contentTypeName: oid.name(parsed.encapContentInfo.eContentType) || parsed.encapContentInfo.eContentType,
|
|
586
|
-
recipientType: candidates[ci].ri.type, recipientIndex: candidates[ci].index,
|
|
587
|
-
macAlgorithm: macAlg.name || macAlg.oid,
|
|
588
|
-
digestAlgorithm: parsed.digestAlgorithm ? (parsed.digestAlgorithm.name || parsed.digestAlgorithm.oid) : null,
|
|
589
|
-
authenticated: true,
|
|
590
|
-
};
|
|
591
688
|
} catch (e) {
|
|
592
689
|
if (candidates.length === 1) throw e; // one candidate: preserve its exact (distinct or uniform) verdict
|
|
593
690
|
}
|