fix: pure Rust EC key decryption — no openssl dependency
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 3m3s
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:
parent
74b9be3046
commit
eda36c937b
17
src-tauri/Cargo.lock
generated
17
src-tauri/Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -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"] }
|
||||
|
||||
@ -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("<empty>");
|
||||
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("<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)))
|
||||
.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<String, String> {
|
||||
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<String, String> {
|
||||
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::<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 {
|
||||
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,<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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user