diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e8000030..0a5b7f76 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6188,7 +6188,7 @@ dependencies = [ [[package]] name = "tabularis" -version = "0.12.0" +version = "0.13.0" dependencies = [ "async-trait", "base64 0.22.1", diff --git a/src-tauri/src/askpass/client.rs b/src-tauri/src/askpass/client.rs new file mode 100644 index 00000000..7852ad7a --- /dev/null +++ b/src-tauri/src/askpass/client.rs @@ -0,0 +1,102 @@ +//! Askpass client mode. +//! +//! When the SSH tunnel code launches `ssh` with `SSH_ASKPASS` pointing at the +//! Tabularis executable, ssh re-runs this binary with the prompt as its only +//! argument. This module detects that situation (via the endpoint env var the +//! server injected), forwards the prompt to the main Tabularis process over a +//! local socket, prints the user's answer to stdout for ssh, and exits without +//! ever booting the full application. + +use std::io::{BufRead, BufReader, Read, Write}; + +use super::protocol::{decode_response, encode_request, PromptKind}; + +/// Env var carrying the server endpoint: a unix socket path on unix, a +/// `127.0.0.1:` address on Windows. +pub const SOCKET_ENV: &str = "TABULARIS_ASKPASS_SOCKET"; +/// Windows only: shared secret proving the client was spawned by Tabularis. +#[cfg(windows)] +pub const TOKEN_ENV: &str = "TABULARIS_ASKPASS_TOKEN"; + +/// If we were invoked as ssh's askpass helper, run the client and exit the +/// process with the appropriate status. Returns without side effects when the +/// endpoint env var is absent (normal application startup). +pub fn maybe_run_askpass_client() { + let Ok(endpoint) = std::env::var(SOCKET_ENV) else { + return; + }; + let prompt = std::env::args().nth(1).unwrap_or_default(); + let kind = PromptKind::from_ssh_env(std::env::var("SSH_ASKPASS_PROMPT").ok().as_deref()); + std::process::exit(run(&endpoint, kind, &prompt)); +} + +/// Exit code contract with ssh: 0 with the secret on stdout means success; +/// any non-zero status means the user cancelled (for `confirm` prompts ssh +/// only looks at the status). +fn run(endpoint: &str, kind: PromptKind, prompt: &str) -> i32 { + let stream = match connect(endpoint) { + Ok(s) => s, + Err(e) => { + eprintln!("[Askpass] Failed to reach Tabularis: {}", e); + return 1; + } + }; + match exchange(stream, kind, prompt) { + Ok(Some(secret)) => { + println!("{}", secret); + 0 + } + Ok(None) => 1, + Err(e) => { + eprintln!("[Askpass] {}", e); + 1 + } + } +} + +/// Send the request line and wait for the response. +/// +/// For [`PromptKind::Notify`] the server never answers: the notification stays +/// on screen until ssh kills this process (user touched the key), at which +/// point the dropped connection tells the server to dismiss it. Blocking on a +/// read that only ever yields EOF gives exactly that behaviour. +fn exchange( + mut stream: S, + kind: PromptKind, + prompt: &str, +) -> Result, String> { + let request = format!("{}\n", encode_request(kind, prompt)); + stream + .write_all(request.as_bytes()) + .and_then(|_| stream.flush()) + .map_err(|e| format!("Failed to send prompt: {}", e))?; + + let mut line = String::new(); + BufReader::new(stream) + .read_line(&mut line) + .map_err(|e| format!("Failed to read response: {}", e))?; + if line.is_empty() { + // EOF without a response: server gone or notification dismissed. + return Ok(None); + } + decode_response(line.trim_end_matches(['\n', '\r'])) + .ok_or_else(|| "Malformed response from Tabularis".to_string()) +} + +#[cfg(unix)] +fn connect(endpoint: &str) -> Result { + std::os::unix::net::UnixStream::connect(endpoint) + .map_err(|e| format!("connect({}): {}", endpoint, e)) +} + +#[cfg(windows)] +fn connect(endpoint: &str) -> Result { + let mut stream = std::net::TcpStream::connect(endpoint) + .map_err(|e| format!("connect({}): {}", endpoint, e))?; + // Authenticate first: anyone on the machine can reach a loopback port. + let token = std::env::var(TOKEN_ENV).map_err(|_| "Missing askpass token".to_string())?; + stream + .write_all(format!("AUTH {}\n", token).as_bytes()) + .map_err(|e| format!("Failed to send auth token: {}", e))?; + Ok(stream) +} diff --git a/src-tauri/src/askpass/mod.rs b/src-tauri/src/askpass/mod.rs new file mode 100644 index 00000000..a5511e6c --- /dev/null +++ b/src-tauri/src/askpass/mod.rs @@ -0,0 +1,137 @@ +//! In-app SSH askpass support. +//! +//! When a connection opts into SSH passphrase/PIN prompts (e.g. FIDO2 +//! security keys), the system `ssh` process needs an `SSH_ASKPASS` helper to +//! collect the secret. Instead of depending on a desktop-specific helper +//! being installed (`ksshaskpass`, `seahorse`, ...), Tabularis acts as its +//! own: ssh re-executes this binary in a thin client mode that forwards the +//! prompt to the running app over a private local socket, and the app shows +//! a native modal. +//! +//! Module layout: +//! - `protocol`: pure encode/decode helpers for the wire format +//! - `client`: the helper process ssh spawns (`SSH_ASKPASS` side) +//! - `server`: socket listener living inside the main process + +mod client; +mod protocol; +mod server; + +#[cfg(test)] +mod tests; + +pub use client::maybe_run_askpass_client; +pub use protocol::PromptKind; +pub use server::{AskpassServer, AskpassUi}; + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{sync_channel, SyncSender}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +use serde::Serialize; +use tauri::{AppHandle, Emitter}; + +/// Event emitted to the frontend when ssh needs user input. +pub const REQUEST_EVENT: &str = "ssh-askpass://request"; +/// Event emitted to the frontend when a prompt is no longer relevant +/// (notification dismissed, request timed out). +pub const DISMISS_EVENT: &str = "ssh-askpass://dismiss"; + +/// How long the user gets to answer a prompt before ssh receives a cancel. +const RESPONSE_TIMEOUT_SECS: u64 = 300; + +/// Global handle for code paths (the SSH tunnel module) that run without a +/// Tauri context. Set once during application setup. +static APP_HANDLE: OnceLock = OnceLock::new(); + +static NEXT_REQUEST_ID: AtomicU64 = AtomicU64::new(1); + +fn pending_responses() -> &'static Mutex>>> { + static PENDING: OnceLock>>>> = OnceLock::new(); + PENDING.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Store the app handle so SSH tunnel code can reach the frontend. +pub fn set_app_handle(app: AppHandle) { + let _ = APP_HANDLE.set(app); +} + +/// Start an askpass server bridged to the frontend. Fails when the app is not +/// fully initialised (e.g. in unit tests), letting callers fall back to the +/// system askpass behaviour. +pub fn start_frontend_server() -> Result { + let app = APP_HANDLE + .get() + .ok_or_else(|| "Askpass UI unavailable: application not initialised".to_string())?; + AskpassServer::start(Arc::new(FrontendUi { app: app.clone() })) +} + +#[derive(Serialize, Clone)] +struct AskpassRequestPayload { + id: u64, + kind: &'static str, + prompt: String, +} + +/// Bridges askpass exchanges to the webview via Tauri events. +struct FrontendUi { + app: AppHandle, +} + +impl AskpassUi for FrontendUi { + fn request(&self, kind: PromptKind, prompt: &str) -> Option { + let id = NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = sync_channel(1); + pending_responses().lock().unwrap().insert(id, tx); + + let payload = AskpassRequestPayload { + id, + kind: kind.as_str(), + prompt: prompt.to_string(), + }; + if let Err(e) = self.app.emit(REQUEST_EVENT, payload) { + eprintln!("[Askpass] Failed to notify frontend: {}", e); + pending_responses().lock().unwrap().remove(&id); + return None; + } + + let response = rx + .recv_timeout(Duration::from_secs(RESPONSE_TIMEOUT_SECS)) + .ok() + .flatten(); + // Entry is still present when the wait timed out (the command removes + // it on a real answer); clean up and close the stale modal. + if pending_responses().lock().unwrap().remove(&id).is_some() { + let _ = self.app.emit(DISMISS_EVENT, id); + } + response + } + + fn show_notification(&self, prompt: &str) -> u64 { + let id = NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed); + let payload = AskpassRequestPayload { + id, + kind: PromptKind::Notify.as_str(), + prompt: prompt.to_string(), + }; + if let Err(e) = self.app.emit(REQUEST_EVENT, payload) { + eprintln!("[Askpass] Failed to notify frontend: {}", e); + } + id + } + + fn dismiss_notification(&self, id: u64) { + let _ = self.app.emit(DISMISS_EVENT, id); + } +} + +/// Frontend answer to an askpass prompt. `response = None` means the user +/// cancelled. +#[tauri::command] +pub fn respond_ssh_askpass(id: u64, response: Option) { + if let Some(tx) = pending_responses().lock().unwrap().remove(&id) { + let _ = tx.send(response); + } +} diff --git a/src-tauri/src/askpass/protocol.rs b/src-tauri/src/askpass/protocol.rs new file mode 100644 index 00000000..094e085b --- /dev/null +++ b/src-tauri/src/askpass/protocol.rs @@ -0,0 +1,115 @@ +//! Line-based wire protocol between the askpass client (this same binary, +//! re-executed by ssh as its `SSH_ASKPASS` helper) and the askpass server +//! running inside the main Tabularis process. +//! +//! Every message is a single line. Prompt and response text is escaped so a +//! message can never contain a raw newline. + +/// The kind of interaction ssh is asking for, derived from the +/// `SSH_ASKPASS_PROMPT` environment variable (OpenSSH 8.4+). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PromptKind { + /// Regular secret prompt (key passphrase, security-key PIN). + Secret, + /// Yes/no confirmation; ssh only inspects the helper's exit status. + Confirm, + /// Notification only (e.g. "Confirm user presence for key ..."); ssh + /// terminates the helper once the condition is satisfied. + Notify, +} + +impl PromptKind { + pub fn as_str(&self) -> &'static str { + match self { + PromptKind::Secret => "secret", + PromptKind::Confirm => "confirm", + PromptKind::Notify => "notify", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "secret" => Some(PromptKind::Secret), + "confirm" => Some(PromptKind::Confirm), + "notify" => Some(PromptKind::Notify), + _ => None, + } + } + + /// Map the value of ssh's `SSH_ASKPASS_PROMPT` env var to a kind. + /// Unset or unknown values mean a regular secret prompt. + pub fn from_ssh_env(value: Option<&str>) -> Self { + match value { + Some("confirm") => PromptKind::Confirm, + Some("none") => PromptKind::Notify, + _ => PromptKind::Secret, + } + } +} + +/// Escape backslashes and line breaks so the text fits on a single line. +pub fn escape(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('\n', "\\n") + .replace('\r', "\\r") +} + +/// Reverse [`escape`]. +pub fn unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c != '\\' { + out.push(c); + continue; + } + match chars.next() { + Some('n') => out.push('\n'), + Some('r') => out.push('\r'), + Some('\\') => out.push('\\'), + Some(other) => { + // Unknown escape: keep it verbatim. + out.push('\\'); + out.push(other); + } + None => out.push('\\'), + } + } + out +} + +/// Client → server: `ASK `. +pub fn encode_request(kind: PromptKind, prompt: &str) -> String { + format!("ASK {} {}", kind.as_str(), escape(prompt)) +} + +pub fn decode_request(line: &str) -> Option<(PromptKind, String)> { + let rest = line.strip_prefix("ASK ")?; + let (kind, prompt) = match rest.split_once(' ') { + Some((kind, prompt)) => (kind, prompt), + None => (rest, ""), + }; + Some((PromptKind::parse(kind)?, unescape(prompt))) +} + +/// Server → client: `OK ` when the user answered, +/// `CANCEL` when the prompt was dismissed or timed out. +pub fn encode_response(response: Option<&str>) -> String { + match response { + Some(secret) => format!("OK {}", escape(secret)), + None => "CANCEL".to_string(), + } +} + +/// Returns `None` for malformed lines, `Some(None)` for a cancellation and +/// `Some(Some(secret))` for an answered prompt. +pub fn decode_response(line: &str) -> Option> { + if line == "CANCEL" { + return Some(None); + } + if line == "OK" { + return Some(Some(String::new())); + } + let secret = line.strip_prefix("OK ")?; + Some(Some(unescape(secret))) +} diff --git a/src-tauri/src/askpass/server.rs b/src-tauri/src/askpass/server.rs new file mode 100644 index 00000000..d98b958b --- /dev/null +++ b/src-tauri/src/askpass/server.rs @@ -0,0 +1,274 @@ +//! Askpass server. +//! +//! Listens on a private local socket for prompt requests coming from askpass +//! client processes (see `client.rs`) and bridges them to an [`AskpassUi`] +//! implementation. The server lives only as long as the ssh process that +//! needs it: the SSH tunnel code starts one, injects its endpoint into the +//! ssh command's environment, and drops it once the tunnel is up (or failed). + +use std::io::{BufRead, BufReader, Write}; +use std::process::Command; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use super::client::SOCKET_ENV; +#[cfg(windows)] +use super::client::TOKEN_ENV; +use super::protocol::{decode_request, encode_response, PromptKind}; + +const ACCEPT_POLL_MS: u64 = 100; + +/// User-interface side of an askpass exchange. Implementations block inside +/// [`AskpassUi::request`] until the user answers (or a timeout fires). +pub trait AskpassUi: Send + Sync { + /// Ask the user to answer a secret or confirmation prompt. `None` means + /// the prompt was cancelled. + fn request(&self, kind: PromptKind, prompt: &str) -> Option; + /// Show a notification that requires no textual answer (e.g. "touch your + /// security key"). Returns an identifier used to dismiss it later. + fn show_notification(&self, prompt: &str) -> u64; + /// Remove a notification previously shown via `show_notification`. + fn dismiss_notification(&self, id: u64); +} + +pub struct AskpassServer { + endpoint: String, + #[cfg(windows)] + token: String, + pending: Arc, + stop: Arc, + #[cfg(unix)] + socket_path: std::path::PathBuf, +} + +impl AskpassServer { + /// Bind the local socket and spawn the accept loop. + pub fn start(ui: Arc) -> Result { + let pending = Arc::new(AtomicUsize::new(0)); + let stop = Arc::new(AtomicBool::new(false)); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + use std::os::unix::net::UnixListener; + + let socket_path = std::env::temp_dir().join(format!( + "tabularis-askpass-{}.sock", + uuid::Uuid::new_v4().simple() + )); + let listener = UnixListener::bind(&socket_path) + .map_err(|e| format!("Failed to bind askpass socket: {}", e))?; + // The socket carries secrets: restrict it to the current user. + std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("Failed to restrict askpass socket permissions: {}", e))?; + listener + .set_nonblocking(true) + .map_err(|e| format!("Failed to configure askpass socket: {}", e))?; + + let endpoint = socket_path.to_string_lossy().to_string(); + spawn_accept_loop(listener, ui, pending.clone(), stop.clone()); + Ok(Self { + endpoint, + pending, + stop, + socket_path, + }) + } + + #[cfg(windows)] + { + use std::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0") + .map_err(|e| format!("Failed to bind askpass socket: {}", e))?; + let endpoint = listener + .local_addr() + .map_err(|e| format!("Failed to read askpass socket address: {}", e))? + .to_string(); + listener + .set_nonblocking(true) + .map_err(|e| format!("Failed to configure askpass socket: {}", e))?; + + let token = uuid::Uuid::new_v4().simple().to_string(); + spawn_accept_loop(listener, ui, pending.clone(), stop.clone(), token.clone()); + Ok(Self { + endpoint, + token, + pending, + stop, + }) + } + } + + /// Endpoint clients must connect to (socket path or `host:port`). + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + /// Point ssh's askpass machinery at this server: ssh re-executes the + /// Tabularis binary, which detects [`SOCKET_ENV`] and runs in client mode. + pub fn configure_command(&self, command: &mut Command) -> Result<(), String> { + let exe = std::env::current_exe() + .map_err(|e| format!("Failed to locate Tabularis executable: {}", e))?; + command + .env("SSH_ASKPASS", exe) + .env("SSH_ASKPASS_REQUIRE", "force") + .env(SOCKET_ENV, &self.endpoint); + #[cfg(windows)] + command.env(TOKEN_ENV, &self.token); + Ok(()) + } + + /// Whether a prompt is currently waiting on the user. Callers use this to + /// pause connection timeouts while the user is typing a PIN. + pub fn has_pending(&self) -> bool { + self.pending.load(Ordering::Relaxed) > 0 + } +} + +impl Drop for AskpassServer { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + #[cfg(unix)] + let _ = std::fs::remove_file(&self.socket_path); + } +} + +#[cfg(unix)] +fn spawn_accept_loop( + listener: std::os::unix::net::UnixListener, + ui: Arc, + pending: Arc, + stop: Arc, +) { + thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + match listener.accept() { + Ok((stream, _)) => { + let ui = ui.clone(); + let pending = pending.clone(); + thread::spawn(move || handle_connection(stream, ui, pending)); + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(ACCEPT_POLL_MS)); + } + Err(e) => { + eprintln!("[Askpass] Accept failed: {}", e); + thread::sleep(Duration::from_millis(ACCEPT_POLL_MS)); + } + } + } + }); +} + +#[cfg(windows)] +fn spawn_accept_loop( + listener: std::net::TcpListener, + ui: Arc, + pending: Arc, + stop: Arc, + token: String, +) { + thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + match listener.accept() { + Ok((stream, _)) => { + let ui = ui.clone(); + let pending = pending.clone(); + let token = token.clone(); + thread::spawn(move || { + let mut reader = + BufReader::new(stream.try_clone().expect("clone askpass stream")); + let mut auth = String::new(); + if reader.read_line(&mut auth).is_err() + || auth.trim_end_matches(['\n', '\r']) != format!("AUTH {}", token) + { + eprintln!("[Askpass] Rejected connection with bad token"); + return; + } + handle_authenticated(reader, stream, ui, pending); + }); + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(ACCEPT_POLL_MS)); + } + Err(e) => { + eprintln!("[Askpass] Accept failed: {}", e); + thread::sleep(Duration::from_millis(ACCEPT_POLL_MS)); + } + } + } + }); +} + +#[cfg(unix)] +fn handle_connection( + stream: std::os::unix::net::UnixStream, + ui: Arc, + pending: Arc, +) { + let reader = BufReader::new(match stream.try_clone() { + Ok(s) => s, + Err(e) => { + eprintln!("[Askpass] Failed to clone stream: {}", e); + return; + } + }); + handle_authenticated(reader, stream, ui, pending); +} + +/// Serve a single askpass exchange on an already-authenticated connection. +fn handle_authenticated( + mut reader: BufReader, + mut writer: W, + ui: Arc, + pending: Arc, +) where + R: std::io::Read, + W: Write, +{ + let mut line = String::new(); + if reader.read_line(&mut line).is_err() { + return; + } + let Some((kind, prompt)) = decode_request(line.trim_end_matches(['\n', '\r'])) else { + eprintln!("[Askpass] Ignoring malformed request"); + return; + }; + + pending.fetch_add(1, Ordering::Relaxed); + // Make sure the counter is decremented on every exit path. + let _guard = PendingGuard(pending); + + match kind { + PromptKind::Secret | PromptKind::Confirm => { + let response = ui.request(kind, &prompt); + let reply = format!("{}\n", encode_response(response.as_deref())); + if let Err(e) = writer + .write_all(reply.as_bytes()) + .and_then(|_| writer.flush()) + { + eprintln!("[Askpass] Failed to send response: {}", e); + } + } + PromptKind::Notify => { + // No answer expected: keep the notification up until the client + // process dies (ssh kills it once the key was touched), which we + // observe as EOF on the connection. + let id = ui.show_notification(&prompt); + let mut rest = String::new(); + let _ = reader.read_line(&mut rest); + ui.dismiss_notification(id); + } + } +} + +struct PendingGuard(Arc); + +impl Drop for PendingGuard { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::Relaxed); + } +} diff --git a/src-tauri/src/askpass/tests.rs b/src-tauri/src/askpass/tests.rs new file mode 100644 index 00000000..72a2c1f7 --- /dev/null +++ b/src-tauri/src/askpass/tests.rs @@ -0,0 +1,263 @@ +use super::protocol::*; + +mod escape_tests { + use super::*; + + #[test] + fn test_plain_text_unchanged() { + assert_eq!(escape("Enter PIN for key:"), "Enter PIN for key:"); + assert_eq!(unescape("Enter PIN for key:"), "Enter PIN for key:"); + } + + #[test] + fn test_newlines_escaped() { + assert_eq!(escape("line1\nline2"), "line1\\nline2"); + assert_eq!(escape("a\r\nb"), "a\\r\\nb"); + } + + #[test] + fn test_backslash_escaped() { + assert_eq!(escape("C:\\path"), "C:\\\\path"); + } + + #[test] + fn test_roundtrip() { + let inputs = [ + "simple", + "with\nnewline", + "with\\backslash", + "mixed \\n literal and \n real", + "trailing\\", + "", + ]; + for input in inputs { + assert_eq!(unescape(&escape(input)), input, "roundtrip of {:?}", input); + } + } + + #[test] + fn test_unescape_unknown_sequence_kept() { + assert_eq!(unescape("a\\tb"), "a\\tb"); + } + + #[test] + fn test_unescape_trailing_backslash() { + assert_eq!(unescape("abc\\"), "abc\\"); + } +} + +mod prompt_kind_tests { + use super::*; + + #[test] + fn test_parse_roundtrip() { + for kind in [PromptKind::Secret, PromptKind::Confirm, PromptKind::Notify] { + assert_eq!(PromptKind::parse(kind.as_str()), Some(kind)); + } + } + + #[test] + fn test_parse_unknown() { + assert_eq!(PromptKind::parse("bogus"), None); + } + + #[test] + fn test_from_ssh_env() { + assert_eq!(PromptKind::from_ssh_env(None), PromptKind::Secret); + assert_eq!( + PromptKind::from_ssh_env(Some("confirm")), + PromptKind::Confirm + ); + assert_eq!(PromptKind::from_ssh_env(Some("none")), PromptKind::Notify); + assert_eq!(PromptKind::from_ssh_env(Some("other")), PromptKind::Secret); + } +} + +mod request_codec_tests { + use super::*; + + #[test] + fn test_request_roundtrip() { + let encoded = encode_request(PromptKind::Secret, "Enter PIN for ED25519-SK key:"); + assert_eq!( + decode_request(&encoded), + Some(( + PromptKind::Secret, + "Enter PIN for ED25519-SK key:".to_string() + )) + ); + } + + #[test] + fn test_request_with_newline_in_prompt() { + let encoded = encode_request(PromptKind::Confirm, "Allow?\nyes/no"); + assert_eq!( + decode_request(&encoded), + Some((PromptKind::Confirm, "Allow?\nyes/no".to_string())) + ); + assert!(!encoded.contains('\n')); + } + + #[test] + fn test_request_empty_prompt() { + let encoded = encode_request(PromptKind::Notify, ""); + assert_eq!( + decode_request(&encoded), + Some((PromptKind::Notify, String::new())) + ); + } + + #[test] + fn test_decode_malformed_request() { + assert_eq!(decode_request("nonsense"), None); + assert_eq!(decode_request("ASK bogus prompt"), None); + assert_eq!(decode_request(""), None); + } +} + +mod response_codec_tests { + use super::*; + + #[test] + fn test_answer_roundtrip() { + let encoded = encode_response(Some("s3cret")); + assert_eq!(decode_response(&encoded), Some(Some("s3cret".to_string()))); + } + + #[test] + fn test_empty_answer() { + let encoded = encode_response(Some("")); + assert_eq!(decode_response(&encoded), Some(Some(String::new()))); + } + + #[test] + fn test_cancel_roundtrip() { + let encoded = encode_response(None); + assert_eq!(decode_response(&encoded), Some(None)); + } + + #[test] + fn test_decode_malformed_response() { + assert_eq!(decode_response("nonsense"), None); + assert_eq!(decode_response(""), None); + } +} + +#[cfg(unix)] +mod server_tests { + use super::super::protocol::{decode_response, encode_request, PromptKind}; + use super::super::server::{AskpassServer, AskpassUi}; + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::net::UnixStream; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + /// Test double that answers every prompt with a fixed response and + /// records notification activity. + struct StubUi { + answer: Option, + seen_prompts: Mutex>, + notifications_dismissed: AtomicU64, + } + + impl StubUi { + fn new(answer: Option<&str>) -> Self { + Self { + answer: answer.map(|s| s.to_string()), + seen_prompts: Mutex::new(Vec::new()), + notifications_dismissed: AtomicU64::new(0), + } + } + } + + impl AskpassUi for StubUi { + fn request(&self, _kind: PromptKind, prompt: &str) -> Option { + self.seen_prompts.lock().unwrap().push(prompt.to_string()); + self.answer.clone() + } + + fn show_notification(&self, prompt: &str) -> u64 { + self.seen_prompts.lock().unwrap().push(prompt.to_string()); + 42 + } + + fn dismiss_notification(&self, _id: u64) { + self.notifications_dismissed.fetch_add(1, Ordering::Relaxed); + } + } + + fn exchange(server: &AskpassServer, request: &str) -> String { + let mut stream = UnixStream::connect(server.endpoint()).expect("connect to server"); + stream + .write_all(format!("{}\n", request).as_bytes()) + .expect("send request"); + let mut line = String::new(); + BufReader::new(stream) + .read_line(&mut line) + .expect("read response"); + line.trim_end_matches('\n').to_string() + } + + #[test] + fn test_secret_prompt_answered() { + let ui = Arc::new(StubUi::new(Some("1234"))); + let server = AskpassServer::start(ui.clone()).expect("start server"); + + let reply = exchange(&server, &encode_request(PromptKind::Secret, "Enter PIN:")); + assert_eq!(decode_response(&reply), Some(Some("1234".to_string()))); + assert_eq!(*ui.seen_prompts.lock().unwrap(), vec!["Enter PIN:"]); + } + + #[test] + fn test_cancelled_prompt() { + let ui = Arc::new(StubUi::new(None)); + let server = AskpassServer::start(ui).expect("start server"); + + let reply = exchange(&server, &encode_request(PromptKind::Secret, "Enter PIN:")); + assert_eq!(decode_response(&reply), Some(None)); + } + + #[test] + fn test_notification_dismissed_on_disconnect() { + let ui = Arc::new(StubUi::new(None)); + let server = AskpassServer::start(ui.clone()).expect("start server"); + + let mut stream = UnixStream::connect(server.endpoint()).expect("connect to server"); + stream + .write_all( + format!( + "{}\n", + encode_request(PromptKind::Notify, "Confirm user presence") + ) + .as_bytes(), + ) + .expect("send request"); + + // Wait until the notification is shown, then drop the connection as + // ssh does when the key was touched. + wait_until(|| !ui.seen_prompts.lock().unwrap().is_empty()); + drop(stream); + wait_until(|| ui.notifications_dismissed.load(Ordering::Relaxed) == 1); + } + + #[test] + fn test_socket_removed_on_drop() { + let ui = Arc::new(StubUi::new(None)); + let server = AskpassServer::start(ui).expect("start server"); + let path = std::path::PathBuf::from(server.endpoint()); + assert!(path.exists()); + drop(server); + assert!(!path.exists()); + } + + fn wait_until(condition: impl Fn() -> bool) { + for _ in 0..100 { + if condition() { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + panic!("condition not met within timeout"); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 5d919ee8..11fc54ca 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -194,6 +194,7 @@ pub async fn expand_ssh_connection_params( expanded_params.ssh_password = ssh_conn.password.clone(); expanded_params.ssh_key_file = ssh_conn.key_file.clone(); expanded_params.ssh_key_passphrase = ssh_conn.key_passphrase.clone(); + expanded_params.ssh_allow_passphrase_prompt = ssh_conn.allow_passphrase_prompt; } } @@ -336,6 +337,7 @@ pub fn resolve_connection_params(params: &ConnectionParams) -> Result(app: &AppHandle) -> Result<(), S Some(key_file.clone()) }, key_passphrase: None, + allow_passphrase_prompt: None, save_in_keychain: conn.params.save_in_keychain, }; @@ -1223,6 +1226,7 @@ pub async fn save_ssh_connection( } else { ssh.key_passphrase.clone() }, + allow_passphrase_prompt: ssh.allow_passphrase_prompt, save_in_keychain: ssh.save_in_keychain, }; @@ -1290,6 +1294,7 @@ pub async fn update_ssh_connection( } else { ssh.key_passphrase.clone() }, + allow_passphrase_prompt: ssh.allow_passphrase_prompt, save_in_keychain: ssh.save_in_keychain, }; @@ -1386,6 +1391,7 @@ pub async fn test_ssh_connection( resolved_password.as_deref(), ssh.key_file.as_deref(), resolved_passphrase.as_deref(), + ssh.allow_passphrase_prompt.unwrap_or(false), ) } @@ -2053,6 +2059,7 @@ mod tests { password: password.map(|p| p.to_string()), key_file: None, key_passphrase: None, + allow_passphrase_prompt: None, save_in_keychain: Some(save_in_keychain), } } diff --git a/src-tauri/src/export_import_tests.rs b/src-tauri/src/export_import_tests.rs index 2b44d7f5..1b7fc65f 100644 --- a/src-tauri/src/export_import_tests.rs +++ b/src-tauri/src/export_import_tests.rs @@ -41,6 +41,7 @@ mod tests { password: Some("ssh_password".to_string()), key_file: None, key_passphrase: None, + allow_passphrase_prompt: None, save_in_keychain: Some(true), }], k8s_connections: vec![], diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9c93fa0c..34730908 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ pub mod ai_commands; pub mod ai_notebook_export; #[cfg(test)] pub mod ai_notebook_export_tests; +pub mod askpass; pub mod cli; pub mod clipboard_import; pub mod commands; @@ -107,6 +108,10 @@ fn close_devtools(window: tauri::WebviewWindow) { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // When ssh re-executes this binary as its SSH_ASKPASS helper (see the + // `askpass` module), serve the prompt and exit without booting the app. + askpass::maybe_run_askpass_client(); + // On Linux + Wayland, disable the DMA-BUF renderer in WebKitGTK to prevent // "Protocol error dispatching to Wayland display" crashes. // This targets the specific protocol causing the error while keeping GPU @@ -179,6 +184,10 @@ pub fn run() { .manage(json_viewer::JsonViewerStore::default()) .manage(query_history::QueryHistoryState::default()) .setup(move |app| { + // Allow the SSH tunnel code (which runs without a Tauri context) + // to bridge askpass prompts to the frontend. + askpass::set_app_handle(app.handle().clone()); + // Read persisted config to know which external plugins are enabled. // `None` means no preference has been saved yet → load all installed plugins. let active_ext_drivers = @@ -269,6 +278,7 @@ pub fn run() { commands::update_ssh_connection, commands::delete_ssh_connection, commands::test_ssh_connection, + askpass::respond_ssh_askpass, // K8s Connections commands::get_k8s_connections, commands::save_k8s_connection, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index eba9d080..fb7eb865 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -80,6 +80,8 @@ pub struct SshConnection { pub key_file: Option, #[serde(skip_serializing_if = "Option::is_none")] pub key_passphrase: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub allow_passphrase_prompt: Option, pub save_in_keychain: Option, } @@ -95,6 +97,8 @@ pub struct SshConnectionInput { pub key_file: Option, #[serde(skip_serializing_if = "Option::is_none")] pub key_passphrase: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub allow_passphrase_prompt: Option, pub save_in_keychain: Option, } @@ -110,6 +114,8 @@ pub struct SshTestParams { #[serde(skip_serializing_if = "Option::is_none")] pub key_passphrase: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub allow_passphrase_prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub connection_id: Option, } @@ -141,6 +147,8 @@ pub struct ConnectionParams { pub ssh_key_file: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ssh_key_passphrase: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ssh_allow_passphrase_prompt: Option, pub save_in_keychain: Option, // Kubernetes Tunnel (mutually exclusive with SSH) #[serde(default)] diff --git a/src-tauri/src/ssh_tunnel.rs b/src-tauri/src/ssh_tunnel.rs index d9f9492a..1913a1e6 100644 --- a/src-tauri/src/ssh_tunnel.rs +++ b/src-tauri/src/ssh_tunnel.rs @@ -95,13 +95,14 @@ impl SshTunnel { ssh_password: Option<&str>, ssh_key_file: Option<&str>, ssh_key_passphrase: Option<&str>, + ssh_allow_passphrase_prompt: bool, remote_host: &str, remote_port: u16, ) -> Result { let use_system_ssh = should_use_system_ssh(ssh_password); println!( - "[SSH Tunnel] New Request: Host={}, Port={}, User={}, UseSystemSSH={}", - ssh_host, ssh_port, ssh_user, use_system_ssh + "[SSH Tunnel] New Request: Host={}, Port={}, User={}, UseSystemSSH={}, AllowPrompt={}", + ssh_host, ssh_port, ssh_user, use_system_ssh, ssh_allow_passphrase_prompt ); let local_port = { @@ -120,6 +121,7 @@ impl SshTunnel { ssh_port, ssh_user, ssh_key_file, + ssh_allow_passphrase_prompt, remote_host, remote_port, local_port, @@ -152,6 +154,7 @@ impl SshTunnel { ssh_port: u16, ssh_user: &str, ssh_key_file: Option<&str>, + ssh_allow_passphrase_prompt: bool, remote_host: &str, remote_port: u16, local_port: u16, @@ -188,25 +191,32 @@ impl SshTunnel { args.push("-o".to_string()); args.push("StrictHostKeyChecking=accept-new".to_string()); args.push("-o".to_string()); - args.push("BatchMode=yes".to_string()); + if ssh_allow_passphrase_prompt { + args.push("BatchMode=no".to_string()); + } else { + args.push("BatchMode=yes".to_string()); + } args.push(destination); println!("[SSH Tunnel] Executing: ssh {:?}", args); - let mut child = Command::new("ssh") + let mut command = Command::new("ssh"); + command .args(&args) .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| { - let err = format!( - "Failed to launch system ssh: {}. Ensure 'ssh' is in PATH.", - e - ); - eprintln!("[SSH Tunnel Error] {}", err); - err - })?; + .stderr(Stdio::piped()); + + let askpass_server = configure_askpass(&mut command, ssh_allow_passphrase_prompt)?; + + let mut child = command.spawn().map_err(|e| { + let err = format!( + "Failed to launch system ssh: {}. Ensure 'ssh' is in PATH.", + e + ); + eprintln!("[SSH Tunnel Error] {}", err); + err + })?; let stdout_log = Arc::new(Mutex::new(Vec::with_capacity(LOG_BUFFER_INITIAL_CAPACITY))); let stderr_log = Arc::new(Mutex::new(Vec::with_capacity(LOG_BUFFER_INITIAL_CAPACITY))); @@ -249,11 +259,18 @@ impl SshTunnel { let child_arc = Arc::new(Mutex::new(child)); // Wait for the tunnel to become ready (port listening) - let start = Instant::now(); + let mut start = Instant::now(); let timeout = Duration::from_secs(SSH_TUNNEL_TIMEOUT_SECS); let mut ready = false; while start.elapsed() < timeout { + // While the user is answering an askpass prompt (PIN entry, + // security-key touch) the clock must not run against them. The + // prompt itself is bounded by the askpass response timeout. + if askpass_server.as_ref().is_some_and(|s| s.has_pending()) { + start = Instant::now(); + } + // Check if process is still alive { let mut c = child_arc.lock().unwrap(); @@ -526,15 +543,22 @@ pub fn test_ssh_connection( ssh_password: Option<&str>, ssh_key_file: Option<&str>, ssh_key_passphrase: Option<&str>, + ssh_allow_passphrase_prompt: bool, ) -> Result { let use_system_ssh = should_use_system_ssh(ssh_password); println!( - "[SSH Test] Testing connection to {}:{} as {} (UseSystemSSH={})", - ssh_host, ssh_port, ssh_user, use_system_ssh + "[SSH Test] Testing connection to {}:{} as {} (UseSystemSSH={}, AllowPrompt={})", + ssh_host, ssh_port, ssh_user, use_system_ssh, ssh_allow_passphrase_prompt ); if use_system_ssh { - test_ssh_connection_system(ssh_host, ssh_port, ssh_user, ssh_key_file) + test_ssh_connection_system( + ssh_host, + ssh_port, + ssh_user, + ssh_key_file, + ssh_allow_passphrase_prompt, + ) } else { test_ssh_connection_russh( ssh_host, @@ -553,6 +577,7 @@ fn test_ssh_connection_system( ssh_port: u16, ssh_user: &str, ssh_key_file: Option<&str>, + ssh_allow_passphrase_prompt: bool, ) -> Result { println!("[SSH Test] Using system SSH (supports ~/.ssh/config)"); @@ -563,7 +588,11 @@ fn test_ssh_connection_system( let mut args = Vec::with_capacity(12); args.extend([ "-o", - "BatchMode=yes", + if ssh_allow_passphrase_prompt { + "BatchMode=no" + } else { + "BatchMode=yes" + }, "-o", "ConnectTimeout=10", "-o", @@ -585,7 +614,14 @@ fn test_ssh_connection_system( println!("[SSH Test] Executing: ssh {:?}", args); - let output = Command::new("ssh").args(&args).output().map_err(|e| { + let mut command = Command::new("ssh"); + command.args(&args); + + // Keep the askpass server alive while ssh runs: prompts can arrive at any + // point until the process exits. + let _askpass_server = configure_askpass(&mut command, ssh_allow_passphrase_prompt)?; + + let output = command.output().map_err(|e| { format!( "Failed to execute ssh command: {}. Ensure 'ssh' is in PATH.", e @@ -703,6 +739,37 @@ fn test_ssh_connection_russh( .map_err(|e| format!("Thread panicked: {:?}", e))? } +/// When passphrase/PIN prompts are allowed, wire ssh's askpass machinery to +/// the in-app prompt bridge (see the `askpass` module). Falls back to the +/// system askpass helper when the app is not fully initialised (e.g. tests), +/// preserving the plain `SSH_ASKPASS_REQUIRE=force` behaviour. +fn configure_askpass( + command: &mut Command, + ssh_allow_passphrase_prompt: bool, +) -> Result, String> { + if !ssh_allow_passphrase_prompt { + return Ok(None); + } + match crate::askpass::start_frontend_server() { + Ok(server) => { + server.configure_command(command)?; + println!( + "[SSH Tunnel] In-app askpass bridge active at {}", + server.endpoint() + ); + Ok(Some(server)) + } + Err(e) => { + eprintln!( + "[SSH Tunnel] In-app askpass unavailable ({}); falling back to system askpass", + e + ); + command.env("SSH_ASKPASS_REQUIRE", "force"); + Ok(None) + } + } +} + /// Build tunnel map key from SSH parameters. /// This is a pure function that can be tested in isolation. #[inline] diff --git a/src/App.tsx b/src/App.tsx index e1ce1bec..e944a5e0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import { UpdateNotificationModal } from "./components/modals/UpdateNotificationM import { CommunityModal } from "./components/modals/CommunityModal"; import { WhatsNewModal } from "./components/modals/WhatsNewModal"; import { AiApprovalGate } from "./components/modals/AiApprovalGate"; +import { SshAskpassGate } from "./components/modals/SshAskpassGate"; import { useUpdate } from "./hooks/useUpdate"; import { useChangelog } from "./hooks/useChangelog"; import { useSettings } from "./hooks/useSettings"; @@ -167,6 +168,7 @@ export function App() { /> + ); } diff --git a/src/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx index ee96739e..645d2955 100644 --- a/src/components/modals/NewConnectionModal.tsx +++ b/src/components/modals/NewConnectionModal.tsx @@ -67,6 +67,7 @@ interface ConnectionParams { ssh_password?: string; ssh_key_file?: string; ssh_key_passphrase?: string; + ssh_allow_passphrase_prompt?: boolean; save_in_keychain?: boolean; // K8s k8s_enabled?: boolean; @@ -1526,6 +1527,23 @@ export const NewConnectionModal = ({ type="password" placeholder={t("newConnection.sshKeyPassphrasePlaceholder")} /> +
+ + updateField("ssh_allow_passphrase_prompt", e.target.checked) + } + className="accent-blue-500 w-3.5 h-3.5 rounded cursor-pointer" + /> + +
)} diff --git a/src/components/modals/SshAskpassGate.tsx b/src/components/modals/SshAskpassGate.tsx new file mode 100644 index 00000000..2e182f9c --- /dev/null +++ b/src/components/modals/SshAskpassGate.tsx @@ -0,0 +1,33 @@ +import { useSshAskpass } from "../../hooks/useSshAskpass"; +import { SshAskpassModal } from "./SshAskpassModal"; + +/// Listens for `ssh-askpass://request` events emitted while a system ssh +/// process authenticates (key passphrase, security-key PIN or touch) and +/// presents one prompt at a time. Mounted once at the App level, so it shows +/// over any current page. +export function SshAskpassGate() { + const { current, respond, dismiss } = useSshAskpass(); + if (!current) return null; + + const handleClose = () => { + if (current.kind === "notify") { + // Notifications have no answer channel; just hide the modal. The + // backend dismisses it for real once the security key is touched. + dismiss(current.id); + } else { + // Closing without an answer is a cancel — ssh is blocked waiting on + // us, so silent dismissal would just burn its timeout. + respond(current.id, null).catch(() => {}); + } + }; + + return ( + respond(current.id, response).catch(() => {})} + onClose={handleClose} + /> + ); +} diff --git a/src/components/modals/SshAskpassModal.tsx b/src/components/modals/SshAskpassModal.tsx new file mode 100644 index 00000000..6dbb9824 --- /dev/null +++ b/src/components/modals/SshAskpassModal.tsx @@ -0,0 +1,127 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Fingerprint, KeyRound, Loader2, X } from "lucide-react"; +import type { SshAskpassRequest } from "../../types/askpass"; + +interface SshAskpassModalProps { + isOpen: boolean; + /** Cancels the prompt (secret/confirm) or hides the notification (notify). */ + onClose: () => void; + request: SshAskpassRequest; + /** Submit the user's answer; ignored for notify prompts. */ + onSubmit: (response: string) => void; +} + +/** + * Modal shown when a system `ssh` process asks for user input during + * authentication: a key passphrase or security-key PIN (`secret`), a yes/no + * question (`confirm`), or a "touch your security key" notification + * (`notify`, auto-dismissed by the backend once satisfied). + */ +export const SshAskpassModal = ({ + isOpen, + onClose, + request, + onSubmit, +}: SshAskpassModalProps) => { + const { t } = useTranslation(); + const [value, setValue] = useState(""); + + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const isNotify = request.kind === "notify"; + const isSecret = request.kind === "secret"; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + // For confirm prompts ssh only checks the exit status; an empty answer + // means "yes". + onSubmit(isSecret ? value : ""); + }; + + return ( + // Above every other modal (highest today is z-[130]): ssh prompts can + // pop over the connection modals that triggered them. +
+
+ {/* Header */} +
+
+
+ {isNotify ? ( + + ) : ( + + )} +
+
+

