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
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:
parent
6e5f08fd09
commit
4a0c2c9790
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user