fix(ext/node_crypto): support PBES2/AES-CBC bags in tls.createSecureContext pfx#34438
Conversation
…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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
|
Build infra flake ( |
Code ReviewOverviewFixes a regression from #34383: Correctness
Security
Code quality
Test coverage
Risk
Suggestions (in priority order)
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
left a comment
There was a problem hiding this comment.
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
iterationsout of thePkcs12PbeParamsand check the cap before callingdecrypt_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 declaredconst(e.g.ID_SHA256_OIDat line ~434). Aconst 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 forpkcs8::pkcs5::EncryptionScheme::from_der(line 4332-4333) is a slightly awkward dance but unavoidable givenp12::AlgorithmIdentifier::OtherAlgis a partial parse. A one-line comment noting "round-trip to DER so pkcs8 can parse the params" would help the next reader.- Adding
yasnaas a direct dependency is fine — already transitive viap12 0.6.3. Pinned to0.5so SemVer-stable. - Test fixture password "secret" matches the other
localhost_*.pfxfixtures. ✅
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
left a comment
There was a problem hiding this comment.
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.
…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.
The
p12crate that backsop_node_load_pfx(added in #34383) onlydecrypts the legacy PKCS#12 PBE algorithms — RC2-40 and 3DES with the
SHA-1 KDF. Every standard
openssl pkcs12 -exportinvocation onOpenSSL 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_pfxrejected that shape with "failed to encode PFXcontents: ASN1Error { kind: Invalid }", so the only PFX file that
loaded was
-legacy, which Node in turn rejects with "UnsupportedPKCS12 PFX data" — leaving no shape interoperable across runtimes.
Reimplement the bag walk in terms of
p12's low-level types and adda PBES2 path via
pkcs8::pkcs5::EncryptionScheme, which handlesPBKDF2 (HMAC-SHA1/224/256/384/512) with AES-128/192/256-CBC. The
legacy PKCS#12 PBE path still uses
p12::AlgorithmIdentifier:: decrypt_pbewith the BMPString password; PBES2 takes the passwordas raw UTF-8, matching what OpenSSL and Node accept.
Fixes #34434