fix: pure Rust EC key decryption — no openssl dependency
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m3s

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) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-24 20:08:55 -04:00
parent 74b9be3046
commit eda36c937b
3 changed files with 126 additions and 31 deletions

17
src-tauri/Cargo.lock generated
View File

@ -4211,6 +4211,16 @@ dependencies = [
"sha1 0.11.0-rc.2", "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]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"
@ -8607,11 +8617,14 @@ dependencies = [
name = "wraith" name = "wraith"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"aes 0.8.4",
"aes-gcm 0.10.3", "aes-gcm 0.10.3",
"anyhow", "anyhow",
"argon2", "argon2",
"async-trait", "async-trait",
"base64 0.22.1", "base64 0.22.1",
"block-padding 0.3.3",
"cbc 0.1.2",
"dashmap", "dashmap",
"env_logger", "env_logger",
"hex", "hex",
@ -8619,11 +8632,15 @@ dependencies = [
"ironrdp-tls", "ironrdp-tls",
"ironrdp-tokio", "ironrdp-tokio",
"log", "log",
"md5",
"pem",
"pkcs8 0.10.2",
"rand 0.9.2", "rand 0.9.2",
"reqwest 0.12.28", "reqwest 0.12.28",
"rusqlite", "rusqlite",
"russh", "russh",
"russh-sftp", "russh-sftp",
"sec1 0.7.3",
"serde", "serde",
"serde_json", "serde_json",
"ssh-key", "ssh-key",

View File

@ -36,6 +36,15 @@ russh = "0.48"
russh-sftp = "2.1.1" russh-sftp = "2.1.1"
ssh-key = { version = "0.6", features = ["ed25519", "rsa"] } 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) # RDP (IronRDP)
ironrdp = { version = "0.14", features = ["connector", "session", "graphics", "input"] } ironrdp = { version = "0.14", features = ["connector", "session", "graphics", "input"] }
ironrdp-tokio = { version = "0.8", features = ["reqwest-rustls-ring"] } ironrdp-tokio = { version = "0.8", features = ["reqwest-rustls-ring"] }

View File

@ -103,14 +103,17 @@ impl SshService {
let pem = resolve_private_key(private_key_pem)?; let pem = resolve_private_key(private_key_pem)?;
let key = match russh::keys::decode_secret_key(&pem, passphrase.as_deref()) { let key = match russh::keys::decode_secret_key(&pem, passphrase.as_deref()) {
Ok(k) => k, Ok(k) => k,
Err(_) => { Err(_) if pem.contains("BEGIN EC PRIVATE KEY") => {
// Fallback: convert to PKCS#8 via openssl (handles EC, DSA, etc.) // EC keys in SEC1 format — decrypt and convert to PKCS#8
let converted = convert_key_to_pkcs8(&pem, passphrase.as_deref())?; let converted = convert_ec_key_to_pkcs8(&pem, passphrase.as_deref())?;
russh::keys::decode_secret_key(&converted, None).map_err(|e| { russh::keys::decode_secret_key(&converted, None).map_err(|e| {
let first_line = pem.lines().next().unwrap_or("<empty>"); format!("Failed to decode converted EC key: {}", e)
format!("Failed to decode private key (header: '{}'): {}", first_line, e)
})? })?
} }
Err(e) => {
let first_line = pem.lines().next().unwrap_or("<empty>");
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))) tokio::time::timeout(std::time::Duration::from_secs(10), handle.authenticate_publickey(username, Arc::new(key)))
.await .await
@ -225,39 +228,105 @@ impl SshService {
} }
} }
/// Convert a private key to PKCS#8 format via the openssl CLI. /// Decrypt a legacy PEM-encrypted EC key and re-encode as unencrypted PKCS#8.
/// Handles EC (SEC1), DSA, and other formats that russh can't parse natively. /// Handles -----BEGIN EC PRIVATE KEY----- with Proc-Type/DEK-Info headers.
fn convert_key_to_pkcs8(pem: &str, passphrase: Option<&str>) -> Result<String, String> { /// Uses the same MD5-based EVP_BytesToKey KDF that OpenSSL/russh use for RSA.
use std::io::Write; fn convert_ec_key_to_pkcs8(pem_text: &str, passphrase: Option<&str>) -> Result<String, String> {
use aes::cipher::{BlockDecryptMut, KeyIvInit};
let mut cmd = std::process::Command::new("openssl"); // Parse PEM to extract headers and base64 body
// `pkey` handles all key types (EC, DSA, RSA) and outputs PKCS#8 by default let parsed = pem::parse(pem_text)
cmd.arg("pkey"); .map_err(|e| format!("Failed to parse PEM: {}", e))?;
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());
let mut child = cmd.spawn().map_err(|e| { if parsed.tag() != "EC PRIVATE KEY" {
format!("Key format not supported by russh and openssl not available for conversion: {}", e) return Err(format!("Expected EC PRIVATE KEY, got {}", parsed.tag()));
})?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(pem.as_bytes());
} }
let output = child.wait_with_output().map_err(|e| format!("openssl conversion failed: {}", e))?; let der_bytes = parsed.contents();
if output.status.success() { // Check if the PEM has encryption headers (Proc-Type: 4,ENCRYPTED)
String::from_utf8(output.stdout) let is_encrypted = pem_text.contains("Proc-Type: 4,ENCRYPTED");
.map(|s| s.trim().to_string())
.map_err(|e| format!("openssl output is not valid UTF-8: {}", e)) 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::<aes::Aes128>::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::<block_padding::Pkcs7>(&mut buf)
.map_err(|_| "Decryption failed — wrong passphrase?".to_string())?;
decrypted.to_vec()
} else { } else {
let stderr = String::from_utf8_lossy(&output.stderr); der_bytes.to_vec()
Err(format!("Failed to convert key to PKCS#8: {}", stderr.trim())) };
// 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,<hex> 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. /// Resolve a private key string — if it looks like PEM content, return as-is.