From eda36c937b1f11172ca2d8482dc197ae43f34f7d Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Tue, 24 Mar 2026 20:08:55 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20pure=20Rust=20EC=20key=20decryption=20?= =?UTF-8?q?=E2=80=94=20no=20openssl=20dependency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced the openssl CLI fallback with pure Rust crypto for EC private keys in SEC1 format (-----BEGIN EC PRIVATE KEY-----). Handles PKCS#5 encrypted keys (AES-128-CBC + MD5 EVP_BytesToKey KDF) and converts to PKCS#8 PEM that russh can parse natively. All crypto crates (md5, aes, cbc, sec1, pkcs8) were already in the dep tree via russh — just promoted to direct dependencies. Zero new binary dependencies, works on Windows without openssl installed. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/Cargo.lock | 17 +++++ src-tauri/Cargo.toml | 9 +++ src-tauri/src/ssh/session.rs | 131 ++++++++++++++++++++++++++--------- 3 files changed, 126 insertions(+), 31 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ed197fb..07aa977 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4211,6 +4211,16 @@ dependencies = [ "sha1 0.11.0-rc.2", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -8607,11 +8617,14 @@ dependencies = [ name = "wraith" version = "0.1.0" dependencies = [ + "aes 0.8.4", "aes-gcm 0.10.3", "anyhow", "argon2", "async-trait", "base64 0.22.1", + "block-padding 0.3.3", + "cbc 0.1.2", "dashmap", "env_logger", "hex", @@ -8619,11 +8632,15 @@ dependencies = [ "ironrdp-tls", "ironrdp-tokio", "log", + "md5", + "pem", + "pkcs8 0.10.2", "rand 0.9.2", "reqwest 0.12.28", "rusqlite", "russh", "russh-sftp", + "sec1 0.7.3", "serde", "serde_json", "ssh-key", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d777825..e4cffc1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,6 +36,15 @@ russh = "0.48" russh-sftp = "2.1.1" ssh-key = { version = "0.6", features = ["ed25519", "rsa"] } +# EC key PEM decryption (all already in dep tree via russh) +md5 = "0.7" +aes = "0.8" +cbc = "0.1" +block-padding = "0.3" +pem = "3" +pkcs8 = { version = "0.10", features = ["pem"] } +sec1 = { version = "0.7", features = ["pem"] } + # RDP (IronRDP) ironrdp = { version = "0.14", features = ["connector", "session", "graphics", "input"] } ironrdp-tokio = { version = "0.8", features = ["reqwest-rustls-ring"] } diff --git a/src-tauri/src/ssh/session.rs b/src-tauri/src/ssh/session.rs index 2c8e9f7..8a151c8 100644 --- a/src-tauri/src/ssh/session.rs +++ b/src-tauri/src/ssh/session.rs @@ -103,14 +103,17 @@ impl SshService { let pem = resolve_private_key(private_key_pem)?; let key = match russh::keys::decode_secret_key(&pem, passphrase.as_deref()) { Ok(k) => k, - Err(_) => { - // Fallback: convert to PKCS#8 via openssl (handles EC, DSA, etc.) - let converted = convert_key_to_pkcs8(&pem, passphrase.as_deref())?; + Err(_) if pem.contains("BEGIN EC PRIVATE KEY") => { + // EC keys in SEC1 format — decrypt and convert to PKCS#8 + let converted = convert_ec_key_to_pkcs8(&pem, passphrase.as_deref())?; russh::keys::decode_secret_key(&converted, None).map_err(|e| { - let first_line = pem.lines().next().unwrap_or(""); - format!("Failed to decode private key (header: '{}'): {}", first_line, e) + format!("Failed to decode converted EC key: {}", e) })? } + Err(e) => { + let first_line = pem.lines().next().unwrap_or(""); + return Err(format!("Failed to decode private key (header: '{}'): {}", first_line, e)); + } }; tokio::time::timeout(std::time::Duration::from_secs(10), handle.authenticate_publickey(username, Arc::new(key))) .await @@ -225,39 +228,105 @@ impl SshService { } } -/// Convert a private key to PKCS#8 format via the openssl CLI. -/// Handles EC (SEC1), DSA, and other formats that russh can't parse natively. -fn convert_key_to_pkcs8(pem: &str, passphrase: Option<&str>) -> Result { - use std::io::Write; +/// Decrypt a legacy PEM-encrypted EC key and re-encode as unencrypted PKCS#8. +/// Handles -----BEGIN EC PRIVATE KEY----- with Proc-Type/DEK-Info headers. +/// Uses the same MD5-based EVP_BytesToKey KDF that OpenSSL/russh use for RSA. +fn convert_ec_key_to_pkcs8(pem_text: &str, passphrase: Option<&str>) -> Result { + use aes::cipher::{BlockDecryptMut, KeyIvInit}; - let mut cmd = std::process::Command::new("openssl"); - // `pkey` handles all key types (EC, DSA, RSA) and outputs PKCS#8 by default - cmd.arg("pkey"); - if let Some(pass) = passphrase { - cmd.args(["-passin", &format!("pass:{}", pass)]); - } - cmd.stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); + // Parse PEM to extract headers and base64 body + let parsed = pem::parse(pem_text) + .map_err(|e| format!("Failed to parse PEM: {}", e))?; - let mut child = cmd.spawn().map_err(|e| { - format!("Key format not supported by russh and openssl not available for conversion: {}", e) - })?; - - if let Some(mut stdin) = child.stdin.take() { - let _ = stdin.write_all(pem.as_bytes()); + if parsed.tag() != "EC PRIVATE KEY" { + return Err(format!("Expected EC PRIVATE KEY, got {}", parsed.tag())); } - let output = child.wait_with_output().map_err(|e| format!("openssl conversion failed: {}", e))?; + let der_bytes = parsed.contents(); - if output.status.success() { - String::from_utf8(output.stdout) - .map(|s| s.trim().to_string()) - .map_err(|e| format!("openssl output is not valid UTF-8: {}", e)) + // Check if the PEM has encryption headers (Proc-Type: 4,ENCRYPTED) + let is_encrypted = pem_text.contains("Proc-Type: 4,ENCRYPTED"); + + let decrypted = if is_encrypted { + let pass = passphrase + .ok_or_else(|| "EC key is encrypted but no passphrase provided".to_string())?; + + // Extract IV from DEK-Info header + let iv = extract_dek_iv(pem_text)?; + + // EVP_BytesToKey: key = MD5(password + iv[:8]) + let mut ctx = md5::Context::new(); + ctx.consume(pass.as_bytes()); + ctx.consume(&iv[..8]); + let key_bytes = ctx.compute(); + + // Decrypt AES-128-CBC + let decryptor = cbc::Decryptor::::new_from_slices(&key_bytes.0, &iv) + .map_err(|e| format!("AES init failed: {}", e))?; + let mut buf = der_bytes.to_vec(); + let decrypted = decryptor + .decrypt_padded_mut::(&mut buf) + .map_err(|_| "Decryption failed — wrong passphrase?".to_string())?; + decrypted.to_vec() } else { - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!("Failed to convert key to PKCS#8: {}", stderr.trim())) + der_bytes.to_vec() + }; + + // Parse SEC1 DER → re-encode as PKCS#8 PEM + use sec1::der::Decode; + let ec_key = sec1::EcPrivateKey::from_der(&decrypted) + .map_err(|e| format!("Failed to parse EC key DER: {}", e))?; + + // Build PKCS#8 wrapper around the SEC1 key + // The OID for the curve is embedded in the SEC1 parameters field + let oid = ec_key.parameters + .map(|p| { let sec1::EcParameters::NamedCurve(oid) = p; oid }) + .ok_or_else(|| "EC key missing curve OID in parameters".to_string())?; + + // Re-encode as PKCS#8 OneAsymmetricKey + use pkcs8::der::Encode; + let inner_der = ec_key.to_der() + .map_err(|e| format!("Failed to re-encode EC key: {}", e))?; + + let algorithm = pkcs8::AlgorithmIdentifierRef { + oid: pkcs8::ObjectIdentifier::new("1.2.840.10045.2.1") + .map_err(|e| format!("Bad EC OID: {}", e))?, + parameters: Some( + pkcs8::der::asn1::AnyRef::new(pkcs8::der::Tag::ObjectIdentifier, oid.as_bytes()) + .map_err(|e| format!("Bad curve param: {}", e))? + ), + }; + + let pkcs8_info = pkcs8::PrivateKeyInfo { + algorithm, + private_key: &inner_der, + public_key: None, + }; + + let pkcs8_der = pkcs8_info.to_der() + .map_err(|e| format!("Failed to encode PKCS#8: {}", e))?; + + // Wrap in PEM + let pkcs8_pem = pem::encode(&pem::Pem::new("PRIVATE KEY", pkcs8_der)); + Ok(pkcs8_pem) +} + +/// Extract the 16-byte IV from a DEK-Info: AES-128-CBC, header. +fn extract_dek_iv(pem_text: &str) -> Result<[u8; 16], String> { + for line in pem_text.lines() { + if let Some(rest) = line.strip_prefix("DEK-Info: AES-128-CBC,") { + let iv_hex = rest.trim(); + let iv_bytes = hex::decode(iv_hex) + .map_err(|e| format!("Invalid DEK-Info IV hex: {}", e))?; + if iv_bytes.len() != 16 { + return Err(format!("IV must be 16 bytes, got {}", iv_bytes.len())); + } + let mut iv = [0u8; 16]; + iv.copy_from_slice(&iv_bytes); + return Ok(iv); + } } + Err("No DEK-Info: AES-128-CBC header found in encrypted PEM".to_string()) } /// Resolve a private key string — if it looks like PEM content, return as-is.