@blamejs/pki 0.3.11 → 0.3.12

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,14 @@ All notable changes to `@blamejs/pki` are documented here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
5
5
  project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## v0.3.12 — 2026-07-24
8
+
9
+ pki.pkcs12.open reads and decrypts a password-integrity PKCS#12 store (RFC 7292, RFC 9579).
10
+
11
+ ### Added
12
+
13
+ - pki.pkcs12.open(pfx, password, opts) reads a password-integrity PKCS#12 store. It verifies the classic Appendix B HMAC or RFC 9579 PBMAC1 MAC before decrypting (RFC 7292 sec. 5.1), then PBES2-decrypts every privacy safe and pkcs8ShroudedKeyBag, returning { integrityMode, macVerified, keys, certs, crls, secrets } -- private keys as re-validated PKCS#8 DER, certificates/CRLs/secrets as raw DER, all carrying friendlyName and localKeyId, and nested safeContents recursively. A wrong password fails at the MAC gate (pkcs12/mac-mismatch); a MAC-less store is refused (pkcs12/no-integrity) unless opts.allowUnauthenticated is set; a public-key-integrity store and a legacy (non-PBES2) bag are refused; a post-MAC decrypt failure is the uniform pkcs12/decrypt-failed. opts.maxIterations lowers the KDF/MAC iteration cap for the call, and opts.keys 'crypto' imports each private key to a WebCrypto CryptoKey (opts.importAlgorithm for the ambiguous RSA/EC arms). pfx accepts a DER Buffer, PEM string, or a pki.schema.pkcs12.parse result. RFC 7292 sec. 5.1, RFC 9579, RFC 8018.
14
+
7
15
  ## v0.3.11 — 2026-07-23
8
16
 
9
17
  pki.pkcs12 builds and MAC-verifies password-integrity PKCS#12 (.p12/.pfx) stores (RFC 7292, RFC 9579).
