diff --git a/src-tauri/src/ssh/session.rs b/src-tauri/src/ssh/session.rs index 36dbc1f..2c8e9f7 100644 --- a/src-tauri/src/ssh/session.rs +++ b/src-tauri/src/ssh/session.rs @@ -100,7 +100,18 @@ impl SshService { .map_err(|e| format!("SSH authentication error: {}", e))? } 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(""); + 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 .map_err(|_| "SSH key authentication timed out after 10s".to_string())? @@ -213,3 +224,73 @@ impl SshService { }).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 { + 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 { + 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()) + } +}