+ {t("sshAskpass.title")} +

+

{t("sshAskpass.subtitle")}

+
+
+ +
+ + {/* Content */} +
+

+ {request.prompt} +

+ + {isSecret && ( + setValue(e.target.value)} + className="w-full px-3 py-2 bg-base border border-strong rounded-lg text-primary focus:border-blue-500 focus:outline-none" + placeholder={t("sshAskpass.placeholder")} + autoFocus + /> + )} + + {isNotify && ( +
+ + {t("sshAskpass.waiting")} +
+ )} + + {/* Footer */} +
+ + {!isNotify && ( + + )} +
+
+
+
+ ); +}; diff --git a/src/components/modals/SshConnectionsModal.tsx b/src/components/modals/SshConnectionsModal.tsx index 3c7999e5..1a05ebb0 100644 --- a/src/components/modals/SshConnectionsModal.tsx +++ b/src/components/modals/SshConnectionsModal.tsx @@ -218,6 +218,7 @@ export function SshConnectionsModal({ password: formData.password, key_file: formData.key_file, key_passphrase: formData.key_passphrase, + allow_passphrase_prompt: formData.allow_passphrase_prompt, save_in_keychain: formData.save_in_keychain, }; @@ -555,6 +556,24 @@ export function SshConnectionsModal({ +
+ { + updateField("allow_passphrase_prompt", e.target.checked); + }} + className="accent-blue-500 w-4 h-4 rounded cursor-pointer" + /> + +
+ {/* Test Button and Status */}