fix(ext/node): import/export PKCS#8 and legacy encrypted PEM private keys#33762
Conversation
fibibot
left a comment
There was a problem hiding this comment.
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)
isEncryptedPembuilds a string viahead += 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+createOpenSSL3PassphraseErrorpreflight is duplicated verbatim at six call sites insig.ts(Sign / Verify / signOneShot / verifyOneShot × private/public branches). Centralizing intoprepareAsymmetricKeywould collapse those six 6-line blocks into one — and would mean the centralized path also coverscreatePrivateKey/createPublicKey/createSign/ etc., not just the sig.ts call sites. createOpenSSL3PassphraseErrorsetscode: ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED— worth confirming that's the exact code Node emits at this point (Node usually attachescodefrom the OpenSSL function name; whether it'sERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLEDspecifically vs a differentERR_OSSL_*code matters for tests that branch onerr.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.
f6cabbd to
c332b60
Compare
fibibot
left a comment
There was a problem hiding this comment.
Major cleanup since my coordination COMMENT — three of the four conflict points are resolved. Walking through:
Resolved ✓
- 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.) - JS-side preflight is gone. The previous
isEncryptedPem+createOpenSSL3PassphraseError+ 6 duplicated call sites insig.tsare dropped. Only change inkeys.tsis the one-lineres.passphrasearg added to theop_node_create_public_keycall at line 711. Massive scope reduction — diff went from 305+/6-/5 files to 184+/4-/3 files. encrypt_pkcs8_derduplication is gone. The hunk header@@ -3349,6 +3394,139 @@ fn encrypt_pkcs8_der(shows the new code added after the existingencrypt_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-positionProc-Type:andDEK-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_hexhere vsfaster_hex::hex_decodein #33769: thefaster_hexversion 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< 8check is redundant becauseexpected_iv_lenis 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.
c332b60 to
865cd2d
Compare
fibibot
left a comment
There was a problem hiding this comment.
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)
EncryptedPrivateKeyRequiresPassphraseToDecryptkeeps the OpenSSL3 string + addscode: 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–907correctly handles RSA/EC/PKCS#8 PEM labels, with passphrase-required →EncryptedPrivateKeyRequiresPassphraseToDecrypt. - Fallback for unencrypted keys when a passphrase is given:
from_pkcs8_encrypted_pem→from_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.
…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]>
865cd2d to
9af2937
Compare
fibibot
left a comment
There was a problem hiding this comment.
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'sPkcs7(which short-circuits on first mismatch) toNoPaddingand does the unpadding manually withsubtle::Choiceconstant-time primitives. ✓ - Loop length is
block_size(constant), notpad_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 tou16→ 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 (viapad_nonzeroAND) and pad_byte > block_size (viau8_le_ct). ✓- Per-position check (
is_in_pad → byte_matches) AND-combined into a singleChoice— the only data-dependent branch is the finalif !bool::from(valid), which leaks only the binary accept/reject (an inherent oracle the format itself exposes via decryption success). subtleis already a workspace dep inext/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.
…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]>
…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]>
Summary
Enables
test-crypto-keygen-empty-passphrase-no-promptin node_compat suite.Test plan
cargo test --test node_compat -- test-crypto-keygen-empty-passphrase-no-prompt