Skip to content

fix(ext/node): decrypt legacy Proc-Type/DEK-Info encrypted PEM private keys#33769

Merged
littledivy merged 3 commits into
denoland:mainfrom
divybot:claude/test-crypto-keygen-async-named-elliptic-curve-encrypted
May 3, 2026
Merged

fix(ext/node): decrypt legacy Proc-Type/DEK-Info encrypted PEM private keys#33769
littledivy merged 3 commits into
denoland:mainfrom
divybot:claude/test-crypto-keygen-async-named-elliptic-curve-encrypted

Conversation

@divybot

@divybot divybot commented May 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Enables test-crypto-keygen-async-named-elliptic-curve-encrypted in node_compat suite.

Test plan

  • cargo test --test node_compat -- test-crypto-keygen-async-named-elliptic-curve-encrypted

@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.

Security-focused walk through decrypt_legacy_encrypted_pem. The implementation faithfully follows the legacy OpenSSL PEM format (RFC 1421-style with Proc-Type: / DEK-Info:), and the header-parsing path is reasonably tight. CI 133/136 success.

No non-constant-time comparisons on secret data

Went through every comparison in the new code. All string/byte comparisons (proc_type != Some("4,ENCRYPTED"), cipher_name match arms, dek_info.split_once(',')) operate on the public PEM header — not on derived keys, plaintext, or padding bytes. No timing channel introduced at the parsing layer. ✓

Real concern: PKCS#7 unpadding is not constant-time (potential padding oracle)

cbc::Decryptor::<…>::new_from_slices(...).decrypt_padded_vec_mut::<Pkcs7>(...) delegates to block-padding 0.3.3's Pkcs7::unpad, which short-circuits on the first padding-byte mismatch. CBC + early-exit padding check is the classic Vaudenay-2002 padding oracle surface. Mitigations already in place help but don't fully close the channel:

  • All decryption failures map to one error variant (InvalidEncryptedPemPrivateKey) — good; the user can't distinguish "wrong cipher" / "wrong passphrase" / "wrong padding" / "valid padding but malformed inner DER" through the error class. ✓
  • But timing alone can still leak: a malformed-padding ciphertext returns before the inner-DER parse, while a valid-padding-but-malformed-DER ciphertext does the full PKCS#7 unpad + DER parse before erroring. That's the timing differential a padding oracle exploits.

For the one-shot local-PEM use case (a developer calling crypto.createPrivateKey({ key: pemString, passphrase }) with their own key on their own machine) this isn't reachable. But for server-side use cases — e.g., a service that imports user-supplied encrypted PEMs and returns any observable signal (timing, error class, retry behaviour) per attempt — this is exploitable. Worth either:

  1. Switching to a constant-time PKCS#7 unpad if available (the subtle crate has variants), or
  2. Adding an explicit doc note that crypto.createPrivateKey with legacy Proc-Type: PEM should not be used in network-exposed adversarial contexts.

Inherent legacy-format weaknesses worth documenting

These aren't bugs in this PR — they're properties of the format being adopted — but they're now Deno-supported attack surface:

  1. evp_bytes_to_key is MD5-based, single-pass with no iteration count (keys.rs:3293–3317). Brute-force / dictionary attacks on passphrases are trivial by modern standards. PBKDF1/MD5 has been deprecated since the early 2000s.
  2. Salt = first 8 bytes of the IV (keys.rs:3431–3433). This isn't a bug — it's the spec — but it means salt and IV aren't independently random, so the KDF and the cipher share entropy. For DES-EDE3-CBC where the IV is exactly 8 bytes, salt = entire IV.
  3. No MAC, CBC-only. Ciphertext is malleable; without authentication, the padding oracle is the only "is this decryption valid?" check.

A // SECURITY: comment block at the top of decrypt_legacy_encrypted_pem flagging these as known-weak (and pointing users at PKCS#8/PBES2 for new keys) would help future readers and document the threat model deliberately.

