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",
|
"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",
|
||||||
|
|||||||
@ -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"] }
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user