Skip to content

fix(ext/node_crypto): support PBES2/AES-CBC bags in tls.createSecureContext pfx#34438

Merged
bartlomieju merged 4 commits into
mainfrom
fix/pfx-modern-pbe-34434
Jun 9, 2026
Merged

fix(ext/node_crypto): support PBES2/AES-CBC bags in tls.createSecureContext pfx#34438
bartlomieju merged 4 commits into
mainfrom
fix/pfx-modern-pbe-34434

Conversation

@bartlomieju

Copy link
Copy Markdown
Member

The p12 crate that backs op_node_load_pfx (added in #34383) only
decrypts the legacy PKCS#12 PBE algorithms — RC2-40 and 3DES with the
SHA-1 KDF. Every standard openssl pkcs12 -export invocation on
OpenSSL 3 emits PBES2 + PBKDF2 + AES-CBC for both the cert bag
envelope and the shrouded key bag, which is the shape Node accepts.
op_node_load_pfx rejected that shape with "failed to encode PFX
contents: ASN1Error { kind: Invalid }", so the only PFX file that
loaded was -legacy, which Node in turn rejects with "Unsupported
PKCS12 PFX data" — leaving no shape interoperable across runtimes.

Reimplement the bag walk in terms of p12's low-level types and add
a PBES2 path via pkcs8::pkcs5::EncryptionScheme, which handles
PBKDF2 (HMAC-SHA1/224/256/384/512) with AES-128/192/256-CBC. The
legacy PKCS#12 PBE path still uses p12::AlgorithmIdentifier:: decrypt_pbe with the BMPString password; PBES2 takes the password
as raw UTF-8, matching what OpenSSL and Node accept.

Fixes #34434

…ontext pfx

The `p12` crate only decrypts the legacy PKCS#12 PBE algorithms
(RC2-40 and 3DES with the SHA-1 KDF). Every standard `openssl
pkcs12 -export` invocation on OpenSSL 3 emits PBES2 + PBKDF2 +
AES-CBC for both the cert bag envelope and the shrouded key bag,
which `op_node_load_pfx` (added in #34383) rejected with
"failed to encode PFX contents: ASN1Error { kind: Invalid }".
The only shape that loaded was `-legacy`, which Node rejects, so
no PFX file was loadable on both runtimes.

Reimplement the bag walk in terms of `p12`'s low-level types and
add a PBES2 path via `pkcs8::pkcs5::EncryptionScheme`, which
handles PBKDF2 (HMAC-SHA1/224/256/384/512) + AES-128/192/256-CBC.
The legacy PKCS#12 PBE path still uses `p12::AlgorithmIdentifier::
decrypt_pbe` with the BMPString password; PBES2 takes the password
as raw UTF-8, matching OpenSSL and Node.

Fixes #34434.

@fibibot fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pfx_safe_bags() mirrors p12::PFX::bags() while routing ContentInfo::EncryptedData through decrypt_pfx_blob(), so legacy PKCS#12 PBE still uses bmp_password and PBES2 uses utf8_password.as_bytes() for OpenSSL 3 / Node-compatible bags. The regression test covers the modern OpenSSL default PFX alongside the existing SHA-MAC fixtures. cargo check -p deno_node_crypto passes locally. Holding approval until CI is green.

@fibibot fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI red; flake-watcher will triage. The failed test node_compat (2/3) shards passed their test suites (1217 tests passed) and then failed after hashy: failed to commit hash node_compat_9f0e7f1bc4f75807 (exit code Some(28)), so I am not treating this as PR-caused.

@fibibot

fibibot commented May 27, 2026

Copy link
Copy Markdown
Contributor

Build infra flake (Upload test results failed in two node_compat jobs), unrelated to this PR. Re-running.

@fibibot

fibibot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Code Review

Overview

Fixes a regression from #34383: op_node_load_pfx used p12::PFX::cert_bags/key_bags, which only handle the legacy PKCS#12 PBE algorithms (RC2-40 / 3DES with SHA-1 KDF). Every modern openssl pkcs12 -export invocation emits PBES2 + PBKDF2 + AES-CBC bags, so Deno rejected every standard PFX with ASN1Error: Invalid — while the only shape it did accept (-legacy) was rejected by Node. The fix reimplements the bag walk on top of p12's low-level types and routes encrypted bags through a new decrypt_pfx_blob that dispatches between p12::AlgorithmIdentifier::decrypt_pbe (BMPString password) and pkcs8::pkcs5::EncryptionScheme::decrypt (UTF-8 password) based on the algorithm OID.

Correctness

  • Algorithm dispatch is right. PKCS#12 PBE → BMPString password (RFC 7292 §B.1). PBES2 → raw UTF-8 octets (RFC 8018 §6.1.1). The comment in decrypt_pfx_blob calls this out, matching OpenSSL/Node.
  • Re-encode-then-parse trick for PBES2. yasna::construct_der(|w| alg.write(w)) followed by pkcs8::pkcs5::EncryptionScheme::from_der(&alg_der) is awkward but works — the two crates have no shared AlgorithmIdentifier type. Functional; minor inefficiency.
  • pfx_safe_bags mirrors PFX::bags() — walks the outer SafeContents, then for each ContentInfo either returns inline Data bytes or decrypts the EncryptedData envelope. Standard PKCS#12 layout.
  • MAC verify still uses auth_safe.data(&bmp_password) — a separate decryption pass that runs before the new code. In the common case auth_safe is Data (not EncryptedData), so both paths return the inline bytes and there's no duplicated decryption. Edge case: if a PFX has its outer auth_safe wrapped in EncryptedData with PBES2 (uncommon but RFC-allowed), MAC verify fails before this PR's code runs, because p12::auth_safe.data() can't handle PBES2 there either. That case remains broken — uncommon shape, not blocking.
  • Error mapping at the shrouded-key-bag site is misleading. decrypt_pfx_blob(...).ok_or(PfxLoadError::NoKey)? returns "no private key found" if PBES2 decryption fails for any reason (wrong passphrase on a modern PFX, unsupported cipher, malformed ciphertext, etc.). A user with a typo'd password would see "no key" instead of a decryption error. Worth changing to an explicit decryption-failure variant.

Security

  • Missing iteration-count cap on PBES2/PBKDF2. The existing code added PFX_MAC_ITERATIONS_CAP = 600_000 for the MAC PBKDF on the rationale that PFX bytes are attacker-controlled. PBES2 has the same property: the PBKDF2 iteration count in the EncryptionAlgorithm parameters is attacker-controlled, can be up to ~4B, and pkcs8::pkcs5::EncryptionScheme::decrypt doesn't cap it. A malicious PFX with a 4-billion-iteration PBKDF2 on a cert bag (or many bags) ties up CPU for tens of seconds per call. Same threat model the existing cap was written to defend against. Recommend extracting the PBKDF2 iteration count from the parsed EncryptionScheme and applying the same cap before decrypt. This is the main thing I'd want addressed before merging.

Code quality

  • Excellent comments. Each new function explains why it exists, what password encoding applies to which algorithm family, and the relationship to the upstream p12 crate.
  • Clean separation. pfx_content_data / decrypt_pfx_blob / pfx_safe_bags decompose cleanly along the layers of PKCS#12 (ContentInfo → SafeContents → SafeBag).
  • pbes2_oid() as a function that constructs a new ObjectIdentifier per call is a minor wart — could be LazyLock since it's compared on every bag. Allocation-light; not a hot path. Leave it or polish at will.
  • OtherContext(_) rejection in pfx_content_data matches Node behavior. Good.

Test coverage

  • New fixture localhost_modern.pfx generated with the standard openssl pkcs12 -export command (documented in tls/README.md). The test exercises PBES2/PBKDF2/AES-256-CBC end-to-end through tls.createSecureContext. Solid regression coverage for tls.createSecureContext({ pfx, passphrase }) rejects every standard openssl pkcs12 -export output with ASN1Error: Invalid (regression after #34383) #34434.
  • Existing legacy fixtures (localhost_sha1/256/384/512.pfx) keep coverage of the PKCS#12 PBE path. README updated to reflect the split.
  • Gaps:
    • No test for wrong passphrase against the modern PFX — would surface the NoKey-as-decryption-failure error mapping.
    • No test for PBES2 with AES-128 or AES-192 (only AES-256, since that's the OpenSSL 3 default). EncryptionScheme handles them uniformly, so risk is low, but a parametrized test would close the gap cheaply.
    • No DoS test for high PBKDF2 iteration counts (related to the security note above).

Risk

  • Medium-low. Reimplementing the bag walk touches the critical path for every pfx load, but legacy PBE still routes through p12::AlgorithmIdentifier::decrypt_pbe unchanged. Main risks are the PBKDF2 iteration DoS and the misleading NoKey error.

Suggestions (in priority order)

  1. Cap the PBES2/PBKDF2 iteration count before calling EncryptionScheme::decrypt, mirroring PFX_MAC_ITERATIONS_CAP. The threat model (attacker-controlled PFX bytes) is identical.
  2. Fix the misleading error mapping at the shrouded key bag site — return an explicit decryption error rather than PfxLoadError::NoKey when decrypt_pfx_blob returns None. Add a test for wrong passphrase against localhost_modern.pfx.
  3. (Nice-to-have) Add a fixture or two for PBES2 with AES-128 / AES-192 to round out cipher coverage.
  4. (Style) Consider LazyLock for pbes2_oid() — minor.

Overall: well-scoped fix with strong analysis and good test coverage of the happy path. The PBES2 iteration cap is the one substantive concern; the error mapping is a small but user-visible issue. Both are easy to address.

…rypt error

Two follow-ups to #34438 review:

- The PBKDF2 iteration count parsed from a PFX EncryptedData /
  EncryptedPrivateKeyInfo envelope is attacker-controlled (u32, up to
  ~4B) in the same way `mac_data.iterations` is. `pkcs8::pkcs5::
  EncryptionScheme::decrypt` does not cap on its own, so a malicious
  PFX could tie up CPU for tens of seconds per bag. Apply the existing
  MAC cap (renamed `PFX_PBKDF_ITERATIONS_CAP` since it now covers both
  KDFs) before delegating to `EncryptionScheme::decrypt`.

- Replace `PfxLoadError::NoKey` at the shrouded-key-bag decrypt site
  with a new `KeyDecryptFailed` variant. The previous mapping surfaced
  "no private key found" for any PBES2 failure, including a wrong
  passphrase or unsupported cipher, which is misleading.

@lunadogbot lunadogbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall

LGTM on the approach — the bag-walk reimplementation is a clean way to bolt PBES2 onto p12's existing types without forking the crate. The UTF-8 vs BMPString password split is correct and matches OpenSSL/Node behavior. Good fixture and clear comments throughout.

That said, there are three things worth addressing before merge — one security-relevant, one a behavioral edge, and one a possible silent regression.

Suggestions

1. Legacy PBE iteration count is uncapped — the cap's comment now overpromises

The constant rename to PFX_PBKDF_ITERATIONS_CAP and its new comment claim the cap covers "any PFX-controlled KDF" (keys.rs:4204-4220). But inside decrypt_pfx_blob:

Pkcs12AlgorithmIdentifier::PbewithSHAAnd40BitRC2CBC(_)
| Pkcs12AlgorithmIdentifier::PbeWithSHAAnd3KeyTripleDESCBC(_) => {
  alg.decrypt_pbe(ciphertext, bmp_password)   // no cap applied
}

p12 v0.6.3's decrypt_pbe does not cap iterations. So an attacker-supplied legacy PBE envelope with iteration count ~4B still ties up CPU — the original concern the cap was added to defend against. This was already true before the PR (only MAC was capped), but the rename + comment now imply broader coverage than the code provides.

Two reasonable fixes:

  • Read iterations out of the Pkcs12PbeParams and check the cap before calling decrypt_pbe. Easy and matches the PBES2 path's gating.
  • Or narrow the comment to acknowledge the legacy gap and file a follow-up.

2. Unencrypted KeyBag and other SafeBagKind variants now silently dropped

The new match (keys.rs:4263-4279) handles only CertBag::X509 and Pkcs8ShroudedKeyBag; everything else falls into _ => {}. The old parsed.key_bags(password) API in p12 v0.6.3 extracts unencrypted KeyBag (PKCS#8) too. Unencrypted key bags are rare but legal PFX shape (openssl pkcs12 -export -nodes or -keypbe NONE-style producers). With the new code, that PFX now silently surfaces PfxLoadError::NoKey instead of working as before. Worth at least:

  • Adding Pkcs12SafeBagKind::KeyBag(der) => key_ders.push(der.clone()), or
  • Explicit comment in the catch-all explaining the dropped variants are intentional and not interesting for the Node interop case.

A quick fixture-free test isn't easy here, but it's a one-line guard.

3. Wrong-passphrase error path differs between legacy and modern PFX

The existing test tls.createSecureContext rejects pfx with wrong passphrase expects "mac verify failure" on a legacy fixture. For a modern (PBES2) PFX with the wrong passphrase, MAC verification still succeeds (BMPString = UTF-8 for ASCII passwords, so the MAC password derivation produces a valid MAC of the auth_safe contents — the wrong-password check happens later at bag decrypt time), and the new code surfaces PfxLoadError::KeyDecryptFailed ("failed to decrypt PFX private key…"). Worth a parallel negative test against localhost_modern.pfx asserting that error string, both to lock the message and to confirm the failure mode is what you intend.

Nits

  • pbes2_oid() is called on every match arm (keys.rs:4329-4330). Other OIDs in this file are declared const (e.g. ID_SHA256_OID at line ~434). A const PBES2_OID: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.5.13"); matches the local convention and avoids the per-call allocation.
  • yasna::construct_der(|w| alg.write(w)) to re-serialize for pkcs8::pkcs5::EncryptionScheme::from_der (line 4332-4333) is a slightly awkward dance but unavoidable given p12::AlgorithmIdentifier::OtherAlg is a partial parse. A one-line comment noting "round-trip to DER so pkcs8 can parse the params" would help the next reader.
  • Adding yasna as a direct dependency is fine — already transitive via p12 0.6.3. Pinned to 0.5 so SemVer-stable.
  • Test fixture password "secret" matches the other localhost_*.pfx fixtures. ✅

Test plan I'd want to see

  • Modern PFX, correct password ✅ (covered)
  • Modern PFX, wrong password → KeyDecryptFailed ❌ (missing)
  • Modern PFX with PBKDF2 iteration count > 600k → rejected ❌ (nice-to-have, exercises the cap)
  • Legacy PFX with iteration count > 600k → rejected (only meaningful if you add the legacy cap per suggestion #1)

…rypt path

Follow-ups to #34438 review:

- Apply PFX_PBKDF_ITERATIONS_CAP to the legacy PKCS#12 PBE path too. The
  iteration count there (Pkcs12PbeParams::iterations) is attacker-controlled
  like the MAC and PBES2 KDFs, and p12::decrypt_pbe does not cap it; the
  renamed constant's comment now matches the code.

- Document that the bag-walk catch-all intentionally drops unencrypted
  KeyBags and other bag kinds. This matches the upstream p12 behaviour it
  replaces (SafeBagKind::get_key only extracts Pkcs8ShroudedKeyBag), so it is
  not a regression.

- Compare the PBES2 OID against a const arc slice instead of allocating a new
  ObjectIdentifier per bag, and note why the AlgorithmIdentifier is re-encoded
  to DER for the pkcs8/pkcs5 parser.

- Add a no-MAC modern PFX fixture and tests: a MAC'd modern PFX with a wrong
  passphrase fails at MAC verification, while a no-MAC PFX is accepted and a
  wrong passphrase surfaces as KeyDecryptFailed, exercising the PBES2
  shrouded-key path and the clarified error.

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three minor, non-blocking suggestions on an otherwise solid fix. I verified both new fixtures with openssl pkcs12 -info: localhost_modern.pfx has an EncryptedData (PBES2) cert envelope + PBES2 shrouded key, and localhost_modern_nomac.pfx has a plaintext cert bag + PBES2 shrouded key, so the error-path tests behave exactly as their comments claim.

Comment thread ext/node_crypto/keys.rs
Comment thread ext/node_crypto/keys.rs Outdated
Comment thread tests/unit_node/tls_test.ts
…est chain

Address review feedback on the PFX PBES2 support:

- The PBKDF iteration cap only covered PBKDF2; PBES2 may also use a scrypt
  KDF (pkcs5 decrypts it), whose memory-hard cost is attacker-controlled the
  same way. Bound the scrypt working set (128 * N * r) at 1 GiB.
- A decrypt failure on an EncryptedData cert envelope reported "unsupported
  PFX encryption algorithm", conflating a wrong passphrase with an actually
  unsupported algorithm. Report both cases honestly.
- Add localhost_modern_chain.pfx (leaf + RootCA via -certfile) and a test
  asserting the leaf-vs-CA split, which also covers an EncryptedData envelope
  holding multiple cert bags.
@bartlomieju
bartlomieju merged commit f424ae4 into main Jun 9, 2026
136 checks passed
@bartlomieju
bartlomieju deleted the fix/pfx-modern-pbe-34434 branch June 9, 2026 18:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tls.createSecureContext({ pfx, passphrase }) rejects every standard openssl pkcs12 -export output with ASN1Error: Invalid (regression after #34383)

3 participants