@blamejs/pki 0.4.12 → 0.4.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,25 @@ 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.13 — 2026-08-10
8
+
9
+ 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
+
11
+ ### Added
12
+
13
+ - 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.
14
+ - 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.
15
+ - pki.oid.kemParams resolves an ML-KEM parameter set to its FIPS 203 Table 3 sizes, by dotted OID or by registered name.
16
+
17
+ ### Changed
18
+
19
+ - 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.
20
+ - 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.
21
+
22
+ ### Fixed
23
+
24
+ - 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.
25
+
7
26
  ## v0.4.12 — 2026-08-09
8
27
 
9
28
  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. A KEM shared secret and its derived key-encryption key are wiped once used, on the failing path as well as the succeeding one (best-effort; NIST SP 800-227, 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` |
@@ -308,10 +308,29 @@ async function _kemriCek(ri, km) {
308
308
  var wrapAlg = k.wrap;
309
309
  if (WRAP_KEK_LENGTHS[wrapAlg.oid] !== kekBytes) throw _fail(); // M29 re-check on the consumer path
310
310
  var priv = await subtle.importKey("pkcs8", _normKeyDer(km.key), { name: wcName }, false, ["decapsulateBits"]);
311
- var ss = Buffer.from(await subtle.decapsulateBits({ name: wcName }, priv, kemct));
312
- var ssKey = await subtle.importKey("raw", ss, { name: "HKDF" }, false, ["deriveBits"]);
313
- var kek = Buffer.from(await subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: Buffer.alloc(0), info: _kemOtherInfo(wrapAlg.name, kekBytes, k.ukm || null) }, ssKey, kekBytes * 8));
314
- return await _aesKwUnwrap(kek, k.encryptedKey);
311
+ var ss = null, kek = null, ssAb = null, kekAb = null;
312
+ try {
313
+ // The engine hands back an ArrayBuffer it allocated, and the Buffer below is a copy of it. Both
314
+ // hold the secret, so both are wiped: clearing only the copy this function made would leave the
315
+ // engine's return value readable, which is the same omission one level up.
316
+ ssAb = await subtle.decapsulateBits({ name: wcName }, priv, kemct);
317
+ ss = Buffer.from(ssAb);
318
+ var ssKey = await subtle.importKey("raw", ss, { name: "HKDF" }, false, ["deriveBits"]);
319
+ kekAb = await subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: Buffer.alloc(0), info: _kemOtherInfo(wrapAlg.name, kekBytes, k.ukm || null) }, ssKey, kekBytes * 8);
320
+ kek = Buffer.from(kekAb);
321
+ return await _aesKwUnwrap(kek, k.encryptedKey);
322
+ } finally {
323
+ // NIST SP 800-227 RS5 / sec. 4.2 and RFC 9629 sec. 7: the shared secret and the KEK it derives
324
+ // are destroyed as soon as they stop being needed. In a `finally`, so an unwrap that FAILS --
325
+ // the common case under a wrong key or a tampered encryptedKey -- leaves nothing behind either;
326
+ // a wipe on the success path alone would keep the secret exactly when an attacker made it fail.
327
+ // Only buffers allocated here are wiped: km.key, the message bytes and the returned CEK are the
328
+ // caller's or the caller's to consume.
329
+ // A Uint8Array view aliases the ArrayBuffer's own bytes, so wiping the view wipes the buffer
330
+ // the engine returned -- no further copy is made in order to destroy one.
331
+ guard.secret.zeroizeAll([ss, kek, ssAb ? new Uint8Array(ssAb) : null, kekAb ? new Uint8Array(kekAb) : null],
332
+ CmsError, "cms/bad-input", "the KEM shared secret");
333
+ }
315
334
  }
316
335
 
317
336
  // The declared cipher MODE must match the container's authentication model: an AEAD cipher belongs in
@@ -410,7 +429,22 @@ async function _aesKwUnwrap(kek, wrapped) {
410
429
  // length, so an AuthenticatedData MAC key that is not 16/24/32 octets (e.g. a 64-octet HMAC-SHA-512
411
430
  // key from another implementation) is recovered instead of rejected before the MAC is even checked.
412
431
  var raw = await subtle.unwrapKey("raw", wrapped, kekKey, { name: "AES-KW" }, { name: "HMAC", hash: "SHA-256" }, true, ["sign"]);
413
- return Buffer.from(await subtle.exportKey("raw", raw));
432
+ // exportKey allocates an ArrayBuffer holding the unwrapped key; the Buffer below is a copy of it,
433
+ // and the original would otherwise stay readable with nothing referencing it. That is a
434
+ // controllable allocation, not one of the runtime-internal copies the best-effort caveat covers.
435
+ // Only the intermediate is wiped -- the returned Buffer IS the key the caller must use to open
436
+ // the content, so wiping it here would destroy the result rather than protect it.
437
+ var rawAb = await subtle.exportKey("raw", raw);
438
+ try {
439
+ // An explicit COPY, not Buffer.from(arrayBuffer) -- that form returns a VIEW sharing the
440
+ // ArrayBuffer's memory, so wiping the intermediate below would zero the key being returned.
441
+ var view = new Uint8Array(rawAb);
442
+ var out = Buffer.alloc(view.length);
443
+ out.set(view);
444
+ return out;
445
+ } finally {
446
+ guard.secret.zeroize(new Uint8Array(rawAb), CmsError, "cms/bad-input", "the unwrapped content key");
447
+ }
414
448
  }
415
449
  function _eccSharedInfo(wrapName, ukm, kekBytes) {
416
450
  var kids = [b.sequence([b.oid(O(wrapName))])];
@@ -275,15 +275,29 @@ async function _buildKemri(cek, cert, opts) {
275
275
  var pub = await subtle.importKey("spki", cert.subjectPublicKeyInfo.bytes, { name: wcName }, false, ["encapsulateBits"]);
276
276
  var kem = await subtle.encapsulateBits({ name: wcName }, pub);
277
277
  var ss = Buffer.from(kem.sharedKey), kemct = Buffer.from(kem.ciphertext);
278
- var ssKey = await subtle.importKey("raw", ss, { name: "HKDF" }, false, ["deriveBits"]);
279
- var kek = Buffer.from(await subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: Buffer.alloc(0), info: _kemOtherInfo(wrapName, kekBytes, ukm) }, ssKey, kekBytes * 8));
280
- var encryptedKey = await _aesKwWrap(kek, cek);
281
- var rid = _rid(cert, opts.keyIdentifier);
282
- var kemriKids = [b.integer(0n), rid.node, _algId(oid.name(keyOid), "absent"), b.octetString(kemct), _algId("hkdfWithSha256", "absent"), b.integer(BigInt(kekBytes))];
283
- if (ukm) kemriKids.push(b.explicit(0, b.octetString(ukm)));
284
- kemriKids.push(_algId(wrapName, "absent"), b.octetString(encryptedKey));
285
- var kemri = b.sequence(kemriKids);
286
- return { tag: 4, node: b.sequence([b.oid(O("kem")), kemri]) };
278
+ var kek = null, kekAb = null;
279
+ try {
280
+ var ssKey = await subtle.importKey("raw", ss, { name: "HKDF" }, false, ["deriveBits"]);
281
+ kekAb = await subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: Buffer.alloc(0), info: _kemOtherInfo(wrapName, kekBytes, ukm) }, ssKey, kekBytes * 8);
282
+ kek = Buffer.from(kekAb);
283
+ var encryptedKey = await _aesKwWrap(kek, cek);
284
+ var rid = _rid(cert, opts.keyIdentifier);
285
+ var kemriKids = [b.integer(0n), rid.node, _algId(oid.name(keyOid), "absent"), b.octetString(kemct), _algId("hkdfWithSha256", "absent"), b.integer(BigInt(kekBytes))];
286
+ if (ukm) kemriKids.push(b.explicit(0, b.octetString(ukm)));
287
+ kemriKids.push(_algId(wrapName, "absent"), b.octetString(encryptedKey));
288
+ var kemri = b.sequence(kemriKids);
289
+ return { tag: 4, node: b.sequence([b.oid(O("kem")), kemri]) };
290
+ } finally {
291
+ // RFC 9629 sec. 7 asks the SENDER to discard the shared secret and KEK once the recipient entry
292
+ // is built -- and to use a fresh secret per recipient, so this runs per call rather than once at
293
+ // the end of a multi-recipient message. In a `finally`, so a wrap or encoding failure does not
294
+ // leave them behind. The CEK is the caller's and is wiped by no one here; kemct is public.
295
+ // kem.sharedKey is the ArrayBuffer the engine returned and ss is this function's copy of it;
296
+ // both hold the secret, so both are cleared. A Uint8Array view aliases the buffer's bytes, so
297
+ // wiping the view wipes the buffer itself. kem.ciphertext is public and stays.
298
+ guard.secret.zeroizeAll([ss, kek, kem.sharedKey ? new Uint8Array(kem.sharedKey) : null, kekAb ? new Uint8Array(kekAb) : null],
299
+ CmsError, "cms/bad-input", "the KEM shared secret");
300
+ }
287
301
  }
288
302
 
289
303
  // AES-KW wrap of the CEK under a raw KEK.
package/lib/guard-all.js CHANGED
@@ -59,6 +59,7 @@ var json = require("./guard-json");
59
59
  var identifier = require("./guard-identifier");
60
60
  var header = require("./guard-header");
61
61
  var compress = require("./guard-compress");
62
+ var secret = require("./guard-secret");
62
63
 
63
64
  module.exports = {
64
65
  bytes: bytes,
@@ -73,4 +74,5 @@ module.exports = {
73
74
  identifier: identifier,
74
75
  header: header,
75
76
  compress: compress,
77
+ secret: secret,
76
78
  };
@@ -0,0 +1,82 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ //
5
+ // @internal -- no operator-facing namespace. The documented surface is the KEM
6
+ // key-establishment paths that compose this guard (pki.cms.encrypt / decrypt).
7
+ //
8
+ // guard-secret -- wipe a secret buffer the TOOLKIT ALLOCATED at the moment it
9
+ // stops being needed. NIST SP 800-227 RS5 / sec. 4.2 requires that a KEM shared
10
+ // secret and every intermediate value be destroyed as soon as they are no longer
11
+ // needed; RFC 9629 sec. 7 says the same of the KEK a KEMRecipientInfo derives.
12
+ //
13
+ // The defended class is secret lifetime, not secret disclosure: a shared secret
14
+ // or KEK left readable in the heap widens the window in which a later memory
15
+ // disclosure -- a core dump, a swapped page, a same-process read primitive --
16
+ // yields key material for traffic that was already decrypted.
17
+ //
18
+ // SCOPE, stated honestly because the docstring is the only place a reader learns
19
+ // it: this is BEST EFFORT. The runtime copies buffers into places no JS can
20
+ // reach (node's decapsulate return is copied on the way out; importKey("raw")
21
+ // copies into a KeyObject), and V8 may relocate a backing store, leaving the
22
+ // original bytes behind. Wiping the copies the toolkit holds shortens the
23
+ // window. It does not support a claim that a secret never persists in memory,
24
+ // and no operator-facing text may imply that it does.
25
+ //
26
+ // OWNERSHIP IS THE CONTRACT. Only a buffer the toolkit allocated may be wiped --
27
+ // never a caller's opts.key / opts.cert / opts.kek / opts.password, and never the
28
+ // input DER. Silently destroying a caller's own memory is a worse defect than
29
+ // leaving a secret readable, and it is the failure mode a zeroization patch
30
+ // reaches for first, so the call sites pass only their own intermediates.
31
+
32
+ var bytes = require("./guard-bytes");
33
+
34
+ // zeroize(value, ErrorClass, code, label) -> the same object, cleared.
35
+ // value : a Buffer / TypedArray the TOOLKIT allocated, or null / undefined
36
+ // (absent is a no-op so a `finally` needs no branch around it).
37
+ // ErrorClass : the caller's typed error CONSTRUCTOR, declared with
38
+ // `{ withCause: true }`. The guard family carries two currencies --
39
+ // most guards take a (code, message) factory and call it without
40
+ // `new`, while guard-bytes / guard-header take the class and
41
+ // construct it. This module's ONLY throw is the delegated re-view
42
+ // below, so it must pass what guard-bytes expects: the class, and
43
+ // one that accepts a cause, because guard-bytes threads the raw
44
+ // detach fault through as one. A plain class fails to construct at
45
+ // the single moment the caller needs a real error.
46
+ // code : the frozen domain/reason code a detached buffer rejects under.
47
+ // label : field phrase for the message.
48
+ //
49
+ // A detached ArrayBuffer cannot be written, and reaching one here means a caller
50
+ // handed over memory that was transferred away -- a real fault, not something to
51
+ // swallow, so it routes through the shared re-view guard and throws typed.
52
+ //
53
+ // The `.fill(0)` shape lives ONLY in this module: a wipe re-inlined anywhere in
54
+ // lib/ -- including a module not yet written -- is flagged, so the safe
55
+ // implementation is also the tripwire that stops the next consumer from rolling
56
+ // its own partial one.
57
+ // @enforced-by guard-shape-reinlined
58
+ // @guard-shape \.fill\s*\(\s*0\s*[,)]
59
+ function zeroize(value, ErrorClass, code, label) {
60
+ if (value === null || value === undefined) return value;
61
+ // Re-view through the shared bytes guard: it is the single place that decides
62
+ // what counts as a writable BufferSource and rejects a detached one typed.
63
+ var view = bytes.view(value, ErrorClass, code, label);
64
+ view.fill(0);
65
+ return value;
66
+ }
67
+
68
+ // zeroizeAll(list, ErrorClass, code, label) -- wipe every present member, tolerating
69
+ // holes so a `finally` can name intermediates that may not have been reached.
70
+ //
71
+ // @enforced-by behavioral -- this is a loop over zeroize, which carries the family's only
72
+ // rename-proof shape (the `.fill(0)` above). It introduces no shape of its own, so a lexical
73
+ // detector here would anchor on a renameable symbol and go silently green (drift rule sec. 3).
74
+ // The behavioural guards are guard-secret.test.js (holes tolerated, every member cleared) and the
75
+ // CMS vectors that assert the shared secret and KEK are wiped on BOTH the success and failure paths.
76
+ function zeroizeAll(list, ErrorClass, code, label) {
77
+ if (!list) return list;
78
+ for (var i = 0; i < list.length; i++) zeroize(list[i], ErrorClass, code, label);
79
+ return list;
80
+ }
81
+
82
+ module.exports = { zeroize: zeroize, zeroizeAll: zeroizeAll };
package/lib/lint.js CHANGED
@@ -628,8 +628,10 @@ var CABF_TLS_RULES = [
628
628
 
629
629
  // RFC 9935 -- ML-KEM public keys in X.509 certificates. The OID is the sole authority for the
630
630
  // parameter set; the SPKI BIT STRING is the raw ek, exactly 384k+32 octets for that OID.
631
+ // Encapsulation-key lengths come from the shared ML-KEM parameter registry (FIPS 203 Table 3),
632
+ // keyed by NAME because an SPKI surfaces its algorithm by name on this path.
631
633
  var ML_KEM_EK_LEN = {};
632
- ["id-ml-kem-512", "id-ml-kem-768", "id-ml-kem-1024"].forEach(function (n, i) { ML_KEM_EK_LEN[n] = [800, 1184, 1568][i]; });
634
+ ["id-ml-kem-512", "id-ml-kem-768", "id-ml-kem-1024"].forEach(function (n) { ML_KEM_EK_LEN[n] = oid.kemParams(n).ek; });
633
635
  function _isMlKem(cert) {
634
636
  var spki = cert.subjectPublicKeyInfo;
635
637
  return !!(spki && spki.algorithm && ML_KEM_EK_LEN[spki.algorithm.name] !== undefined);
package/lib/oid.js CHANGED
@@ -693,11 +693,46 @@ function paramsMustBeAbsent(dotted) {
693
693
  return _PARAMS_ABSENT.has(dotted);
694
694
  }
695
695
 
696
+ // ---- ML-KEM parameter sets (FIPS 203 Table 3) --------------------------------
697
+ //
698
+ // ONE row per parameter set, resolvable by dotted OID or by registered name, because the
699
+ // consumers hold different currencies: the CMS codec and the decrypt path key by OID, the
700
+ // linter keys by the SPKI algorithm's name. Before this table the same FIPS 203 constants
701
+ // lived in three modules -- schema-cms's ciphertext lengths, webcrypto's {ek, dk}, and
702
+ // lint's encapsulation-key lengths -- so a fourth consumer (a composite KEM, an HPKE row,
703
+ // HQC by analogy) meant a fourth copy, and the ek values were already duplicated verbatim
704
+ // in two of them. A parameter set is a property OF the algorithm identifier, so it lives
705
+ // beside the registry that resolves one rather than in whichever module needed it first.
706
+ //
707
+ // ek : encapsulation-key octets dk : decapsulation-key octets
708
+ // ct : ciphertext octets ss : shared-secret octets
709
+ // A null-prototype table: a plain object would answer kemParams("toString") with a function
710
+ // inherited from Object.prototype, so the documented fail-closed contract would hold for every
711
+ // input EXCEPT the handful every object already carries -- exactly the ones an untrusted
712
+ // identifier might be.
713
+ var KEM_PARAMS = Object.create(null);
714
+ [["id-ml-kem-512", 800, 1632, 768, 32],
715
+ ["id-ml-kem-768", 1184, 2400, 1088, 32],
716
+ ["id-ml-kem-1024", 1568, 3168, 1568, 32]].forEach(function (r) {
717
+ // FROZEN, because these rows are reachable from the public surface AND one of them now governs a
718
+ // security check: the engine reads .ct to enforce the FIPS 203 sec. 7.3 ciphertext length. A shared
719
+ // mutable row would let any code in the process rewrite that bound once, for every later call --
720
+ // a parameter set is a fact about the algorithm, not a setting an application may retune.
721
+ var row = Object.freeze({ ek: r[1], dk: r[2], ct: r[3], ss: r[4] });
722
+ KEM_PARAMS[byName(r[0])] = row; // by dotted OID
723
+ KEM_PARAMS[r[0]] = row; // and by registered name -- the same frozen row
724
+ });
725
+ Object.freeze(KEM_PARAMS);
726
+ // kemParams(oidOrName) -> the row, or undefined for anything that is not an ML-KEM
727
+ // parameter set. Undefined is the caller's signal to fail closed; it never guesses a size.
728
+ function kemParams(oidOrName) { return KEM_PARAMS[oidOrName]; }
729
+
696
730
  module.exports = {
697
731
  name: name,
698
732
  byName: byName,
699
733
  has: has,
700
734
  paramsMustBeAbsent: paramsMustBeAbsent,
735
+ kemParams: kemParams,
701
736
  register: register,
702
737
  registerFamily: registerFamily,
703
738
  all: all,
package/lib/schema-cms.js CHANGED
@@ -106,10 +106,10 @@ WRAP_KEK_LENGTHS[oid.byName("aes256-wrap")] = 32;
106
106
  // ML-KEM OID -> the exact ciphertext (kemct) length in octets (FIPS 203). A
107
107
  // recognized ML-KEM kem carries a fixed-size ciphertext; any other length can
108
108
  // never decapsulate. (The params-absent rule rides the shared oid registry.)
109
+ // Ciphertext lengths come from the shared ML-KEM parameter registry (FIPS 203 Table 3) rather
110
+ // than a local copy, so this codec and the crypto engine cannot disagree about a parameter set.
109
111
  var KEM_CT_LENGTHS = {};
110
- KEM_CT_LENGTHS[oid.byName("id-ml-kem-512")] = 768;
111
- KEM_CT_LENGTHS[oid.byName("id-ml-kem-768")] = 1088;
112
- KEM_CT_LENGTHS[oid.byName("id-ml-kem-1024")] = 1568;
112
+ ["id-ml-kem-512", "id-ml-kem-768", "id-ml-kem-1024"].forEach(function (n) { KEM_CT_LENGTHS[oid.byName(n)] = oid.kemParams(n).ct; });
113
113
 
114
114
  // Recognized AEAD content-encryption OIDs -> the AES-GCM/CCM parameter shape + the
115
115
  // legal ICVlen set (RFC 5084). An unrecognized content-encryption OID surfaces its
package/lib/webcrypto.js CHANGED
@@ -608,8 +608,16 @@ async function _deriveBitsRaw(alg, key, length) {
608
608
  }
609
609
  if (name === "HKDF") {
610
610
  _requireDeriveLength(length, "HKDF");
611
- var derived = nodeCrypto.hkdfSync(_hashNode(alg.hash, "HKDF"), _secretBytes(key), _toBuf(alg.salt, "HKDF salt"), _toBuf(alg.info || Buffer.alloc(0), "HKDF info"), length / 8);
612
- return derived instanceof ArrayBuffer ? derived : _toArrayBuffer(Buffer.from(derived));
611
+ // _secretBytes EXPORTS the key material into a fresh Buffer this module owns -- for a KEM flow
612
+ // that Buffer is the shared secret itself. It is a controllable allocation, not one of the
613
+ // runtime-internal copies the best-effort caveat covers, so it is wiped once HKDF has consumed it.
614
+ var ikm = _secretBytes(key);
615
+ try {
616
+ var derived = nodeCrypto.hkdfSync(_hashNode(alg.hash, "HKDF"), ikm, _toBuf(alg.salt, "HKDF salt"), _toBuf(alg.info || Buffer.alloc(0), "HKDF info"), length / 8);
617
+ return derived instanceof ArrayBuffer ? derived : _toArrayBuffer(Buffer.from(derived));
618
+ } finally {
619
+ guard.secret.zeroize(ikm, WebCryptoError, "webcrypto/operation", "the HKDF input key material");
620
+ }
613
621
  }
614
622
  if (name === "PBKDF2") {
615
623
  _requireDeriveLength(length, "PBKDF2");
@@ -701,7 +709,18 @@ SubtleCrypto.prototype.encapsulateBits = async function encapsulateBits(algorith
701
709
  var r;
702
710
  try { r = nodeCrypto.encapsulate(encapsulationKey._handle); }
703
711
  catch (e) { throw new WebCryptoError("webcrypto/operation", "encapsulateBits: ML-KEM encapsulation failed", e); }
704
- return { sharedKey: _toArrayBuffer(Buffer.from(r.sharedKey)), ciphertext: _toArrayBuffer(Buffer.from(r.ciphertext)) };
712
+ try {
713
+ // _toArrayBuffer copies via ArrayBuffer.slice, so the shared key is passed straight through --
714
+ // an intermediate Buffer.from would be another copy of the secret that nothing wipes. The
715
+ // ciphertext is public and needs no such care, but it is copied the same way for symmetry.
716
+ return { sharedKey: _toArrayBuffer(r.sharedKey), ciphertext: _toArrayBuffer(r.ciphertext) };
717
+ } finally {
718
+ // Encapsulation produces a shared secret exactly as decapsulation does, so it owes the same
719
+ // duty: the provider's buffer is wiped once the caller's copy exists (NIST SP 800-227 RS5 /
720
+ // sec. 4.2, RFC 9629 sec. 7). Wiping only the decapsulation side would make the guarantee a
721
+ // half-truth -- the sender holds the same secret the recipient does.
722
+ guard.secret.zeroize(r.sharedKey, WebCryptoError, "webcrypto/operation", "the KEM shared secret");
723
+ }
705
724
  };
706
725
 
707
726
  SubtleCrypto.prototype.decapsulateBits = async function decapsulateBits(algorithm, decapsulationKey, ciphertext) {
@@ -711,10 +730,39 @@ SubtleCrypto.prototype.decapsulateBits = async function decapsulateBits(algorith
711
730
  _requireAlgMatch(alg, decapsulationKey, "decapsulateBits");
712
731
  if (decapsulationKey.type !== "private") throw new WebCryptoError("webcrypto/invalid-access", "decapsulateBits requires a private (decapsulation) key, got " + JSON.stringify(decapsulationKey.type));
713
732
  var ct = _toBuf(ciphertext, "decapsulateBits ciphertext");
733
+ // FIPS 203 sec. 7.3 makes the ciphertext-length check the ONE per-execution input check a
734
+ // decapsulating party owes, so it belongs here, at the engine boundary, rather than only in the
735
+ // format module that happens to call this today: a direct caller -- or a future composite-KEM or
736
+ // HPKE consumer -- inherits nothing from a check that lives in cms-decrypt. A distinct code names
737
+ // the real reason, which "the operation failed" cannot.
738
+ //
739
+ // Length ONLY. A correct-length ciphertext that has been tampered with must still resolve to a
740
+ // pseudo-random shared secret (the Fujisaki-Okamoto implicit rejection of FIPS 203 sec. 6.3);
741
+ // turning that into a throw would hand an attacker a decryption oracle, and it is the property
742
+ // the CMS uniform verdict is built on.
743
+ // The registry is keyed by the registered OID name ("id-ml-kem-768"), which is the node
744
+ // algorithm name this module already maps ("ml-kem-768") under its id- prefix.
745
+ var kemRow = oid.kemParams("id-" + ML_KEM_NODE[alg.name]);
746
+ if (kemRow && ct.length !== kemRow.ct) {
747
+ throw new WebCryptoError("webcrypto/bad-kem-ciphertext",
748
+ "decapsulateBits: " + alg.name + " expects a " + kemRow.ct + "-octet ciphertext, got " + ct.length + " (FIPS 203 sec. 7.3)");
749
+ }
714
750
  var ss;
715
751
  try { ss = nodeCrypto.decapsulate(decapsulationKey._handle, ct); }
716
752
  catch (e) { throw new WebCryptoError("webcrypto/operation", "decapsulateBits: ML-KEM decapsulation failed (malformed or wrong-length ciphertext)", e); }
717
- return _toArrayBuffer(Buffer.from(ss));
753
+ try {
754
+ // ss is already a Buffer, and _toArrayBuffer copies via ArrayBuffer.slice -- so it is passed
755
+ // straight through. An intermediate Buffer.from(ss) would be a THIRD copy of the secret that
756
+ // nothing wipes, which would give back most of what the wipe below is for.
757
+ return _toArrayBuffer(ss);
758
+ } finally {
759
+ // The shared secret is returned as a COPY, so the buffer the provider handed back would stay
760
+ // readable until collection -- and a caller wiping only what it receives would leave the
761
+ // original behind, which is the whole secret. Wiping here means the engine owns the lifetime of
762
+ // the buffer it allocated, and every caller (CMS today, a composite KEM or HPKE later) inherits
763
+ // it rather than each having to remember (NIST SP 800-227 RS5 / sec. 4.2).
764
+ guard.secret.zeroize(ss, WebCryptoError, "webcrypto/operation", "the KEM shared secret");
765
+ }
718
766
  };
719
767
 
720
768
  SubtleCrypto.prototype.wrapKey = async function wrapKey(format, key, wrappingKey, wrapAlgorithm) {
@@ -731,10 +779,16 @@ SubtleCrypto.prototype.wrapKey = async function wrapKey(format, key, wrappingKey
731
779
  if (bytes.length < 16 || bytes.length % 8 !== 0) {
732
780
  throw new WebCryptoError("webcrypto/operation", "wrapKey: AES-KW requires the serialized key be a multiple of 8 bytes (>= 16); got " + bytes.length + " -- format " + JSON.stringify(format) + " is not AES-KW-wrappable");
733
781
  }
782
+ // The mirror of unwrapKey below: in a KEM flow this export is the SENDER's copy of the same
783
+ // key-encryption key, so leaving it unwiped would keep a full copy of the KEK alive for the
784
+ // process lifetime and make the wipes the CMS layer performs pointless in the encrypt direction.
785
+ var wkBytes = null;
734
786
  try {
735
- var c = nodeCrypto.createCipheriv("aes" + wrappingKey.algorithm.length + "-wrap", _secretBytes(wrappingKey), Buffer.from("A6A6A6A6A6A6A6A6", "hex"));
787
+ wkBytes = _secretBytes(wrappingKey);
788
+ var c = nodeCrypto.createCipheriv("aes" + wrappingKey.algorithm.length + "-wrap", wkBytes, Buffer.from("A6A6A6A6A6A6A6A6", "hex"));
736
789
  return _toArrayBuffer(Buffer.concat([c.update(bytes), c.final()]));
737
790
  } catch (e) { throw new WebCryptoError("webcrypto/operation", "wrapKey: AES-KW key wrap failed", e); }
791
+ finally { guard.secret.zeroize(wkBytes, WebCryptoError, "webcrypto/operation", "the AES-KW wrapping key"); }
738
792
  }
739
793
  // Delegate to a content-encryption algorithm (RSA-OAEP / AES-GCM).
740
794
  var wrapKeyClone = _cloneWithUsage(wrappingKey, "encrypt");
@@ -760,10 +814,19 @@ SubtleCrypto.prototype.unwrapKey = async function unwrapKey(format, wrappedKey,
760
814
  if (wrapped.length < 24 || wrapped.length % 8 !== 0) {
761
815
  throw new WebCryptoError("webcrypto/operation", "unwrapKey: AES-KW wrapped key must be a multiple of 8 bytes (>= 24); got " + wrapped.length);
762
816
  }
817
+ // The exported wrapping key is a Buffer this module owns; in a KEM flow it is the KEK derived
818
+ // from the shared secret, so it is wiped once the unwrap has consumed it -- on the failing path
819
+ // too, which is the one an attacker induces by tampering with the wrapped key.
820
+ // Declared outside so the finally can reach it, but EXPORTED INSIDE the try: a key whose handle
821
+ // cannot be exported must still surface the typed verdict this branch promises, not a raw node
822
+ // TypeError -- and a non-PkiError throw would also break the fuzz-harness contract.
823
+ var kwBytes = null;
763
824
  try {
764
- var d = nodeCrypto.createDecipheriv("aes" + unwrappingKey.algorithm.length + "-wrap", _secretBytes(unwrappingKey), Buffer.from("A6A6A6A6A6A6A6A6", "hex"));
825
+ kwBytes = _secretBytes(unwrappingKey);
826
+ var d = nodeCrypto.createDecipheriv("aes" + unwrappingKey.algorithm.length + "-wrap", kwBytes, Buffer.from("A6A6A6A6A6A6A6A6", "hex"));
765
827
  bytes = Buffer.concat([d.update(wrapped), d.final()]);
766
828
  } catch (e) { throw new WebCryptoError("webcrypto/operation", "unwrapKey: AES-KW key unwrap failed (integrity or length)", e); }
829
+ finally { guard.secret.zeroize(kwBytes, WebCryptoError, "webcrypto/operation", "the AES-KW unwrapping key"); }
767
830
  } else {
768
831
  var unwrapKeyClone = _cloneWithUsage(unwrappingKey, "decrypt");
769
832
  bytes = Buffer.from(await this.decrypt(unwrapAlgorithm, unwrapKeyClone, wrappedKey));
@@ -783,7 +846,14 @@ SubtleCrypto.prototype.unwrapKey = async function unwrapKey(format, wrappedKey,
783
846
  } else {
784
847
  keyData = bytes;
785
848
  }
786
- return this.importKey(format, keyData, unwrappedKeyAlgorithm, extractable, keyUsages);
849
+ try {
850
+ return await this.importKey(format, keyData, unwrappedKeyAlgorithm, extractable, keyUsages);
851
+ } finally {
852
+ // `bytes` is the UNWRAPPED key in plaintext -- a module-owned buffer, and the last plaintext
853
+ // copy this layer controls once importKey has taken its own. Clearing the caller-visible copy
854
+ // downstream while leaving this one live would make that wipe ceremonial.
855
+ guard.secret.zeroize(bytes, WebCryptoError, "webcrypto/operation", "the unwrapped key material");
856
+ }
787
857
  };
788
858
 
789
859
  function _cloneWithUsage(key, usage) {
@@ -819,10 +889,13 @@ function _nodeKey(fn, who) {
819
889
  // RFC 9935 sec. 6 ML-KEM-*-PrivateKey CHOICE: the inner sizes, keyed by the OID -- the OID is
820
890
  // the SOLE authority for the parameter set (never a length heuristic). ek = 384k+32, dk = the
821
891
  // FIPS 203 decapsulation key length.
892
+ // {ek, dk} come from the shared ML-KEM parameter registry (FIPS 203 Table 3) -- the same rows the
893
+ // CMS codec and the linter read, so a parameter set cannot mean one size here and another there.
822
894
  var ML_KEM_INNER = {};
823
- ML_KEM_INNER[oid.byName("id-ml-kem-512")] = { ek: 800, dk: 1632 };
824
- ML_KEM_INNER[oid.byName("id-ml-kem-768")] = { ek: 1184, dk: 2400 };
825
- ML_KEM_INNER[oid.byName("id-ml-kem-1024")] = { ek: 1568, dk: 3168 };
895
+ ["id-ml-kem-512", "id-ml-kem-768", "id-ml-kem-1024"].forEach(function (n) {
896
+ var row = oid.kemParams(n);
897
+ ML_KEM_INNER[oid.byName(n)] = { ek: row.ek, dk: row.dk };
898
+ });
826
899
 
827
900
  function _isOctet(node, size) {
828
901
  return node && node.tagClass === "universal" && node.tagNumber === asn1.TAGS.OCTET_STRING &&
@@ -1028,9 +1101,16 @@ SubtleCrypto.prototype.exportKey = async function exportKey(format, key) {
1028
1101
  if (!key.extractable) throw new WebCryptoError("webcrypto/invalid-access", "key is not extractable");
1029
1102
  if (format === "jwk") return key._handle.export({ format: "jwk" });
1030
1103
  if (key.type === "secret") {
1104
+ // export() allocates a fresh Buffer holding the secret key, and _toArrayBuffer copies it via
1105
+ // ArrayBuffer.slice -- so the export Buffer is a controllable allocation nothing references once
1106
+ // the copy exists. Wiped in a `finally` so the unsupported-format throw below clears it too.
1031
1107
  var raw = key._handle.export();
1032
- if (format === "raw") return _toArrayBuffer(raw);
1033
- throw new WebCryptoError("webcrypto/not-supported", "exportKey: secret keys support 'raw' / 'jwk' only");
1108
+ try {
1109
+ if (format === "raw") return _toArrayBuffer(raw);
1110
+ throw new WebCryptoError("webcrypto/not-supported", "exportKey: secret keys support 'raw' / 'jwk' only");
1111
+ } finally {
1112
+ guard.secret.zeroize(raw, WebCryptoError, "webcrypto/operation", "the exported secret key");
1113
+ }
1034
1114
  }
1035
1115
  if (format === "spki") return _toArrayBuffer(key._handle.export({ format: "der", type: "spki" }));
1036
1116
  if (format === "pkcs8") return _toArrayBuffer(key._handle.export({ format: "der", type: "pkcs8" }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.4.12",
3
+ "version": "0.4.13",
4
4
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:14a71e45-54e7-49bd-8fe1-ac576a769036",
5
+ "serialNumber": "urn:uuid:47691d4d-f958-4718-adb0-5a945e2dd23e",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-10T02:28:13.520Z",
8
+ "timestamp": "2026-08-10T06:22:53.264Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/pki@0.4.12",
22
+ "bom-ref": "@blamejs/pki@0.4.13",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.4.12",
25
+ "version": "0.4.13",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
29
- "purl": "pkg:npm/%40blamejs/pki@0.4.12",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.4.13",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/pki@0.4.12",
57
+ "ref": "@blamejs/pki@0.4.13",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]