fix: SSH key auth — handle EC/DSA keys via openssl pkey fallback
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Has been cancelled

russh only parses 4 PEM headers: OPENSSH, RSA, PKCS8, ENCRYPTED PKCS8.
EC keys (-----BEGIN EC PRIVATE KEY-----) with PKCS5 encryption silently
failed with "Could not read key".

Fix adds two fallbacks:
1. If russh can't parse the key, convert to PKCS8 via `openssl pkey`
   which handles EC, DSA, and all other OpenSSL-supported formats
2. If the input doesn't start with -----BEGIN, try reading it as a
   file path (supports ~ expansion) for keys stored on disk

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-24 19:18:01 -04:00
parent 6e5f08fd09
commit 4a0c2c9790

View File

@ -100,7 +100,18 @@ impl SshService {
.map_err(|e| format!("SSH authentication error: {}", e))? .map_err(|e| format!("SSH authentication error: {}", e))?
} }
AuthMethod::Key { ref private_key_pem, ref passphrase } => { AuthMethod::Key { ref private_key_pem, ref passphrase } => {
let key = russh::keys::decode_secret_key(private_key_pem, passphrase.as_deref()).map_err(|e| format!("Failed to decode private key: {}", e))?; 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())?;
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)
})?
}
};
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
.map_err(|_| "SSH key authentication timed out after 10s".to_string())? .map_err(|_| "SSH key authentication timed out after 10s".to_string())?
@ -213,3 +224,73 @@ impl SshService {
}).collect() }).collect()
} }
} }
/// 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;
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());
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());
}
let output = child.wait_with_output().map_err(|e| format!("openssl conversion failed: {}", e))?;
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))
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!("Failed to convert key to PKCS#8: {}", stderr.trim()))
}
}
/// Resolve a private key string — if it looks like PEM content, return as-is.
/// If it looks like a file path, read the file. Strip BOM and normalize.
fn resolve_private_key(input: &str) -> Result<String, String> {
let input = input.trim();
// Strip UTF-8 BOM if present
let input = input.strip_prefix('\u{feff}').unwrap_or(input);
if input.starts_with("-----BEGIN ") {
return Ok(input.to_string());
}
// Doesn't look like PEM — try as file path
let path = if input.starts_with('~') {
if let Ok(home) = std::env::var("HOME") {
input.replacen('~', &home, 1)
} else {
input.to_string()
}
} else {
input.to_string()
};
let path = std::path::Path::new(&path);
if path.exists() && path.is_file() {
std::fs::read_to_string(path)
.map(|s| s.trim().to_string())
.map_err(|e| format!("Failed to read private key file '{}': {}", path.display(), e))
} else if input.contains('/') || input.contains('\\') {
Err(format!("Private key file not found: {}", input))
} else {
// Neither PEM nor a path — pass through and let russh give its error
Ok(input.to_string())
}
}