Other concerns I checked and discarded

  • IV length check ordering (keys.rs:3434–3436): iv.len() != expected_iv_len runs before salt.copy_from_slice(&iv[..8]), and min expected_iv_len is 8 across all supported ciphers, so the slice is always in-bounds. ✓
  • Hex-decode and base64-decode: both errors mapped to a single error variant; no panic path on malformed input. ✓
  • pem.contains("Proc-Type:") as the routing predicate (keys.rs:1378): substring match against the full PEM, including base64 body. Base64 alphabet has no :, so a body-level false positive isn't constructible from valid base64. ✓
  • End label not verified against begin label: for line in lines { if line.starts_with("-----END ") { break; } accepts a mismatched END label (e.g. BEGIN RSA PRIVATE KEY paired with END EC PRIVATE KEY). The downstream DER parse for the assumed label will reject mismatched content with InvalidPkcs1PrivateKey/InvalidSec1PrivateKey/etc., so it's not a security issue, just lenient. Robustness nit.
  • Header injection / parser confusion: only Proc-Type: and DEK-Info: are accepted; any other header line is rejected with InvalidPemPrivateKey. ✓

Summary

Not blocking from a substance standpoint — the code is correct for the spec. The one finding worth a follow-up before merge is the padding-oracle-resistant unpadding question (constant-time PKCS#7 vs current block-padding 0.3.3 short-circuit behaviour). Whether that ships in this PR or as a // TODO(security) comment + tracking issue is a maintainer call. The other items (weak KDF, salt=IV[..8], no MAC) are format-level constraints worth documenting as known-weak in the function header.

@divybot
divybot force-pushed the claude/test-crypto-keygen-async-named-elliptic-curve-encrypted branch 2 times, most recently from 0370b31 to 43b6488 Compare May 1, 2026 23:28

@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.

Re-reviewed at 43b6488. The new // SECURITY: block on decrypt_legacy_encrypted_pem (keys.rs:3438–3457 per the hunk header) is exactly the disclosure I was asking for in my prior security review — it covers all four format-level weaknesses (MD5 EVP_BytesToKey with no iteration count, salt = first 8 bytes of IV, CBC + no MAC, PKCS#7 padding non-constant-time in block-padding 0.3.x), correctly notes that the single-error-variant mapping is a partial mitigation but doesn't close the timing channel, and gives clear usage guidance: "Callers should treat this path as 'import a local key file', not 'decrypt attacker-supplied PEMs as a service'."

That's the right level of resolution given the constraints — implementing a constant-time PKCS#7 unpad would require either a subtle-based reimplementation or upgrading block-padding, neither of which belongs in this PR. The documentation makes the threat model explicit so future readers (and downstream auditors) can reason about which call sites are safe.

No other substance changes from my prior pass — decrypt_legacy_encrypted_pem shape is the same (header parse, PBES1 KDF via evp_bytes_to_key, AES-CBC / 3DES decrypt with PKCS#7 unpad), and the public-key path at keys.rs:1382 still routes through the private-key handle and derives via to_public_key(). All my non-blocking nits from before still apply only as future-cleanup ideas (END label not verified against BEGIN, etc.).

CI

Fresh CI run after the force-push. Release shards across all 4 platforms are green for both test node_compat and test unit_node; lint debug linux/macos green, lint debug windows in flight. Debug shards on macos-aarch64 / linux-aarch64 / linux-x86_64 still running. Holding COMMENT one more cycle until those land — but barring a surprise red, this is APPROVE-ready from my side.

The coordination point with #33762 (which has the parallel parse_legacy_encrypted_pem) still stands: pick one canonical implementation across the two PRs before both merge.

divybot added a commit to divybot/deno that referenced this pull request May 2, 2026
Replace block-padding's Pkcs7 (which short-circuits on first padding-byte
mismatch) with a branchless unpad that inspects all bytes of the last
block on every call. Closes the Vaudenay-style padding-oracle timing
channel called out by @fibibot on PR denoland#33762 / denoland#33769.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…ve-encrypted

The legacy Proc-Type/DEK-Info encrypted PEM private key decryption needed
to pass this test was added to ext/node_crypto/keys.rs in denoland#33762.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@divybot
divybot force-pushed the claude/test-crypto-keygen-async-named-elliptic-curve-encrypted branch from 43b6488 to aa017ee Compare May 3, 2026 04:14
…ey path

createPublicKey on a wrapped { key, passphrase } whose key is a legacy
encrypted PEM (Proc-Type: 4,ENCRYPTED + DEK-Info) was failing because
decode_pem_lenient base64-decodes everything between BEGIN/END that doesn't
start with "-----", so the DEK-Info header bytes broke parsing and surfaced
as "invalid PEM public key". Detect Proc-Type early and dispatch to the
private key path (which already handles the legacy decryption added in
denoland#33762), then derive the public key from it.

Required by test-crypto-keygen-async-named-elliptic-curve-encrypted, where
the SEC1 EC private key is exported encrypted with AES-128-CBC and then
fed to verify() as { key, passphrase }.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

@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.

Single new commit since my prior security walk: 23f1f35 — "fix(ext/node_crypto): route Proc-Type/DEK-Info PEMs through private key path". Substance is sound: createPublicKey({ key: encryptedPem, passphrase }) was failing because decode_pem_lenient base64-decodes the entire PEM body including the Proc-Type: 4,ENCRYPTED / DEK-Info: AES-128-CBC,... header lines (those aren't valid base64), so the parse failed at the public-key path before the encrypted-PEM decryption ever ran. The fix detects pem.contains("Proc-Type: 4,ENCRYPTED") early in the public-key path, routes through new_asymmetric_private_key_from_js (which knows how to handle the legacy format), then converts back via to_public_key(). Right shape — the alternative would have been to teach decode_pem_lenient to skip non-base64 header lines, which is broader surface than this targeted route.

The earlier security disclosure on decrypt_legacy_encrypted_pem is unchanged and still adequate.

CI failure is from main, not from this PR

The 9 build debug * shard failures across linux/macos/windows are all the same panic:

thread 'main' panicked at libs/core/runtime/jsruntime.rs:2564:9:
Failed to initialize JsRuntime for snapshotting: CoreError(JsBox(JsErrorBox { class: "Error", message: "Specifier \"ext:deno_web/08_text_encoding.js\" was not passed as an extension module and was not included in the snapshot." }))

This PR's diff only touches ext/node_crypto/keys.rs (+19/-0) and tests/node_compat/config.jsonc (+1) — neither of which references 08_text_encoding.js. So this is not a regression introduced by 33769.

Verified that main itself is currently broken: the most recent main CI run (f14cb96, in_progress) and the previous two completed runs (349f015 from PR #33795, 09c6782 from PR #33708) all panic with the same 08_text_encoding.js was not passed as an extension module snapshot error. Main is in a broken-build state right now, and every PR that merges main inherits the breakage.

The likely culprit is one of the recent lazy_loaded_js conversions that converted 08_text_encoding.js from ESM to script form without updating all consumers — same shape as the OffscreenCanvas regression that hit #33760. Probably worth a separate fix-main PR before any of the in-flight feature PRs can land cleanly.

Not blocking on substance — happy to come back once main is unbroken and 33769's CI re-runs cleanly. PR is APPROVE-shape from my side.

@littledivy
littledivy merged commit 913f97b into denoland:main May 3, 2026
136 checks passed
littledivy added a commit that referenced this pull request May 18, 2026
## Summary

Enables `parallel/test-crypto-keygen-async-rsa.js` in the node_compat
suite. The legacy PEM (Proc-Type/DEK-Info) encrypted RSA private key
import/export paths required by this test were already implemented in
`parse_legacy_encrypted_pem` and `encrypt_private_key_pem`
(`ext/node_crypto/keys.rs`); the test was left ignored after the EC
counterpart was enabled in #33769.

Verified locally against a 512-bit RSA key generated via
`generateKeyPair('rsa', { ..., privateKeyEncoding: { type: 'pkcs1',
format: 'pem', cipher: 'aes-256-cbc', passphrase: 'secret' }})`:

- Generated PEM matches `pkcs1EncExp('AES-256-CBC')` (Proc-Type /
DEK-Info headers, 64-char base64 body).
- Sign without passphrase throws
`ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED` — matches the OpenSSL 3
expectation in the test.
- `publicEncrypt`/`privateDecrypt` and sign/verify round-trip correctly
when the passphrase is supplied.

## Test plan

- [x] `cargo test --test node_compat test-crypto-keygen-async-rsa`
passes locally.

Closes denoland/orchid#134

Co-authored-by: divybot <[email protected]>
Co-authored-by: Divy Srivastava <[email protected]>
littledivy added a commit to crowlKats/deno that referenced this pull request Jun 10, 2026
…e keys (denoland#33769)

Enables `test-crypto-keygen-async-named-elliptic-curve-encrypted` in
node_compat suite.

Co-authored-by: Divy Srivastava <[email protected]>
littledivy added a commit to crowlKats/deno that referenced this pull request Jun 10, 2026
…34208)

## Summary

Enables `parallel/test-crypto-keygen-async-rsa.js` in the node_compat
suite. The legacy PEM (Proc-Type/DEK-Info) encrypted RSA private key
import/export paths required by this test were already implemented in
`parse_legacy_encrypted_pem` and `encrypt_private_key_pem`
(`ext/node_crypto/keys.rs`); the test was left ignored after the EC
counterpart was enabled in denoland#33769.

Verified locally against a 512-bit RSA key generated via
`generateKeyPair('rsa', { ..., privateKeyEncoding: { type: 'pkcs1',
format: 'pem', cipher: 'aes-256-cbc', passphrase: 'secret' }})`:

- Generated PEM matches `pkcs1EncExp('AES-256-CBC')` (Proc-Type /
DEK-Info headers, 64-char base64 body).
- Sign without passphrase throws
`ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED` — matches the OpenSSL 3
expectation in the test.
- `publicEncrypt`/`privateDecrypt` and sign/verify round-trip correctly
when the passphrase is supplied.

## Test plan

- [x] `cargo test --test node_compat test-crypto-keygen-async-rsa`
passes locally.

Closes denoland/orchid#134

Co-authored-by: divybot <[email protected]>
Co-authored-by: Divy Srivastava <[email protected]>
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.

3 participants