@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 +19 -0
- package/README.md +1 -1
- package/lib/cms-decrypt.js +39 -5
- package/lib/cms-encrypt.js +23 -9
- package/lib/guard-all.js +2 -0
- package/lib/guard-secret.js +82 -0
- package/lib/lint.js +3 -1
- package/lib/oid.js +35 -0
- package/lib/schema-cms.js +3 -3
- package/lib/webcrypto.js +92 -12
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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` |
|
package/lib/cms-decrypt.js
CHANGED
|
@@ -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 =
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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))])];
|
package/lib/cms-encrypt.js
CHANGED
|
@@ -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
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
612
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
ML_KEM_INNER[oid.byName(
|
|
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
|
-
|
|
1033
|
-
|
|
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
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:
|
|
5
|
+
"serialNumber": "urn:uuid:47691d4d-f958-4718-adb0-5a945e2dd23e",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-08-
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.4.13",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.4.
|
|
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.
|
|
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.
|
|
57
|
+
"ref": "@blamejs/pki@0.4.13",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|