package/README.md CHANGED
@@ -229,7 +229,7 @@ is callable today; nothing below is a stub.
229
229
  | `pki.cmp` | RFC 9810 Certificate Management Protocol message building — `build(message, opts)` assembles a protected `PKIMessage`. `message.header` carries the `sender` / `recipient` GeneralNames (including the anonymous NULL-DN) plus optional transaction metadata (`transactionID`, `senderNonce` / `recipNonce`, `messageTime` as a GeneralizedTime, `senderKID` / `recipKID`, `freeText`, `generalInfo`); `message.body` is a single-key object naming the arm. Request-side: `ir` / `cr` / `kur` (a `CertReqMessages` spec delegated to `pki.crmf.build`), `p10cr` (a PKCS#10 `CertificationRequest`), `certConf`, `pollReq`, `genm`, `rr`. CA/responder-side: `ip` / `cp` / `kup` / `ccp` (a `CertRepMessage` — `caPubs` and `response` entries carrying a `PKIStatusInfo` and, under a granting status, a `certifiedKeyPair`), `rp` (revocation response), `genp`, `error`, `pollRep`, `krp` (key-recovery response), `pkiconf`. Protection is exactly one of `opts.{ key, cert }` — a signature over the message under the sender key, the algorithm resolved from the signer certificate so RSA (PKCS#1 v1.5 / PSS), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch — or `opts.mac` — a PBMAC1 shared-secret HMAC (RFC 9481 / 9579, PBKDF2-derived). The protection covers the exact DER of the virtual `ProtectedPart` (the header and body) and is self-verified before the message is returned; the `protectionAlg` is derived, never caller-set, so the message the parser accepts is coherent by construction. Returns DER, or a PEM `CMP` block with `opts.pem`; malformed input throws a typed `CmpError`. Parsing stays at `pki.schema.cmp.parse` — `build` |
230
230
  | `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` |
231
231
  | `pki.key` | RFC 5958 / RFC 8018 key-material lifecycle — `encrypt(privateKey, password, opts)` wraps a PKCS#8 private key (DER, PEM, or an extractable `CryptoKey`) into an `EncryptedPrivateKeyInfo` under PBES2 (PBKDF2 + AES-CBC-Pad): `opts` selects the `cipher` (`aes-256-cbc` default, `aes-192-cbc`, `aes-128-cbc`), the `prf` (`hmacWithSHA256` default, SHA-384/512, SHA-1), the `iterations` (default 600000), and the `salt`; the plaintext is validated as PKCS#8 before encryption, a default `prf` and `keyLength` are omitted so the parameters are byte-exact with OpenSSL, and the output is re-parsed before return. `decrypt(encrypted, password, opts)` recovers the inner `PrivateKeyInfo` (re-validated through `pki.schema.pkcs8.parse`) — only PBES2/PBKDF2/AES-CBC is accepted (PBES1, PBMAC1, scrypt refused), the salt and iteration count are bounded before any derivation (`opts.maxIterations` lowers the cap), a malformed parameter set or wrong-length IV is a distinct typed error, and — because a MAC-less PBES2-CBC decrypt must not be a padding oracle (RFC 8018 §8) — a wrong password and a valid-pad-but-not-a-key both surface the one uniform `key/decrypt-failed`. `export(key, opts)` / `import(input, opts)` move a private key as PKCS#8 or a public key as SubjectPublicKeyInfo, delegating the encoding to WebCrypto so RSA carries an explicit NULL, EC a named curve, and Ed25519/Ed448/X25519/X448 omit parameters (an ambiguous RSA/EC import requires `opts.algorithm`). `generate(algorithm, opts)` produces a key pair over RSA, ECDSA/ECDH, the Edwards/Montgomery curves, and the FIPS post-quantum ML-DSA / ML-KEM, and `publicFromPrivate(privateKey)` derives the public key. Returns DER or PEM; fail-closed with typed `KeyError`. Parsing stays at `pki.schema.pkcs8.parse` — `encrypt` / `decrypt` / `export` / `import` / `generate` / `publicFromPrivate` |
232
- | `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 and bag decryption are not yet built. Returns DER or a PEM `PKCS12`; fail-closed with typed `Pkcs12Error`. Parsing stays at `pki.schema.pkcs12.parse` — `build` / `verifyMac` |
232
+ | `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 and legacy-PBE (PKCS#12 Appendix C) bag decryption are not yet built. Returns DER or a PEM `PKCS12`; fail-closed with typed `Pkcs12Error`. `open(pfx, password, opts)` reads a store back: it verifies the MAC **first** (a wrong password is the MAC verdict, not a decrypt error), then PBES2-decrypts every privacy safe and shrouded key bag and returns `{ integrityMode, macVerified, 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 or legacy-PBE store is refused, a post-MAC 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` |
233
233
  | `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`. **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. **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`, `encrypt`, `decrypt`, `compress`, `decompress` |
234
234
  | `pki.smime` | RFC 8551 S/MIME message assembly, verification, encryption, and compression over the CMS layer — `sign(content, signers, opts)` wraps a MIME entity as a signed S/MIME message in either form: `multipart/signed` (clear-signed — the content stays readable in any MUA, a detached CMS SignedData rides alongside as `application/pkcs7-signature` with a matching `micalg`) or `application/pkcs7-mime; smime-type=signed-data` (opaque — the whole entity is a base64 CMS SignedData). The signed bytes are the entity's RFC 8551 §3.1.1 canonical form (CRLF line endings); `verify(message, opts)` unwraps both forms and recomputes over the same canonicalizer, so a transport that re-wraps line endings still verifies and a tampered part fails. `encrypt(content, recipients, opts)` envelopes a MIME entity as an opaque `application/pkcs7-mime` message and `decrypt(message, keyMaterial, opts)` opens one — `smime-type=authEnveloped-data` (AES-GCM, confidentiality and integrity, the default) or `smime-type=enveloped-data` (AES-CBC, confidentiality only, so `decrypt` reports `authenticated: false`, the §3.3 no-integrity caveat); the `smime-type` is derived from the CMS body, not the header, and decryption is fail-closed and oracle-free. The crypto is entirely `pki.cms.sign` / `verify` / `encrypt` / `decrypt` — any RSA / RSASSA-PSS / ECDSA / EdDSA / ML-DSA / SLH-DSA signer and any RSA-OAEP / ECDH / X25519 / X448 / AES-KW / PBKDF2 / ML-KEM recipient carries through (algorithm-agnostic). Like `cms.verify`, `verify` returns the per-signer cryptographic verdict plus the recovered content; chaining a signer to a trust anchor is the caller's `pki.path.validate` step. `compress(content, opts)` / `decompress(message, opts)` add the opaque `application/pkcs7-mime; smime-type=compressed-data; name=smime.p7z` frame (RFC 8551 §3.6, RFC 3274) — a size transform with no integrity/confidentiality (§2.4.5), decompress bounded against a bomb; the recovered content, which may itself be signed or enveloped, is returned for the caller to re-verify. Bidirectionally interoperable with `openssl smime` / `openssl cms`. Fail-closed with typed `smime/*` errors — `sign`, `verify`, `encrypt`, `decrypt`, `compress`, `decompress` |
235
235
  | `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` |
@@ -271,11 +271,10 @@ certificate and writes a DER (or `--pem`) SignedData to `--out` or stdout.
271
271
 
272
272
  ### What's coming
273
273
 
274
- Certificate build/sign/verify, CMS AuthenticatedData /
275
- AuthEnvelopedData, PKCS#8 decryption (PBES2) / SPKI, PKCS#12 MAC verification
276
- and bag decryption, and the post-quantum certificate and CMS surface (ML-DSA /
277
- ML-KEM / SLH-DSA and hybrid composites) are on the roadmap and ride this same
278
- core. See
274
+ Thin enrollment-protocol HTTP clients (ACME, EST, CMP transport over
275
+ `node:https`), PKCS#12 public-key integrity and legacy-PBE (Appendix C) bag
276
+ decryption, and CMS countersignatures / unsigned attributes are on the roadmap
277
+ and ride this same core. See
279
278
  [ROADMAP.md](ROADMAP.md) for the full plan and current status of each area, and
280
279
  [CHANGELOG.md](CHANGELOG.md) for what has landed.
281
280
 
package/lib/key.js CHANGED
@@ -174,37 +174,10 @@ async function decrypt(encrypted, password, opts) {
174
174
  }
175
175
 
176
176
  function _decryptPbes2(encAlg, ciphertext, password, opts) {
177
- var pb, keyBits, iv;
178
- // Structural parse (typed, pre-derivation). These faults are not password-dependent, so a distinct code
179
- // per fault leaks no oracle; a raw asn1 fault is normalized to key/bad-algorithm-parameters.
180
- try {
181
- var params = pbes2.seqChildren(encAlg.parameters, 2, "PBES2 parameters", _err, "key");
182
- var kdf = pbes2.requireChildren(params[0], 2, "PBES2 keyDerivationFunc", _err, "key");
183
- var encScheme = pbes2.requireChildren(params[1], 2, "PBES2 encryptionScheme", _err, "key");
184
- if (asn1.read.oid(kdf[0]) !== O("pbkdf2")) throw _err("key/unsupported-algorithm", "the PBES2 keyDerivationFunc must be PBKDF2 (RFC 8018 sec. 6.2)");
185
- pb = pbes2.parsePbkdf2Params(kdf[1].bytes, opts, _err, "key", true); // strictPrf: reject a non-canonical explicit default prf
186
- var encOid = asn1.read.oid(encScheme[0]);
187
- keyBits = pbes2.CONTENT_KEYBITS[encOid];
188
- if (!keyBits || !/CBC/.test(oid.name(encOid) || "")) throw _err("key/unsupported-algorithm", "unsupported PBES2 encryptionScheme " + (oid.name(encOid) || encOid) + " (AES-CBC only)");
189
- iv = asn1.read.octetString(encScheme[1]);
190
- if (iv.length !== 16) throw _err("key/bad-algorithm-parameters", "the AES-CBC IV must be 16 octets");
191
- } catch (e) {
192
- // The shared PBES2 home rejects a malformed / truncated / over-cap parameter with the generic
193
- // key/bad-input; in the PBES2 parameter-structure context that IS a bad-algorithm-parameters. The
194
- // semantic verdicts it raises (unsupported-algorithm, iteration-limit) keep their precise codes.
195
- if (e instanceof KeyError) {
196
- if (e.code === "key/bad-input") throw _err("key/bad-algorithm-parameters", e.message, e.cause);
197
- throw e;
198
- }
199
- throw _err("key/bad-algorithm-parameters", "malformed PBES2 parameters", e);
200
- }
201
- var dk = nodeCrypto.pbkdf2Sync(pbes2.passwordBytes(password, _err, "key"), pb.salt, pb.iterations, keyBits / 8, pb.prfNode);
202
- // Post-derivation failures collapse to ONE uniform verdict (RFC 8018 sec. 8): a bad PKCS#7 pad and a
203
- // valid pad whose plaintext is not a PrivateKeyInfo are indistinguishable -- the re-parse is the integrity
204
- // check for MAC-less PBES2-CBC, and it MUST run (never skipped).
205
- var plaintext;
206
- try { plaintext = pbes2.cbcDecrypt(dk, iv, ciphertext, keyBits); }
207
- catch (_e) { throw _err("key/decrypt-failed", "decryption failed"); }
177
+ // The shared PBES2 decrypt home (strict params + the uniform decrypt-failed on a wrong key / bad pad). The
178
+ // plaintext re-parse below is the MAC-less PBES2-CBC integrity check (RFC 8018 sec. 8): a bad pad and a
179
+ // valid pad whose plaintext is not a PrivateKeyInfo are indistinguishable, and it MUST run (never skipped).
180
+ var plaintext = pbes2.pbes2Decrypt(pbes2.passwordBytes(password, _err, "key"), encAlg.parameters, ciphertext, opts, _err, "key");
208
181
  try { pkcs8.parse(plaintext); }
209
182
  catch (_e) { throw _err("key/decrypt-failed", "decryption failed"); }
210
183
  return plaintext;
package/lib/pbes2.js CHANGED
@@ -145,6 +145,38 @@ function pbes2Encrypt(pwBytes, plaintext, opts, E, prefix) {
145
145
  return { algId: pbes2AlgId(salt, iterations, prf, cipherName, iv), ct: cbcEncrypt(key, iv, plaintext, keyBits) };
146
146
  }
147
147
 
148
+ // PBES2 AES-CBC DECRYPT: parse the PBES2 params (PBKDF2 kdf + AES-CBC scheme), derive the key from
149
+ // PRE-FORMATTED password bytes (the caller owns UTF-8 vs BMPString), and AES-CBC-decrypt. Structural faults
150
+ // (a non-PBKDF2 KDF, a non-AES-CBC scheme, an over-cap salt/iteration, a wrong-length IV, a malformed param
151
+ // SEQUENCE) are typed pre-derivation and password-independent, so they leak no oracle; the generic
152
+ // <prefix>/bad-input a param guard raises is normalized to the structural <prefix>/bad-algorithm-parameters.
153
+ // A wrong key / bad PKCS#7 pad collapses to the UNIFORM <prefix>/decrypt-failed (RFC 8018 sec. 8). The
154
+ // plaintext integrity re-check (re-parse as a PrivateKeyInfo / SafeContents) is the CALLER's step.
155
+ function pbes2Decrypt(pwBytes, params, ciphertext, opts, E, prefix) {
156
+ var keyBits, iv, pb;
157
+ try {
158
+ var p = seqChildren(params, 2, "PBES2 parameters", E, prefix);
159
+ var kdf = requireChildren(p[0], 2, "PBES2 keyDerivationFunc", E, prefix);
160
+ var encScheme = requireChildren(p[1], 2, "PBES2 encryptionScheme", E, prefix);
161
+ if (asn1.read.oid(kdf[0]) !== O("pbkdf2")) throw E(prefix + "/unsupported-algorithm", "the PBES2 keyDerivationFunc must be PBKDF2 (RFC 8018 sec. 6.2)");
162
+ pb = parsePbkdf2Params(kdf[1].bytes, opts, E, prefix, true); // strictPrf: reject a non-canonical explicit default prf
163
+ var encOid = asn1.read.oid(encScheme[0]);
164
+ keyBits = CONTENT_KEYBITS[encOid];
165
+ if (!keyBits || !/CBC/.test(oid.name(encOid) || "")) throw E(prefix + "/unsupported-algorithm", "unsupported PBES2 encryptionScheme " + (oid.name(encOid) || encOid) + " (AES-CBC only)");
166
+ iv = asn1.read.octetString(encScheme[1]);
167
+ if (iv.length !== 16) throw E(prefix + "/bad-algorithm-parameters", "the AES-CBC IV must be 16 octets");
168
+ } catch (e) {
169
+ if (e && e.isPkiError && typeof e.code === "string" && e.code.indexOf(prefix + "/") === 0) {
170
+ if (e.code === prefix + "/bad-input") throw E(prefix + "/bad-algorithm-parameters", e.message, e.cause);
171
+ throw e;
172
+ }
173
+ throw E(prefix + "/bad-algorithm-parameters", "malformed PBES2 parameters", e);
174
+ }
175
+ var dk = nodeCrypto.pbkdf2Sync(pwBytes, pb.salt, pb.iterations, keyBits / 8, pb.prfNode);
176
+ try { return cbcDecrypt(dk, iv, ciphertext, keyBits); }
177
+ catch (_e) { throw E(prefix + "/decrypt-failed", "decryption failed"); }
178
+ }
179
+
148
180
  // ---- PBMAC1 (RFC 9579 / RFC 8018 App. A.5) : PBKDF2 -> HMAC over a message --
149
181
  // An hmacWithSHA_n AlgorithmIdentifier carries NULL parameters (RFC 8018 B.1.1).
150
182
  function _hmacAlgId(name) { return b.sequence([b.oid(O(name)), b.nullValue()]); }
@@ -179,6 +211,6 @@ module.exports = {
179
211
  prfNodeByName: prfNodeByName, prfNodeByOid: prfNodeByOid,
180
212
  pbkdf2ParamsSeq: pbkdf2ParamsSeq, pbes2AlgId: pbes2AlgId, parsePbkdf2Params: parsePbkdf2Params,
181
213
  requireChildren: requireChildren, seqChildren: seqChildren,
182
- cbcEncrypt: cbcEncrypt, cbcDecrypt: cbcDecrypt, pbes2Encrypt: pbes2Encrypt, CONTENT_KEYBITS: CONTENT_KEYBITS,
214
+ cbcEncrypt: cbcEncrypt, cbcDecrypt: cbcDecrypt, pbes2Encrypt: pbes2Encrypt, pbes2Decrypt: pbes2Decrypt, CONTENT_KEYBITS: CONTENT_KEYBITS,
183
215
  pbmac1AlgId: pbmac1AlgId, pbmac1: pbmac1,
184
216
  };
@@ -32,6 +32,7 @@ var pbes2 = require("./pbes2");
32
32
  var pkcs8 = require("./schema-pkcs8");
33
33
  var x509 = require("./schema-x509");
34
34
  var schemaCrl = require("./schema-crl");
35
+ var key = require("./key");
35
36
  var schemaPkcs12 = require("./schema-pkcs12");
36
37
  var pkix = require("./schema-pkix");
37
38
  var guard = require("./guard-all");
@@ -428,7 +429,123 @@ function _capWork(iterations, salt, opts, keyLength, hardCap) {
428
429
  if (keyLength != null && (keyLength < 20 || keyLength > MAX_PBMAC1_KEYLEN)) throw _err("pkcs12/bad-input", "the PBMAC1 keyLength must be in [20, " + MAX_PBMAC1_KEYLEN + "] (RFC 9579 sec. 9)");
429
430
  }
430
431
 
432
+ /**
433
+ * @primitive pki.pkcs12.open
434
+ * @signature pki.pkcs12.open(pfx, password, opts?) -> Promise<OpenResult>
435
+ * @since 0.3.12
436
+ * @status experimental
437
+ * @spec RFC 7292 sec. 5.1, RFC 9579, RFC 8018
438
+ * @defends pkcs12-unauthenticated-decrypt (CWE-347), pbes2-padding-oracle (CWE-208)
439
+ * @related pki.pkcs12.build, pki.pkcs12.verifyMac, pki.schema.pkcs12.parse
440
+ *
441
+ * Read a password-integrity PKCS#12 store: verify its MAC FIRST (RFC 7292 sec. 5.1 -- never trust a bag from a
442
+ * store whose password MAC fails), then decrypt every PBES2 privacy safe and pkcs8ShroudedKeyBag with the same
443
+ * password, returning a structured bundle `{ integrityMode, macVerified, keys, certs, crls, secrets }` -- each
444
+ * private key as PKCS#8 `PrivateKeyInfo` DER (re-validated), each certificate / CRL / secret as raw DER, all
445
+ * carrying their `friendlyName` / `localKeyId` for pairing. `pfx` is a DER `Buffer`, PEM string, or a
446
+ * `pki.schema.pkcs12.parse` result.
447
+ *
448
+ * A MAC-less store is refused (`pkcs12/no-integrity`) unless `opts.allowUnauthenticated` is set; a public-key
449
+ * integrity store is refused. Only PBES2 (AES-CBC) bags are decrypted -- a legacy PBE scheme is named and
450
+ * refused. The password is BMPString+NULL for the MAC and UTF-8 for the PBES2 bags; a wrong password fails at
451
+ * the MAC gate (`pkcs12/mac-mismatch`), and a post-MAC decrypt failure is the uniform `pkcs12/decrypt-failed`.
452
+ *
453
+ * @opts
454
+ * - `allowUnauthenticated` (boolean) -- open a MAC-less store anyway (result carries `macVerified: false`).
455
+ * - `maxIterations` (number) -- lower the PBKDF2 / MAC iteration cap for this call (downward-only).
456
+ * - `keys` (string) -- `der` (default) or `crypto` (also `pki.key.import` each private key to a CryptoKey).
457
+ * - `importAlgorithm` -- forwarded to `pki.key.import` for the ambiguous RSA / EC arms when `keys: crypto`.
458
+ * @example
459
+ * var p12 = await pki.pkcs12.build({ key: signerKeyPkcs8, cert: signerCertDer }, { password: 'changeit' });
460
+ * var store = await pki.pkcs12.open(p12, 'changeit');
461
+ * var keyDer = store.keys[0].pkcs8, certDer = store.certs[0].cert;
462
+ */
463
+ async function open(pfx, password, opts) {
464
+ opts = opts || {};
465
+ if (opts.maxIterations != null && (typeof opts.maxIterations !== "number" || !isFinite(opts.maxIterations) || opts.maxIterations < 1 || Math.floor(opts.maxIterations) !== opts.maxIterations)) {
466
+ throw _err("pkcs12/bad-input", "maxIterations must be a positive integer");
467
+ }
468
+ var m = (pfx && pfx.integrityMode !== undefined && pfx.mac !== undefined) ? pfx : schemaPkcs12.parse(_coerceDer(pfx, "pfx"));
469
+ if (m.integrityMode === "public-key") throw _err("pkcs12/bad-integrity-mode", "public-key-integrity (id-signedData) stores are not supported by open");
470
+ var macVerified = false;
471
+ if (m.integrityMode === "password") {
472
+ macVerified = await verifyMac(m, password, opts);
473
+ if (!macVerified) throw _err("pkcs12/mac-mismatch", "the PKCS#12 MAC did not verify (wrong password or a tampered store)");
474
+ } else if (!opts.allowUnauthenticated) {
475
+ throw _err("pkcs12/no-integrity", "the store carries no integrity MAC (integrityMode " + m.integrityMode + "); set opts.allowUnauthenticated to open it anyway");
476
+ }
477
+ // The PBES2 bag/safe password is UTF-8 (the pinned interop convention), distinct from the classic MAC BMPString.
478
+ var pwBytes = _pbePassword(password);
479
+ var out = { integrityMode: m.integrityMode, macVerified: macVerified, keys: [], certs: [], crls: [], secrets: [] };
480
+ var i;
481
+ for (i = 0; i < m.safeBags.length; i++) _openBag(m.safeBags[i], pwBytes, opts, out, 0);
482
+ for (i = 0; i < m.encryptedSafes.length; i++) _openEncryptedSafe(m.encryptedSafes[i], pwBytes, opts, out, 0);
483
+ if (opts.keys === "crypto") {
484
+ for (i = 0; i < out.keys.length; i++) out.keys[i].key = await key.import(out.keys[i].pkcs8, { algorithm: opts.importAlgorithm });
485
+ }
486
+ return out;
487
+ }
488
+
489
+ function _openBag(bag, pwBytes, opts, out, depth) {
490
+ switch (bag.type) {
491
+ case "keyBag":
492
+ // The open contract surfaces keys[].pkcs8 as canonical PrivateKeyInfo DER (key.import re-decodes strict
493
+ // DER). The parser accepts a BER keyBag value, so re-parse it strictly here -- guaranteeing DER exactly as
494
+ // the shrouded path does via _decryptShroudedKey's pkcs8.parse. A non-DER (BER) plaintext key is refused.
495
+ try { pkcs8.parse(bag.keyDer); }
496
+ catch (e) { throw _err("pkcs12/bad-der", "a plaintext keyBag PrivateKeyInfo is not canonical DER", e); }
497
+ out.keys.push({ pkcs8: bag.keyDer, encrypted: false, friendlyName: bag.friendlyName, localKeyId: bag.localKeyId });
498
+ break;
499
+ case "pkcs8ShroudedKeyBag":
500
+ out.keys.push({ pkcs8: _decryptShroudedKey(bag.encrypted, pwBytes, opts), encrypted: true, friendlyName: bag.friendlyName, localKeyId: bag.localKeyId });
501
+ break;
502
+ case "certBag":
503
+ out.certs.push({ cert: bag.certValue, certType: bag.certType, friendlyName: bag.friendlyName, localKeyId: bag.localKeyId });
504
+ break;
505
+ case "crlBag":
506
+ out.crls.push({ crl: bag.crlValue, crlType: bag.crlType, friendlyName: bag.friendlyName, localKeyId: bag.localKeyId });
507
+ break;
508
+ case "secretBag":
509
+ out.secrets.push({ secretTypeId: bag.secretTypeId, secretTypeName: bag.secretTypeName, secretValue: bag.secretValue, friendlyName: bag.friendlyName, localKeyId: bag.localKeyId });
510
+ break;
511
+ case "safeContentsBag":
512
+ if (depth + 1 > C.LIMITS.PKCS12_MAX_BAG_DEPTH) throw _err("pkcs12/too-deep", "safeContentsBag nesting exceeds the depth cap " + C.LIMITS.PKCS12_MAX_BAG_DEPTH);
513
+ for (var n = 0; n < (bag.nested || []).length; n++) _openBag(bag.nested[n], pwBytes, opts, out, depth + 1);
514
+ break;
515
+ default:
516
+ throw _err("pkcs12/bad-input", "unexpected bag type " + bag.type);
517
+ }
518
+ }
519
+
520
+ // A pkcs8ShroudedKeyBag is a bare EncryptedPrivateKeyInfo (RFC 5958 sec. 3): PBES2-decrypt to a PrivateKeyInfo,
521
+ // re-validated (the re-parse is the MAC-less integrity check for the bag ciphertext).
522
+ function _decryptShroudedKey(encrypted, pwBytes, opts) {
523
+ if (encrypted.encryptionAlgorithm.oid !== O("pbes2")) throw _err("pkcs12/unsupported-algorithm", "the shrouded key uses " + (encrypted.encryptionAlgorithm.name || encrypted.encryptionAlgorithm.oid) + " (only RFC 8018 PBES2 is decrypted; re-export with -keypbe AES-256-CBC)");
524
+ var der = pbes2.pbes2Decrypt(pwBytes, encrypted.encryptionAlgorithm.parameters, encrypted.encryptedData, opts, _err, "pkcs12");
525
+ try { pkcs8.parse(der); } catch (_e) { throw _err("pkcs12/decrypt-failed", "decryption failed"); }
526
+ return der;
527
+ }
528
+
529
+ // An id-encryptedData privacy safe: PBES2-decrypt the SafeContents, then re-walk it through the strict parser
530
+ // (same PKCS12_MAX_* caps) and open each recovered bag.
531
+ function _openEncryptedSafe(encSafe, pwBytes, opts, out, depth) {
532
+ if (encSafe.type !== "encryptedData") throw _err("pkcs12/unsupported-algorithm", "an " + encSafe.type + " privacy safe is not supported (only id-encryptedData under PBES2)");
533
+ var eci = encSafe.content.encryptedContentInfo;
534
+ if (eci.contentEncryptionAlgorithm.oid !== O("pbes2")) throw _err("pkcs12/unsupported-algorithm", "the privacy safe uses " + (eci.contentEncryptionAlgorithm.name || eci.contentEncryptionAlgorithm.oid) + " (only RFC 8018 PBES2 is decrypted; re-export with -certpbe AES-256-CBC)");
535
+ if (eci.encryptedContent == null) throw _err("pkcs12/bad-input", "the encrypted privacy safe has no content");
536
+ var safeContentsDer = pbes2.pbes2Decrypt(pwBytes, eci.contentEncryptionAlgorithm.parameters, eci.encryptedContent, opts, _err, "pkcs12");
537
+ // The bag/safe password MAY differ from the (already-verified) MAC password, so a decrypt under the MAC
538
+ // password can succeed with a valid pad yet yield bytes that are not a SafeContents. Collapse THAT re-parse
539
+ // failure into the same uniform pkcs12/decrypt-failed as a bad pad -- a distinguishable structural code here
540
+ // would be a padding oracle (RFC 8018 sec. 8). The strict walk's caps still fire (bounding the work).
541
+ var bags;
542
+ try { bags = schemaPkcs12.walkSafeContents(safeContentsDer); }
543
+ catch (_e) { throw _err("pkcs12/decrypt-failed", "decryption failed"); }
544
+ for (var i = 0; i < bags.length; i++) _openBag(bags[i], pwBytes, opts, out, depth);
545
+ }
546
+
431
547
  module.exports = {
432
548
  build: build,
433
549
  verifyMac: verifyMac,
550
+ open: open,
434
551
  };
@@ -437,6 +437,19 @@ function _walkAuthenticatedSafe(bytes, state, ctx) {
437
437
  // carries plaintext SafeContents (re-decoded here); the two privacy modes are
438
438
  // full CMS structures validated by the CMS module on the already-decoded node
439
439
  // -- ciphertext stays opaque inside the CMS surface.
440
+ // @internal -- re-parse a DECRYPTED privacy-safe's plaintext (a SafeContents ::= SEQUENCE OF SafeBag) through
441
+ // the SAME strict SAFE_CONTENTS walk + bag dispatch the plaintext path uses, so a decrypted blob shares every
442
+ // PKCS12_MAX_* cap and produces the identical bag records. Consumed by pki.pkcs12.open for an encrypted safe.
443
+ function walkSafeContents(der) {
444
+ // PKCS#12 content regions are normatively BER (RFC 7292 sec. 4.1) -- a decrypted SafeContents may use an
445
+ // indefinite-length SEQUENCE, exactly as the plaintext-safe path (_dispatchSafes) decodes it with ber:true.
446
+ var walked = schema.walk(SAFE_CONTENTS, asn1.decode(der, { ber: true }), NS);
447
+ var state = { budget: { remaining: C.LIMITS.PKCS12_MAX_REDECODES }, bagDepth: 0 };
448
+ var bags = [];
449
+ for (var i = 0; i < walked.items.length; i++) bags.push(_buildBag(walked.items[i].value.result, state, NS));
450
+ return bags;
451
+ }
452
+
440
453
  function _dispatchSafes(asMatch, state, ctx) {
441
454
  var safeBags = [];
442
455
  var encryptedSafes = [];
@@ -476,6 +489,7 @@ function _buildBag(rec, state, ctx) {
476
489
  if (rec.bagId === OID_KEY_BAG) {
477
490
  bag.type = "keyBag";
478
491
  bag.key = pkcs8.walkPrivateKeyInfo(rec.valueNode);
492
+ bag.keyDer = rec.valueNode.bytes; // the raw PrivateKeyInfo DER (pki.pkcs12.open surfaces it verbatim)
479
493
  return bag;
480
494
  }
481
495
  if (rec.bagId === OID_SHROUDED_KEY_BAG) {
@@ -621,4 +635,5 @@ module.exports = {
621
635
  pemDecode: pemDecode,
622
636
  pemEncode: pemEncode,
623
637
  matches: matches,
638
+ walkSafeContents: walkSafeContents,
624
639
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.3.11",
3
+ "version": "0.3.12",
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:253a6908-56c1-4a56-bdf4-33cb2035a161",
5
+ "serialNumber": "urn:uuid:a2281c9c-5058-4827-83e2-53a8a4561863",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-23T22:26:59.213Z",
8
+ "timestamp": "2026-07-24T02:15:58.035Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/pki@0.3.11",
22
+ "bom-ref": "@blamejs/pki@0.3.12",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.3.11",
25
+ "version": "0.3.12",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
29
- "purl": "pkg:npm/%40blamejs/pki@0.3.11",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.3.12",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/pki@0.3.11",
57
+ "ref": "@blamejs/pki@0.3.12",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]