Repository: denoland/deno
Pull request: fix(ext/node): support encrypted PEM export and deprecated hash option in crypto keygen

## Summary

- Fix `generateKeyPair` for `rsa-pss` to fall back to deprecated `hash` option when `hashAlgorithm` is not provided, matching Node.js behavior
- Implement encrypted PEM private key export with legacy OpenSSL format (Proc-Type/DEK-Info headers) using EVP_BytesToKey key derivation
- Support AES-128-CBC, AES-192-CBC, AES-256-CBC, and DES-EDE3-CBC ciphers for PEM encryption
- Wire up `cipher`/`passphrase` options from `KeyObject.export()` through to the Rust op
- Add node_compat test entries for `test-crypto-keygen-deprecation`, `test-crypto-keygen-bit-length` (ignored: Rust dsa crate limitation), and `test-crypto-keygen-async-rsa` (ignored: legacy PEM decryption not yet supported)

## Test plan

- [x] `test-crypto-keygen-deprecation.js` passes (was failing due to missing `hash` → `hashAlgorithm` fallback)
- [x] `test-crypto-keygen-bit-length.js` ignored with reason (dsa crate only supports fixed key sizes)
- [x] `test-crypto-keygen-async-rsa.js` ignored with reason (legacy PEM decryption not yet implemented)
- [x] Encrypted PEM output matches expected format (Proc-Type/DEK-Info headers, correct base64 line wrapping)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Changed files:
filename: ext/node/polyfills/internal/crypto/keygen.ts
patch: @@ -1093,15 +1093,15 @@ function createJob(mode, type, options) {
         return op_node_generate_rsa_pss_key(
           modulusLength,
           publicExponent,
-          hashAlgorithm,
+          hashAlgorithm ?? hash,
           mgf1HashAlgorithm ?? mgf1Hash,
           saltLength,
         );
       } else {
         return op_node_generate_rsa_pss_key_async(
           modulusLength,
           publicExponent,
-          hashAlgorithm,
+          hashAlgorithm ?? hash,
           mgf1HashAlgorithm ?? mgf1Hash,
           saltLength,
         );

filename: ext/node/polyfills/internal/crypto/keys.ts
patch: @@ -954,10 +954,17 @@ export class PrivateKeyObject extends AsymmetricKeyObject {
     const {
       format,
       type,
+      cipher,
+      passphrase,
     } = parsePrivateKeyEncoding(options, this.asymmetricKeyType);
 
     if (format === "pem") {
-      return op_node_export_private_key_pem(this[kHandle], type);
+      return op_node_export_private_key_pem(
+        this[kHandle],
+        type,
+        cipher ?? null,
+        passphrase != null ? passphrase.toString() : null,
+      );
     } else {
       return Buffer.from(op_node_export_private_key_der(this[kHandle], type));
     }

filename: ext/node_crypto/keys.rs
patch: @@ -3120,13 +3120,123 @@ pub enum ExportPrivateKeyPemError {
   #[class(generic)]
   #[error(transparent)]
   Der(#[from] der::Error),
+  #[class(type)]
+  #[error("{0}")]
+  UnsupportedCipher(String),
+  #[class(type)]
+  #[error(
+    "cipher and passphrase must both be provided for encrypted key export"
+  )]
+  MissingCipherOrPassphrase,
+}
+
+/// Derive an encryption key from a passphrase and salt using the legacy
+/// OpenSSL EVP_BytesToKey algorithm (MD5-based). This is used for the
+/// traditional PEM encryption format (Proc-Type/DEK-Info headers).
+fn evp_bytes_to_key(
+  passphrase: &[u8],
+  salt: &[u8; 8],
+  key_len: usize,
+) -> Vec<u8> {
+  use digest::Digest;
+
+  let mut key = Vec::with_capacity(key_len);
+  let mut prev_hash: Option<[u8; 16]> = None;
+
+  while key.len() < key_len {
+    let mut hasher = md5::Md5::new();
+    if let Some(ref prev) = prev_hash {
+      hasher.update(prev);
+    }
+    hasher.update(passphrase);
+    hasher.update(salt);
+    let hash: [u8; 16] = hasher.finalize().into();
+    key.extend_from_slice(&hash);
+    prev_hash = Some(hash);
+  }
+
+  key.truncate(key_len);
+  key
+}
+
+/// Encrypt DER data and format as legacy OpenSSL encrypted PEM with
+/// Proc-Type and DEK-Info headers.
+fn encrypt_private_key_pem(
+  label: &str,
+  data: &[u8],
+  cipher_name: &str,
+  passphrase: &[u8],
+) -> Result<String, ExportPrivateKeyPemError> {
+  use aes::cipher::BlockEncryptMut;
+  use aes::cipher::KeyIvInit;
+  use aes::cipher::block_padding::Pkcs7;
+
+  let (key_len, dek_info_name, iv_len) = match cipher_name {
+    "aes-128-cbc" => (16, "AES-128-CBC", 16),
+    "aes-192-cbc" => (24, "AES-192-CBC", 16),
+    "aes-256-cbc" => (32, "AES-256-CBC", 16),
+    "des-ede3-cbc" => (24, "DES-EDE3-CBC", 8),
+    _ => {
+      return Err(ExportPrivateKeyPemError::UnsupportedCipher(format!(
+        "Unsupported cipher for PEM encryption: {cipher_name}"
+      )));
+    }
+  };
+
+  // Generate random IV
+  let mut iv = vec![0u8; iv_len];
+  thread_rng().fill_bytes(&mut iv);
+
+  // Derive key using EVP_BytesToKey (uses first 8 bytes of IV as salt)
+  let mut salt = [0u8; 8];
+  salt.copy_from_slice(&iv[..8]);
+  let key = evp_bytes_to_key(passphrase, &salt, key_len);
+
+  // Encrypt with PKCS#7 padding
+  let encrypted = match cipher_name {
+    "aes-128-cbc" => cbc::Encryptor::<aes::Aes128>::new_from_slices(&key, &iv)
+      .unwrap()
+      .encrypt_padded_vec_mut::<Pkcs7>(data),
+    "aes-192-cbc" => cbc::Encryptor::<aes::Aes192>::new_from_slices(&key, &iv)
+      .unwrap()
+      .encrypt_padded_vec_mut::<Pkcs7>(data),
+    "aes-256-cbc" => cbc::Encryptor::<aes::Aes256>::new_from_slices(&key, &iv)
+      .unwrap()
+      .encrypt_padded_vec_mut::<Pkcs7>(data),
+    "des-ede3-cbc" => {
+      cbc::Encryptor::<des::TdesEde3>::new_from_slices(&key, &iv)
+        .unwrap()
+        .encrypt_padded_vec_mut::<Pkcs7>(data)
+    }
+    _ => unreachable!(),
+  };
+
+  // Format as legacy encrypted PEM
+  let iv_hex = iv.iter().map(|b| format!("{b:02X}")).collect::<String>();
+  let b64 = base64::engine::general_purpose::STANDARD.encode(&encrypted);
+
+  // Split base64 into 64-char lines
+  let mut pem = String::new();
+  pem.push_str(&format!("-----BEGIN {label}-----\n"));
+  pem.push_str("Proc-Type: 4,ENCRYPTED\n");
+  pem.push_str(&format!("DEK-Info: {dek_info_name},{iv_hex}\n"));
+  pem.push('\n');
+  for chunk in b64.as_bytes().chunks(64) {
+    pem.push_str(std::str::from_utf8(chunk).unwrap());
+    pem.push('\n');
+  }
+  pem.push_str(&format!("-----END {label}-----\n"));
+
+  Ok(pem)
 }
 
 #[op2]
 #[string]
 pub fn op_node_export_private_key_pem(
   #[cppgc] handle: &KeyObjectHandle,
   #[string] typ: &str,
+  #[string] cipher: Option<String>,
+  #[string] passphrase: Option<String>,
 ) -> Result<String, ExportPrivateKeyPemError> {
   let private_key = handle
     .as_private_key()
@@ -3140,6 +3250,21 @@ pub fn op_node_export_private_key_pem(
     _ => unreachable!("export_der would have errored"),
   };
 
+  match (&cipher, &passphrase) {
+    (Some(cipher), Some(passphrase)) => {
+      return encrypt_private_key_pem(
+        label,
+        &data,
+        cipher,
+        passphrase.as_bytes(),
+      );
+    }
+    (Some(_), None) | (None, Some(_)) => {
+      return Err(ExportPrivateKeyPemError::MissingCipherOrPassphrase);
+    }
+    (None, None) => {}
+  }
+
   let pem_len = der::pem::encapsulated_len(label, LineEnding::LF, data.len())
     .map_err(|_| ExportPrivateKeyPemError::VeryLargeData)?;
   let mut out = vec![0; pem_len];

filename: tests/node_compat/config.jsonc
patch: @@ -380,6 +380,15 @@
     "parallel/test-crypto-keygen-async-elliptic-curve-jwk.js": {},
     "parallel/test-crypto-keygen-async-elliptic-curve-jwk-rsa.js": {},
     "parallel/test-crypto-keygen-async-named-elliptic-curve.js": {},
+    "parallel/test-crypto-keygen-async-rsa.js": {
+      "ignore": true,
+      "reason": "Decrypting legacy PEM encrypted private keys (Proc-Type/DEK-Info format) is not yet supported; only PKCS#8 encrypted key import works"
+    },
+    "parallel/test-crypto-keygen-bit-length.js": {
+      "ignore": true,
+      "reason": "DSA key generation with arbitrary bit lengths (e.g. 2049) is not supported; the Rust dsa crate only supports fixed KeySize variants (1024/160, 2048/224, 2048/256, 3072/256)"
+    },
+    "parallel/test-crypto-keygen-deprecation.js": {},
     "parallel/test-crypto-keygen-dh-classic.js": {},
     "parallel/test-crypto-keygen-duplicate-deprecated-option.js": {},
     "parallel/test-crypto-keygen-eddsa.js": {},