Skip to content

fix(ext/node): import/export PKCS#8 and legacy encrypted PEM private keys#33762

Merged
littledivy merged 2 commits into
denoland:mainfrom
divybot:claude/test-crypto-keygen-empty-passphrase-no-prompt
May 3, 2026
Merged

fix(ext/node): import/export PKCS#8 and legacy encrypted PEM private keys#33762
littledivy merged 2 commits into
denoland:mainfrom
divybot:claude/test-crypto-keygen-empty-passphrase-no-prompt

Conversation

@divybot

@divybot divybot commented May 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Enables test-crypto-keygen-empty-passphrase-no-prompt in node_compat suite.

Test plan

  • cargo test --test node_compat -- test-crypto-keygen-empty-passphrase-no-prompt

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

PR title and body say "enables test-crypto-keygen-empty-passphrase-no-prompt" but the diff is 305+/6- across 5 files and bundles at least four distinct features that overlap with already-approved or in-flight crypto PRs. Worth surfacing the coordination problem before this lands, because if it merges as-is at least three other approved PRs will need to be rebased or partly reverted.

Overlap matrix with the open crypto-cluster PRs

Feature This PR (#33762) Other PR Relationship
parse_legacy_encrypted_pem (Proc-Type/DEK-Info decrypt) new keys.rs:3331+ #33769 has decrypt_legacy_encrypted_pem Direct duplicate (different name, same logic)
encrypt_pkcs8_der (PKCS#8 PBES2 encrypt) new keys.rs:~3490+ #33758 added the same function (already APPROVED) Direct duplicate
decode_hex helper new in this PR #33769 uses faster_hex::hex_decode for the same job Convergent reinvention
EncryptedPrivateKeyRequiresPassphraseToDecrypt message reverts to code: ERR_MISSING_PASSPHRASE + "Passphrase required for encrypted key" #33758 + #33756 set it to OpenSSL3 string error:07880109:... (already APPROVED) Conflicting strategy
JS pre-flight isEncryptedPem + createOpenSSL3PassphraseError new in sig.ts, called at 6 sites n/a New (compensates for the message revert above)

Conflict #1 (real): error message strategy

This PR reverts the OpenSSL3 string that #33758 + #33756 + #33769 all converge on, and instead emits the OpenSSL3 string from JS via the new createOpenSSL3PassphraseError(). Either approach can satisfy Node's hasOpenSSL3 test assertions, but mixing them — Rust emits ERR_MISSING_PASSPHRASE, then JS also preflight-throws OpenSSL3 string for the same input — means the two paths fire from different control flow and the user sees a different error class depending on which one wins. Pick one:

  • (A) Keep the Rust-side OpenSSL3 string (status quo across the three approved PRs). Drop the JS preflight, drop the variant-message revert here.
  • (B) Move the OpenSSL3 string entirely to JS (this PR's direction). Then revert #33758's and #33769's Rust message changes too — and explain why JS-layered error reporting is preferred over Rust-emitted errors for this surface.

Doing both — Rust emits ERR_MISSING_PASSPHRASE + JS preflight throws OpenSSL3 — is the worst combination because the Rust path is now unreachable for this case (JS catches it first), so the Rust message is dead code that can still drift from the JS one.

Conflict #2 (real): duplicated parse_legacy_encrypted_pem vs #33769's decrypt_legacy_encrypted_pem

Whichever lands first wins. If #33769 lands first, the rebase here will need to either drop this implementation or reconcile differences (#33769 returns the label as &'a str from the input PEM; this one's signature is the same shape but parsing details differ — iv.len() < 8 || iv.len() != expected_iv_len here vs iv.len() != expected_iv_len alone in #33769). Worth coordinating into one canonical implementation rather than racing.

NB: #33769 has open security feedback on padding-oracle resistance that I posted earlier today (pullrequestreview-4212838732). That feedback applies equally to this PR's decrypt_legacy_pem_data since both use cbc::Decryptor::decrypt_padded_vec_mut::<Pkcs7> with block-padding 0.3.3's non-constant-time PKCS#7 unpad.

Conflict #3 (real): duplicated encrypt_pkcs8_der vs #33758's encrypt_pkcs8_der

Already approved in #33758. Both use 100,000 PBKDF2 iterations and aes-128/256-cbc, so the implementations are essentially identical. Can be deleted from this PR after #33758 lands.

Style nits (non-blocking, conditional on the coordination above)

  • isEncryptedPem builds a string via head += String.fromCharCode(view[i]) over the first 256 bytes — that's Latin-1, not strictly ASCII, but works because PEM markers are ASCII. new TextDecoder("ascii", { fatal: false }).decode(view.subarray(0, len)) is the more idiomatic shape and avoids a per-byte JS loop.
  • The isEncryptedPem + createOpenSSL3PassphraseError preflight is duplicated verbatim at six call sites in sig.ts (Sign / Verify / signOneShot / verifyOneShot × private/public branches). Centralizing into prepareAsymmetricKey would collapse those six 6-line blocks into one — and would mean the centralized path also covers createPrivateKey/createPublicKey / createSign / etc., not just the sig.ts call sites.
  • createOpenSSL3PassphraseError sets code: ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED — worth confirming that's the exact code Node emits at this point (Node usually attaches code from the OpenSSL function name; whether it's ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED specifically vs a different ERR_OSSL_* code matters for tests that branch on err.code).

Summary

Not blocking on substance — the individual pieces are reasonable — but strongly suggest coordinating with #33758 / #33756 / #33769 / #33770 before merging any of them. Easiest path: pick one error-message strategy (A or B above), squash the duplicated encrypt_pkcs8_der and parse_legacy_encrypted_pem into one PR each, and drop the duplicates from the others. CI 133/136 success here, but CI doesn't catch "this PR conflicts with another open PR's already-approved diff" — that hits at merge time.

@divybot
divybot force-pushed the claude/test-crypto-keygen-empty-passphrase-no-prompt branch from f6cabbd to c332b60 Compare May 1, 2026 20:34

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

Major cleanup since my coordination COMMENT — three of the four conflict points are resolved. Walking through:

Resolved ✓

  1. Error-message strategy collision is gone. Previous head reverted the OpenSSL3 string and added a JS-side preflight to compensate; this head keeps the OpenSSL3 string on EncryptedPrivateKeyRequiresPassphraseToDecrypt (consistent with #33758 already on main, plus #33769) and just adds #[property("code" = "ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED")]. That's the cleaner shape — Rust owns the message + code, JS doesn't preflight. (Same recommendation I made on #33772 for the DH error variants.)
  2. JS-side preflight is gone. The previous isEncryptedPem + createOpenSSL3PassphraseError + 6 duplicated call sites in sig.ts are dropped. Only change in keys.ts is the one-line res.passphrase arg added to the op_node_create_public_key call at line 711. Massive scope reduction — diff went from 305+/6-/5 files to 184+/4-/3 files.
  3. encrypt_pkcs8_der duplication is gone. The hunk header @@ -3349,6 +3394,139 @@ fn encrypt_pkcs8_der( shows the new code added after the existing encrypt_pkcs8_der (now on main from the merged #33758), not redefining it. ✓

Still unresolved: duplicate of #33769

parse_legacy_encrypted_pem here vs decrypt_legacy_encrypted_pem in #33769 are the same function with different names — both parse Proc-Type:/DEK-Info: PEMs and decrypt with evp_bytes_to_key + CBC, both support the same four ciphers (AES-128/192/256-CBC + DES-EDE3-CBC). Whichever lands first wins; the second to rebase will need to drop its copy. Since you author both, easiest path is to consolidate into one PR (probably the smaller #33769) and have this PR depend on it.

Differences worth noting if you do consolidate:

  • Header parsing — this PR uses lines.next() for fixed-position Proc-Type: and DEK-Info: lines; #33769 uses an open-ended loop that bails on unknown headers. The looped version is slightly more robust to extra blank lines / unknown headers, but yours is simpler and still correct for the canonical format OpenSSL emits.
  • decode_hex here vs faster_hex::hex_decode in #33769: the faster_hex version is already a transitive dep so it doesn't add weight. The handwritten one in this PR is fine but redundant.
  • iv.len() < 8 || iv.len() != expected_iv_len — the < 8 check is redundant because expected_iv_len is at least 8 across all supported ciphers (8 for DES-EDE3-CBC, 16 for AES-CBC), so the second condition subsumes it. Minor cosmetic.

Same security caveats from #33769 apply here

Forwarding the security walk I posted on #33769 — both PRs share decrypt_legacy_pem_data shape using cbc::Decryptor::decrypt_padded_vec_mut::<Pkcs7> from block-padding 0.3.3, whose Pkcs7::unpad short-circuits on first padding-byte mismatch. That's a padding-oracle surface in adversarial server-side contexts (not for one-shot local PEM imports). And the format-level weaknesses (single-pass MD5 EVP_BytesToKey, no MAC, salt = first 8 bytes of IV) are inherent to legacy PEM but worth a // SECURITY: block on parse_legacy_encrypted_pem flagging them as known-weak.

CI

131/135 success, 1 in-progress, 2 skip, 1 cla pending. No failures. The one in-progress is just the tail of the build matrix.

Not blocking from my side once #33769 sequences before this (or this absorbs it). The substance is solid.

@divybot
divybot force-pushed the claude/test-crypto-keygen-empty-passphrase-no-prompt branch from c332b60 to 865cd2d Compare May 1, 2026 22:05

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

LGTM at 865cd2d. The prerequisite coordination from my prior COMMENT has resolved cleanly on main:

  • #33756 (variant split for EncryptedPkcs8DerRequiresPassphrase) merged at 15:46.
  • #33770 (encrypted-key passphrase via prepareKey) merged at 18:49.

With those landed, this PR's diff against current main is exactly what I'd reviewed at c332b60 — single new commit on top, 1 ahead / 11 behind. The 184-line keys.rs patch is unchanged, the JS-side passphrase-threading at keys.ts:710 is unchanged, and the test enable is unchanged.

Substance recap (verified earlier, still holds)

  • EncryptedPrivateKeyRequiresPassphraseToDecrypt keeps the OpenSSL3 string + adds code: ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED. Aligns with the test fixtures and the path the (now-merged) #33758 established.
  • Legacy PEM parse path at keys.rs:855–907 correctly handles RSA/EC/PKCS#8 PEM labels, with passphrase-required → EncryptedPrivateKeyRequiresPassphraseToDecrypt.
  • Fallback for unencrypted keys when a passphrase is given: from_pkcs8_encrypted_pemfrom_pem → label-dispatched DER parse. Matches Node's "ignore passphrase if not encrypted" semantic.
  • CI: 133/136 success, 2 skip, 1 license/cla pending. No failures.

Remaining coordination

The parse_legacy_encrypted_pem here vs decrypt_legacy_encrypted_pem in #33769 (still open) is the one outstanding overlap. Both implement the same RFC 1421 / Proc-Type+DEK-Info decryption; the chosen mover-first wins and the second author cleans up the duplicate. Both authors are divybot, so this is fully within author control to resolve. Not blocking the merge of either PR — the duplicate just becomes obvious-noise in the rebase pass.

Security caveats (carry-forward from #33769 review, applicable here too)

The decrypt_legacy_pem_data path here uses cbc::Decryptor::decrypt_padded_vec_mut::<Pkcs7> from block-padding 0.3.x, whose Pkcs7::unpad short-circuits on first padding-byte mismatch — classic Vaudenay-style padding-oracle surface in adversarial network contexts. Mitigated locally by mapping all decryption errors to the same InvalidEncryptedPemPrivateKey variant, but the timing channel itself isn't closed without a constant-time PKCS#7 unpad.

The other format-level weaknesses (MD5-based evp_bytes_to_key with no iteration count, salt = first 8 bytes of IV, CBC + no MAC) are inherent to RFC 1421 and not bugs in this implementation. Worth a // SECURITY: block on parse_legacy_encrypted_pem (the one #33769 added) so future readers see the threat model deliberately documented — but that can land in either this PR or a follow-up cleanup; not blocking.

@bartlomieju

Copy link
Copy Markdown
Member

@divybot please address non-const time comparisons pointed out by @fibibot

divybot and others added 2 commits May 2, 2026 20:09
…keys

Enables tests/node_compat/runner/suite/test/parallel/test-crypto-keygen-empty-passphrase-no-prompt.js

Co-authored-by: Divy Srivastava <[email protected]>
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]>
@divybot
divybot force-pushed the claude/test-crypto-keygen-empty-passphrase-no-prompt branch from 865cd2d to 9af2937 Compare May 2, 2026 15:07

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

LGTM (re-affirming). Single new commit since my APPROVE at 865cd2d: fix(ext/node_crypto): constant-time PKCS#7 unpad in legacy PEM decrypt — exactly the Vaudenay-padding-oracle fix I asked for in the earlier review. Walked the new pkcs7_unpad_ct implementation:

  • Switches block-padding's Pkcs7 (which short-circuits on first mismatch) to NoPadding and does the unpadding manually with subtle::Choice constant-time primitives. ✓
  • Loop length is block_size (constant), not pad_byte (data-dependent) — so loop iteration count doesn't leak the claimed pad length. ✓
  • u8_gt_ct(a, b) uses (b - a) as i16 → cast to u16 → high bit. Correctly returns Choice(1) iff a > b for u8 inputs (verified for diff=-1 → 0xFFFF >> 15 = 1; diff=1 → 0; diff=0 → 0). ✓
  • pad_in_range = u8_le_ct(pad_byte, block_size as u8) correctly rejects pad_byte=0 (via pad_nonzero AND) and pad_byte > block_size (via u8_le_ct). ✓
  • Per-position check (is_in_pad → byte_matches) AND-combined into a single Choice — the only data-dependent branch is the final if !bool::from(valid), which leaks only the binary accept/reject (an inherent oracle the format itself exposes via decryption success).
  • subtle is already a workspace dep in ext/node_crypto/Cargo.toml — no new dependencies introduced.
  • The doc comment is updated to accurately reflect that the decrypt path itself is no longer a timing oracle, while the format-level weaknesses (single-round MD5 KDF, no MAC, salt = first 8 bytes of IV) are inherent to RFC 1421 and remain.

CI

134/136 success, 2 skip, 0 fail at this head — including the new parallel/test-crypto-keygen-empty-passphrase-no-prompt.js enable plus all related tests/unit_node/crypto/* shards.

Nothing else to add — substance and security posture are now both right.

@littledivy
littledivy merged commit 15788d2 into denoland:main May 3, 2026
136 checks passed
divybot added a commit to divybot/deno that referenced this pull request May 3, 2026
…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 added a commit to divybot/deno that referenced this pull request May 3, 2026
…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]>
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.

4 participants