diff --git a/CHANGELOG.md b/CHANGELOG.md index 80778b2..f68941e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ Versions follow [Semantic Versioning](https://semver.org/). --- +## Unreleased + +### Bug Fixes +- **Passphrase-protected SSH keys can be unlocked.** Connecting with an encrypted identity file failed with a generic authentication error because the key was loaded with no passphrase. OmnySSH now prompts for the passphrase (desktop and terminal apps), caches it in memory for the rest of the process — never on disk — and retries. Keys already loaded in an SSH agent still work without a prompt, including the Windows OpenSSH agent (`\\.\pipe\openssh-ssh-agent`). The host password field remains the server login password, not the key passphrase. + +--- + ## 1.1.2 — 2026-08-22 ### Features diff --git a/README.md b/README.md index 32b97b0..f86cb6b 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ Before touching `sshd_config` it saves a backup on the server. If any step fails The code lives in [`crates/omnyssh-core/src/ssh/key_setup.rs`](crates/omnyssh-core/src/ssh/key_setup.rs). Read it before you point this at production. That is the whole point of shipping it open source. +Passphrase-protected keys work too. If a key is encrypted, OmnySSH asks for the passphrase once and keeps it in memory for the rest of the session — it is never written to `hosts.toml`. Keys already loaded in `ssh-agent` (including Windows OpenSSH's agent) are used first, so a key you `ssh-add` does not need a prompt. The host **Password** field is the server login password for first-time key setup, not the key passphrase. + --- ## Comparison diff --git a/crates/omnyssh-core/src/event.rs b/crates/omnyssh-core/src/event.rs index 58dbb64..35e127f 100644 --- a/crates/omnyssh-core/src/event.rs +++ b/crates/omnyssh-core/src/event.rs @@ -143,6 +143,10 @@ pub enum CoreEvent { /// Emergency rollback was triggered (host_id, rollback_result). KeySetupRollback(HostId, String), + /// A private key is encrypted and no passphrase is cached for it yet. + /// Frontends prompt once per key path and call [`crate::ssh::identity::unlock`]. + KeyPassphraseRequired { host_name: HostId, key_path: String }, + // ----------------------------------------------------------------------- // Update checker events // ----------------------------------------------------------------------- diff --git a/crates/omnyssh-core/src/ssh/identity.rs b/crates/omnyssh-core/src/ssh/identity.rs new file mode 100644 index 0000000..a5a9444 --- /dev/null +++ b/crates/omnyssh-core/src/ssh/identity.rs @@ -0,0 +1,209 @@ +//! Loading SSH identity files, including passphrase-protected keys. +//! +//! Passphrases are cached in process memory only — never written to disk — +//! and keyed by the canonical path of the private key so every host that +//! shares a key unlocks it once per session. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::{OnceLock, RwLock}; + +use russh::keys::key::KeyPair; +use thiserror::Error; + +static CACHE: OnceLock>> = OnceLock::new(); + +fn cache() -> &'static RwLock> { + CACHE.get_or_init(|| RwLock::new(HashMap::new())) +} + +/// Failure to load or unlock a private key file. +#[derive(Debug, Error)] +pub enum IdentityError { + /// The key is encrypted and no usable passphrase is cached. + #[error("SSH key {0} requires a passphrase")] + Encrypted(String), + /// A passphrase was supplied (or cached) but did not decrypt the key. + #[error("wrong passphrase for SSH key {0}")] + WrongPassphrase(String), + /// The file could not be read or parsed for a reason other than encryption. + #[error("could not load SSH key {path}: {source}")] + Load { + path: String, + #[source] + source: anyhow::Error, + }, +} + +/// Expand `~/` and, when the file exists, resolve it to a canonical path so +/// `~/.ssh/id_ed25519` and `/home/me/.ssh/id_ed25519` share one cache slot. +pub(crate) fn normalize_key_path(path: &str) -> String { + let expanded = expand_tilde(path); + std::fs::canonicalize(&expanded) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or(expanded) +} + +pub(crate) fn expand_tilde(path: &str) -> String { + if path.starts_with("~/") || path == "~" { + if let Some(home) = dirs::home_dir() { + return path.replacen('~', &home.to_string_lossy(), 1); + } + } + path.to_string() +} + +fn cached_passphrase(path: &str) -> Option { + cache() + .read() + .expect("identity passphrase cache poisoned") + .get(path) + .cloned() +} + +fn remember(path: &str, passphrase: &str) { + cache() + .write() + .expect("identity passphrase cache poisoned") + .insert(path.to_string(), passphrase.to_string()); +} + +fn forget(path: &str) { + cache() + .write() + .expect("identity passphrase cache poisoned") + .remove(path); +} + +/// Decrypt `path` with `passphrase` and remember it for the rest of the process. +/// +/// # Errors +/// Wrong passphrase, unreadable file, or a key that is not encrypted but +/// still fails to parse. +pub fn unlock(path: &str, passphrase: &str) -> Result<(), IdentityError> { + let key_path = normalize_key_path(path); + match russh::keys::load_secret_key(&key_path, Some(passphrase)) { + Ok(_) => { + remember(&key_path, passphrase); + Ok(()) + } + Err(russh::keys::Error::KeyIsEncrypted) => Err(IdentityError::WrongPassphrase(key_path)), + Err(e) => Err(IdentityError::Load { + path: key_path, + source: e.into(), + }), + } +} + +/// Load a private key, using a cached passphrase when the file is encrypted. +/// +/// # Errors +/// [`IdentityError::Encrypted`] when the key needs a passphrase that has not +/// been unlocked yet; other variants for I/O, parse, or decrypt failures. +pub fn load_key_pair(path: &str) -> Result { + let key_path = normalize_key_path(path); + let passphrase = cached_passphrase(&key_path); + match russh::keys::load_secret_key(Path::new(&key_path), passphrase.as_deref()) { + Ok(key) => Ok(key), + Err(russh::keys::Error::KeyIsEncrypted) if passphrase.is_none() => { + Err(IdentityError::Encrypted(key_path)) + } + Err(russh::keys::Error::KeyIsEncrypted) => { + forget(&key_path); + Err(IdentityError::WrongPassphrase(key_path)) + } + Err(e) => Err(IdentityError::Load { + path: key_path, + source: e.into(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use std::process::Command; + + fn ssh_keygen_available() -> bool { + Command::new("ssh-keygen").output().is_ok() + } + + fn write_key(dir: &Path, passphrase: &str) -> PathBuf { + let path = dir.join("id_ed25519"); + let status = Command::new("ssh-keygen") + .args([ + "-t", + "ed25519", + "-f", + path.to_str().expect("utf-8 path"), + "-N", + passphrase, + "-q", + ]) + .status() + .expect("run ssh-keygen"); + assert!(status.success(), "ssh-keygen failed: {status}"); + path + } + + #[test] + fn an_unencrypted_key_loads_without_a_passphrase() { + if !ssh_keygen_available() { + return; + } + let dir = tempfile::tempdir().expect("tempdir"); + let path = write_key(dir.path(), ""); + load_key_pair(path.to_str().expect("utf-8")).expect("unencrypted key should load"); + } + + #[test] + fn an_encrypted_key_reports_that_a_passphrase_is_required() { + if !ssh_keygen_available() { + return; + } + let dir = tempfile::tempdir().expect("tempdir"); + let path = write_key(dir.path(), "correct horse"); + let path_str = path.to_str().expect("utf-8"); + forget(&normalize_key_path(path_str)); + match load_key_pair(path_str) { + Err(IdentityError::Encrypted(_)) => {} + other => panic!("expected Encrypted, got {other:?}"), + } + } + + #[test] + fn unlocking_caches_the_passphrase_for_later_loads() { + if !ssh_keygen_available() { + return; + } + let dir = tempfile::tempdir().expect("tempdir"); + let path = write_key(dir.path(), "correct horse"); + let path_str = path.to_str().expect("utf-8"); + forget(&normalize_key_path(path_str)); + + unlock(path_str, "correct horse").expect("unlock"); + load_key_pair(path_str).expect("cached passphrase should decrypt the key"); + forget(&normalize_key_path(path_str)); + } + + #[test] + fn a_wrong_passphrase_is_rejected_and_not_cached() { + if !ssh_keygen_available() { + return; + } + let dir = tempfile::tempdir().expect("tempdir"); + let path = write_key(dir.path(), "correct horse"); + let path_str = path.to_str().expect("utf-8"); + forget(&normalize_key_path(path_str)); + + match unlock(path_str, "wrong") { + Err(IdentityError::WrongPassphrase(_)) | Err(IdentityError::Load { .. }) => {} + other => panic!("expected wrong-passphrase error, got {other:?}"), + } + match load_key_pair(path_str) { + Err(IdentityError::Encrypted(_)) => {} + other => panic!("wrong passphrase must not be cached, got {other:?}"), + } + } +} diff --git a/crates/omnyssh-core/src/ssh/mod.rs b/crates/omnyssh-core/src/ssh/mod.rs index 85be5f0..e7f7dea 100644 --- a/crates/omnyssh-core/src/ssh/mod.rs +++ b/crates/omnyssh-core/src/ssh/mod.rs @@ -5,6 +5,7 @@ /// discovery and Auto SSH Key Setup for secure authentication. pub mod client; pub mod discovery; +pub mod identity; pub mod jump; pub mod key_setup; pub mod metrics; diff --git a/crates/omnyssh-core/src/ssh/pool.rs b/crates/omnyssh-core/src/ssh/pool.rs index efaa759..9c72bd6 100644 --- a/crates/omnyssh-core/src/ssh/pool.rs +++ b/crates/omnyssh-core/src/ssh/pool.rs @@ -80,6 +80,8 @@ pub struct PollManager { task_handles: Vec>, /// Per-host channel to send an immediate-refresh signal. refresh_txs: HashMap>, + /// Per-host channel that interrupts reconnect backoff after a key is unlocked. + retry_txs: HashMap>, } impl PollManager { @@ -87,6 +89,7 @@ impl PollManager { pub fn start(hosts: Vec, tx: mpsc::Sender, poll_interval: Duration) -> Self { let mut task_handles = Vec::with_capacity(hosts.len()); let mut refresh_txs = HashMap::with_capacity(hosts.len()); + let mut retry_txs = HashMap::with_capacity(hosts.len()); for host in hosts { let (refresh_tx, refresh_rx) = mpsc::channel::<()>(4); @@ -94,13 +97,25 @@ impl PollManager { let event_tx = tx.clone(); let interval = poll_interval; - let handle = tokio::spawn(run_host_poller(host, event_tx, interval, refresh_rx)); + let handle = match host.monitoring { + MonitorMode::Ssh => { + let (retry_tx, retry_rx) = mpsc::channel::<()>(4); + retry_txs.insert(host.name.clone(), retry_tx); + tokio::spawn(run_ssh_poller( + host, event_tx, interval, refresh_rx, retry_rx, + )) + } + MonitorMode::TcpPort => { + tokio::spawn(run_tcp_poller(host, event_tx, interval, refresh_rx)) + } + }; task_handles.push(handle); } Self { task_handles, refresh_txs, + retry_txs, } } @@ -113,6 +128,16 @@ impl PollManager { } } + /// Wake SSH pollers that are sitting in reconnect backoff, so a just-unlocked + /// identity is tried immediately instead of waiting out the schedule. + pub fn retry_now(&self) { + for (name, tx) in &self.retry_txs { + if tx.try_send(()).is_err() { + tracing::debug!(host = %name, "retry signal dropped — channel full or closed"); + } + } + } + /// Abort all poller tasks. Called on app exit to allow clean shutdown. /// SSH sessions are dropped inside the tasks, which triggers /// russh's graceful disconnect. @@ -127,18 +152,6 @@ impl PollManager { // Per-host poller task // --------------------------------------------------------------------------- -async fn run_host_poller( - host: Host, - tx: mpsc::Sender, - poll_interval: Duration, - refresh_rx: mpsc::Receiver<()>, -) { - match host.monitoring { - MonitorMode::Ssh => run_ssh_poller(host, tx, poll_interval, refresh_rx).await, - MonitorMode::TcpPort => run_tcp_poller(host, tx, poll_interval, refresh_rx).await, - } -} - /// How long a reachability probe waits for the port to answer. const TCP_PROBE_TIMEOUT: Duration = Duration::from_secs(5); @@ -213,7 +226,7 @@ async fn run_tcp_poller( // Same restraint as the SSH poller: a device that is down should not // be re-dialled on every refresh tick. let delay = backoff.next_delay().max(poll_interval); - wait_backoff(delay, &mut refresh_rx).await; + wait_backoff(delay, &mut refresh_rx, None).await; } } } @@ -223,6 +236,7 @@ async fn run_ssh_poller( tx: mpsc::Sender, poll_interval: Duration, mut refresh_rx: mpsc::Receiver<()>, + mut retry_rx: mpsc::Receiver<()>, ) { let mut backoff = BackoffState::new(); let mut session: Option = None; @@ -240,10 +254,24 @@ async fn run_ssh_poller( } Err(e) => { tracing::debug!(host = %host.name, error = %e, "connection failed"); - send_status(&tx, &host.name, ConnectionStatus::Failed(e.to_string())).await; - // Wait with backoff, allowing early refresh. + if let Some((_, key_path)) = crate::ssh::session::passphrase_required(&e) { + let _ = tx + .send(CoreEvent::KeyPassphraseRequired { + host_name: host.name.clone(), + key_path, + }) + .await; + send_status( + &tx, + &host.name, + ConnectionStatus::Failed(String::from("key requires a passphrase")), + ) + .await; + } else { + send_status(&tx, &host.name, ConnectionStatus::Failed(e.to_string())).await; + } let delay = backoff.next_delay(); - wait_backoff(delay, &mut refresh_rx).await; + wait_backoff(delay, &mut refresh_rx, Some(&mut retry_rx)).await; continue; } } @@ -307,7 +335,7 @@ async fn run_ssh_poller( session.take(); send_status(&tx, &host.name, ConnectionStatus::Failed(e.to_string())).await; let delay = backoff.next_delay(); - wait_backoff(delay, &mut refresh_rx).await; + wait_backoff(delay, &mut refresh_rx, Some(&mut retry_rx)).await; continue; } } @@ -338,22 +366,44 @@ async fn wait_or_refresh(delay: Duration, refresh_rx: &mut mpsc::Receiver<()>) { /// A reconnect must never dial faster than the backoff schedule: the GUI drives /// `refresh_all` on its own timer, which is indistinguishable from a keypress /// here and would otherwise retry a failing host every few seconds. -async fn wait_backoff(delay: Duration, refresh_rx: &mut mpsc::Receiver<()>) { +async fn wait_backoff( + delay: Duration, + refresh_rx: &mut mpsc::Receiver<()>, + retry_rx: Option<&mut mpsc::Receiver<()>>, +) { let sleep = tokio::time::sleep(delay); tokio::pin!(sleep); - loop { - tokio::select! { - () = &mut sleep => return, - signal = refresh_rx.recv() => { - // `None` means every sender is gone and `recv` will return it - // immediately from now on — stop selecting on it, or the task - // spins without ever yielding. - if signal.is_none() { + if let Some(retry_rx) = retry_rx { + loop { + tokio::select! { + () = &mut sleep => return, + signal = refresh_rx.recv() => { + if signal.is_none() { + sleep.await; + return; + } + } + signal = retry_rx.recv() => { + if signal.is_some() { + return; + } sleep.await; return; } } } + } else { + loop { + tokio::select! { + () = &mut sleep => return, + signal = refresh_rx.recv() => { + if signal.is_none() { + sleep.await; + return; + } + } + } + } } } @@ -557,7 +607,7 @@ mod tests { } let start = tokio::time::Instant::now(); - wait_backoff(Duration::from_secs(300), &mut rx).await; + wait_backoff(Duration::from_secs(300), &mut rx, None).await; assert_eq!(start.elapsed(), Duration::from_secs(300)); } @@ -568,7 +618,7 @@ mod tests { drop(tx); let start = tokio::time::Instant::now(); - wait_backoff(Duration::from_secs(300), &mut rx).await; + wait_backoff(Duration::from_secs(300), &mut rx, None).await; assert_eq!(start.elapsed(), Duration::from_secs(300)); } diff --git a/crates/omnyssh-core/src/ssh/pty.rs b/crates/omnyssh-core/src/ssh/pty.rs index 971cd87..077fdc9 100644 --- a/crates/omnyssh-core/src/ssh/pty.rs +++ b/crates/omnyssh-core/src/ssh/pty.rs @@ -192,7 +192,16 @@ async fn session_task( let (_handle, mut channel) = match result { Ok(pair) => pair, Err(e) => { - let _ = tx.send(CoreEvent::Error(format!("Terminal: {e}"))).await; + if let Some((_, key_path)) = crate::ssh::session::passphrase_required(&e) { + let _ = tx + .send(CoreEvent::KeyPassphraseRequired { + host_name: host.name.clone(), + key_path, + }) + .await; + } else { + let _ = tx.send(CoreEvent::Error(format!("Terminal: {e}"))).await; + } let _ = tx.send(CoreEvent::PtyExited(id)).await; return; } diff --git a/crates/omnyssh-core/src/ssh/session.rs b/crates/omnyssh-core/src/ssh/session.rs index 917627f..855e97a 100644 --- a/crates/omnyssh-core/src/ssh/session.rs +++ b/crates/omnyssh-core/src/ssh/session.rs @@ -2,7 +2,7 @@ //! //! Provides [`SshSession`] — a thin wrapper around a russh client handle that //! supports connecting, executing commands, and graceful disconnect. -//! Authentication order: identity file → SSH agent → failure. +//! Authentication order: SSH agent → identity file → default keys → password. //! //! Hosts with a `ProxyJump` are reached through their bastions: each hop is //! connected and authenticated in turn, and the next hop rides a @@ -22,6 +22,7 @@ use russh::ChannelMsg; use tokio::time; use crate::ssh::client::Host; +use crate::ssh::identity::{self, IdentityError}; // --------------------------------------------------------------------------- // russh Handler implementation @@ -413,38 +414,68 @@ fn known_hosts_handler(host: &Host) -> KnownHostsHandler { } } +/// A private key for `host` is encrypted and has not been unlocked yet. +#[derive(Debug, thiserror::Error)] +#[error("SSH key {path} requires a passphrase")] +pub struct PassphraseRequired { + /// Host that needed the key. + pub host: String, + /// Canonical path of the encrypted private key. + pub path: String, +} + +/// If `err` is a [`PassphraseRequired`], return the host name and key path. +pub fn passphrase_required(err: &anyhow::Error) -> Option<(String, String)> { + err.downcast_ref::() + .map(|e| (e.host.clone(), e.path.clone())) +} + /// Authenticates `handle` as `host`, converting a refusal into an error. async fn finish_auth( mut handle: Handle, host: &Host, ) -> anyhow::Result> { - if !authenticate(&mut handle, host).await? { - return Err(anyhow!("SSH authentication failed for {}", host.name)); + match authenticate(&mut handle, host).await? { + AuthOutcome::Ok => Ok(handle), + AuthOutcome::Failed => Err(anyhow!("SSH authentication failed for {}", host.name)), + AuthOutcome::PassphraseRequired { path } => Err(PassphraseRequired { + host: host.name.clone(), + path, + } + .into()), } - Ok(handle) } // --------------------------------------------------------------------------- // Authentication helpers // --------------------------------------------------------------------------- -async fn authenticate(handle: &mut Handle, host: &Host) -> anyhow::Result { +enum AuthOutcome { + Ok, + Failed, + PassphraseRequired { path: String }, +} + +async fn authenticate( + handle: &mut Handle, + host: &Host, +) -> anyhow::Result { let user = host.user.clone(); + let mut encrypted_key: Option = None; - // 1. Try SSH agent first — it handles passphrase-protected keys and is the - // most common auth method for non-interactive clients. - #[cfg(unix)] - { - if try_agent_auth(handle, &user).await.unwrap_or(false) { - return Ok(true); - } + // 1. Try SSH agent first — it handles passphrase-protected keys already + // unlocked in the agent (including Windows OpenSSH's named pipe). + if try_agent_auth(handle, &user).await.unwrap_or(false) { + return Ok(AuthOutcome::Ok); } // 2. Try explicit identity_file from host config. if let Some(key_path) = &host.identity_file { - let path = expand_tilde(key_path); - if try_key_auth(handle, &user, &path).await.unwrap_or(false) { - return Ok(true); + let path = identity::expand_tilde(key_path); + match try_key_auth(handle, &user, &path).await { + Ok(true) => return Ok(AuthOutcome::Ok), + Ok(false) => {} + Err(e) => note_encrypted(&mut encrypted_key, e), } } @@ -453,18 +484,18 @@ async fn authenticate(handle: &mut Handle, host: &Host) -> an for key_path in default_key_paths() { if key_path.exists() { let path_str = key_path.to_string_lossy().into_owned(); - if try_key_auth(handle, &user, &path_str) - .await - .unwrap_or(false) - { - return Ok(true); + match try_key_auth(handle, &user, &path_str).await { + Ok(true) => return Ok(AuthOutcome::Ok), + Ok(false) => {} + Err(e) => note_encrypted(&mut encrypted_key, e), } } } // 4. Try password authentication if provided. // Password auth is NOT recommended for production use but is required for - // the initial connection before setting up key-based auth. + // the initial connection before setting up key-based auth. This is the + // server login password, never the private-key passphrase. if let Some(password) = &host.password { if try_password_auth(handle, &user, password) .await @@ -474,11 +505,25 @@ async fn authenticate(handle: &mut Handle, host: &Host) -> an host = %host.name, "Connected via password authentication — consider setting up SSH key" ); - return Ok(true); + return Ok(AuthOutcome::Ok); } } - Ok(false) + if let Some(path) = encrypted_key { + return Ok(AuthOutcome::PassphraseRequired { path }); + } + Ok(AuthOutcome::Failed) +} + +fn note_encrypted(encrypted_key: &mut Option, err: anyhow::Error) { + match err.downcast_ref::() { + Some(IdentityError::Encrypted(path)) if encrypted_key.is_none() => { + *encrypted_key = Some(path.clone()); + } + _ => { + tracing::debug!(error = %err, "public-key authentication attempt failed"); + } + } } /// Returns the standard default SSH private key paths in priority order. @@ -506,11 +551,9 @@ async fn try_key_auth( ) -> anyhow::Result { // load_secret_key is synchronous (file I/O) — offload to blocking pool. let path = key_path.to_string(); - let key_pair = tokio::task::spawn_blocking(move || { - russh::keys::load_secret_key(&path, None).with_context(|| format!("load key from {path}")) - }) - .await - .context("spawn_blocking panicked")??; + let key_pair = tokio::task::spawn_blocking(move || identity::load_key_pair(&path)) + .await + .context("spawn_blocking panicked")??; let ok = handle .authenticate_publickey(user, Arc::new(key_pair)) @@ -519,17 +562,55 @@ async fn try_key_auth( Ok(ok) } -#[cfg(unix)] async fn try_agent_auth( handle: &mut Handle, user: &str, +) -> anyhow::Result { + try_agent_auth_inner(handle, user).await +} + +#[cfg(unix)] +async fn try_agent_auth_inner( + handle: &mut Handle, + user: &str, ) -> anyhow::Result { use russh::keys::agent::client::AgentClient; let mut agent = AgentClient::connect_env() .await .context("connect to SSH agent")?; + offer_agent_identities(handle, user, agent).await +} + +#[cfg(windows)] +async fn try_agent_auth_inner( + handle: &mut Handle, + user: &str, +) -> anyhow::Result { + use russh::keys::agent::client::AgentClient; + + let agent = AgentClient::connect_named_pipe(r"\\.\pipe\openssh-ssh-agent") + .await + .context("connect to Windows OpenSSH agent")?; + offer_agent_identities(handle, user, agent).await +} + +#[cfg(not(any(unix, windows)))] +async fn try_agent_auth_inner( + _handle: &mut Handle, + _user: &str, +) -> anyhow::Result { + Ok(false) +} +async fn offer_agent_identities( + handle: &mut Handle, + user: &str, + mut agent: russh::keys::agent::client::AgentClient, +) -> anyhow::Result +where + S: russh::keys::agent::client::AgentStream + Unpin + Send + 'static, +{ let identities = agent .request_identities() .await @@ -597,16 +678,3 @@ async fn collect_output( let normalised: String = raw.lines().flat_map(|l| [l, "\n"]).collect(); Ok((normalised, exit_status)) } - -// --------------------------------------------------------------------------- -// Path helpers -// --------------------------------------------------------------------------- - -fn expand_tilde(path: &str) -> String { - if path.starts_with("~/") || path == "~" { - if let Some(home) = dirs::home_dir() { - return path.replacen('~', &home.to_string_lossy(), 1); - } - } - path.to_string() -} diff --git a/crates/omnyssh-core/tests/poll_backoff.rs b/crates/omnyssh-core/tests/poll_backoff.rs index 9d6c99c..0c36183 100644 --- a/crates/omnyssh-core/tests/poll_backoff.rs +++ b/crates/omnyssh-core/tests/poll_backoff.rs @@ -88,6 +88,27 @@ async fn a_refresh_signal_does_not_shorten_the_reconnect_backoff() { ); } +#[tokio::test(start_paused = true)] +async fn unlocking_a_key_retries_without_waiting_out_backoff() { + let (port, dials) = dead_listener().await; + let (tx, rx) = mpsc::channel(64); + drain(rx); + + let manager = PollManager::start(vec![unreachable_host(port)], tx, Duration::from_secs(30)); + assert!( + wait_for_dials(&dials, 1, Duration::from_secs(60)).await, + "the poller never dialled at all" + ); + + manager.retry_now(); + let retried = wait_for_dials(&dials, 2, Duration::from_secs(5)).await; + manager.shutdown(); + assert!( + retried, + "retry_now should cut the reconnect backoff after a key is unlocked" + ); +} + #[tokio::test(start_paused = true)] async fn an_unreachable_host_keeps_retrying_on_its_own_schedule() { let (port, dials) = dead_listener().await; diff --git a/crates/omnyssh-gui/src/bridge.rs b/crates/omnyssh-gui/src/bridge.rs index 6537267..2c4f41a 100644 --- a/crates/omnyssh-gui/src/bridge.rs +++ b/crates/omnyssh-gui/src/bridge.rs @@ -43,6 +43,16 @@ pub async fn forward_core_events(app: AppHandle, mut rx: mpsc::Receiver { let _ = events::Error { message }.emit(&app); } + CoreEvent::KeyPassphraseRequired { + host_name, + key_path, + } => { + let _ = events::KeyPassphraseRequired { + host_name, + key_path, + } + .emit(&app); + } // Remote shell exit / dropped connection. Map the inner PTY id to its // public id (dropping routing state); `None` means the user already // closed the tab, so nothing is emitted (§3.4). diff --git a/crates/omnyssh-gui/src/commands/auth.rs b/crates/omnyssh-gui/src/commands/auth.rs new file mode 100644 index 0000000..89a7cea --- /dev/null +++ b/crates/omnyssh-gui/src/commands/auth.rs @@ -0,0 +1,29 @@ +//! Unlock passphrase-protected identity files (in-memory cache only). + +use tauri::State; + +use crate::error::CommandError; +use crate::state::GuiState; + +/// Decrypt `key_path` with `passphrase` and remember it for this process. +/// On success, SSH pollers waiting on that key retry immediately. +#[tauri::command] +#[specta::specta] +pub async fn unlock_identity( + state: State<'_, GuiState>, + key_path: String, + passphrase: String, +) -> Result<(), CommandError> { + tauri::async_runtime::spawn_blocking(move || { + omnyssh_core::ssh::identity::unlock(&key_path, &passphrase) + }) + .await + .map_err(|e| CommandError { + message: format!("unlock task failed: {e}"), + })? + .map_err(|e| CommandError { + message: e.to_string(), + })?; + state.retry_connections(); + Ok(()) +} diff --git a/crates/omnyssh-gui/src/commands/keysetup.rs b/crates/omnyssh-gui/src/commands/keysetup.rs index 01923f8..ee130f9 100644 --- a/crates/omnyssh-gui/src/commands/keysetup.rs +++ b/crates/omnyssh-gui/src/commands/keysetup.rs @@ -74,6 +74,14 @@ async fn run_key_setup(app: AppHandle, host: Host, engine_tx: mpsc::Sender session, Err(e) => { + if let Some((_, key_path)) = omnyssh_core::ssh::session::passphrase_required(&e) { + let _ = engine_tx + .send(CoreEvent::KeyPassphraseRequired { + host_name: host.name.clone(), + key_path, + }) + .await; + } let _ = engine_tx .send(CoreEvent::KeySetupFailed( host.name.clone(), diff --git a/crates/omnyssh-gui/src/commands/mod.rs b/crates/omnyssh-gui/src/commands/mod.rs index 04c67cc..34c4b18 100644 --- a/crates/omnyssh-gui/src/commands/mod.rs +++ b/crates/omnyssh-gui/src/commands/mod.rs @@ -1,6 +1,7 @@ //! IPC command handlers, one module per domain (tech-gui.md §3.1). Commands are //! thin: validate input, call the core, return a DTO or an error. +pub mod auth; pub mod hosts; pub mod keysetup; pub mod sftp; diff --git a/crates/omnyssh-gui/src/commands/sftp.rs b/crates/omnyssh-gui/src/commands/sftp.rs index fbe7f3e..779bb49 100644 --- a/crates/omnyssh-gui/src/commands/sftp.rs +++ b/crates/omnyssh-gui/src/commands/sftp.rs @@ -38,11 +38,24 @@ pub async fn sftp_open( // A dedicated channel per tab: its owner is the session id, so the forwarder can // attribute the core's session-less `sftp-*` events to this tab (§3.4). let (tx, rx) = mpsc::channel::(SFTP_EVENT_BUFFER); - let manager = SftpManager::connect(&host, tx) - .await - .map_err(|e| CommandError { - message: e.to_string(), - })?; + let manager = match SftpManager::connect(&host, tx).await { + Ok(manager) => manager, + Err(e) => { + if let Some((host_name, key_path)) = omnyssh_core::ssh::session::passphrase_required(&e) + { + let _ = state + .engine_sender() + .send(omnyssh_core::event::CoreEvent::KeyPassphraseRequired { + host_name, + key_path, + }) + .await; + } + return Err(CommandError { + message: e.to_string(), + }); + } + }; let session_id = state.register_sftp(manager); tauri::async_runtime::spawn(bridge::forward_sftp_events(app, session_id, rx)); Ok(session_id) diff --git a/crates/omnyssh-gui/src/events.rs b/crates/omnyssh-gui/src/events.rs index a842162..b7ce854 100644 --- a/crates/omnyssh-gui/src/events.rs +++ b/crates/omnyssh-gui/src/events.rs @@ -169,6 +169,15 @@ pub struct UpdateAvailable { pub info: UpdateInfoDto, } +/// A private key is encrypted and no passphrase is cached yet. Frontends prompt +/// once per key path; the passphrase never crosses back out of the backend. +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)] +#[serde(rename_all = "camelCase")] +pub struct KeyPassphraseRequired { + pub host_name: String, + pub key_path: String, +} + /// A background error surfaced to the user. #[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)] pub struct Error { diff --git a/crates/omnyssh-gui/src/main.rs b/crates/omnyssh-gui/src/main.rs index 996304a..2b2dc85 100644 --- a/crates/omnyssh-gui/src/main.rs +++ b/crates/omnyssh-gui/src/main.rs @@ -13,6 +13,7 @@ mod error; mod events; mod state; +use commands::auth::unlock_identity; use commands::hosts::{delete_host, list_hosts, refresh_metrics, reload_hosts, save_host}; use commands::keysetup::start_key_setup; use commands::sftp::{ @@ -110,6 +111,7 @@ fn specta_builder() -> Builder { preview_local_file, start_key_setup, refresh_metrics, + unlock_identity, check_update, install_update, load_update_config, @@ -134,6 +136,7 @@ fn specta_builder() -> Builder { events::KeySetupFailed, events::KeySetupRollback, events::UpdateAvailable, + events::KeyPassphraseRequired, events::Error ]) } diff --git a/crates/omnyssh-gui/src/state.rs b/crates/omnyssh-gui/src/state.rs index 06d4c49..37a526a 100644 --- a/crates/omnyssh-gui/src/state.rs +++ b/crates/omnyssh-gui/src/state.rs @@ -167,6 +167,13 @@ impl GuiState { } } + /// Wake SSH pollers waiting on reconnect backoff after an identity is unlocked. + pub fn retry_connections(&self) { + if let Some(poll) = self.poll.lock().expect("poll lock poisoned").as_ref() { + poll.retry_now(); + } + } + /// Reserve the single key-setup slot for `host`. `Ok` starts the run; `Err` names the /// host already running one, so a concurrent start is rejected instead of racing a /// second `hosts.toml` write and clobbering the progress panel (§4.2). Paired with diff --git a/crates/omnyssh-gui/ui/src/lib/bindings.ts b/crates/omnyssh-gui/ui/src/lib/bindings.ts index f9315d1..d58c43d 100644 --- a/crates/omnyssh-gui/ui/src/lib/bindings.ts +++ b/crates/omnyssh-gui/ui/src/lib/bindings.ts @@ -309,6 +309,18 @@ async refreshMetrics() : Promise> { else return { status: "error", error: e as any }; } }, +/** + * Decrypt `key_path` with `passphrase` and remember it for this process. + * On success, SSH pollers waiting on that key retry immediately. + */ +async unlockIdentity(keyPath: string, passphrase: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("unlock_identity", { keyPath, passphrase }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, /** * Query GitHub for a newer release (tech-gui.md §4.2). `None` means up to date — the * core swallows network/parse errors so a failed check never disrupts. @@ -368,6 +380,7 @@ error: Error, filePreview: FilePreview, hostStatusChanged: HostStatusChanged, hostsLoaded: HostsLoaded, +keyPassphraseRequired: KeyPassphraseRequired, keySetupComplete: KeySetupComplete, keySetupFailed: KeySetupFailed, keySetupProgress: KeySetupProgress, @@ -388,6 +401,7 @@ error: "error", filePreview: "file-preview", hostStatusChanged: "host-status-changed", hostsLoaded: "hosts-loaded", +keyPassphraseRequired: "key-passphrase-required", keySetupComplete: "key-setup-complete", keySetupFailed: "key-setup-failed", keySetupProgress: "key-setup-progress", @@ -459,6 +473,11 @@ export type HostStatusChanged = { hostName: string; status: ConnectionStatusDto * cache; the bridge does not map `HostsLoaded` (tech-gui.md §3.4). */ export type HostsLoaded = HostDto[] +/** + * A private key is encrypted and no passphrase is cached yet. Frontends prompt + * once per key path; the passphrase never crosses back out of the backend. + */ +export type KeyPassphraseRequired = { hostName: string; keyPath: string } /** * Key setup finished successfully — key auth is configured (tech-gui.md §4.3). * `keyPath` is the generated private-key path (a path, never key material, §3.4). diff --git a/crates/omnyssh-gui/ui/src/lib/components/AppShell.svelte b/crates/omnyssh-gui/ui/src/lib/components/AppShell.svelte index 93fd113..f89cbe7 100644 --- a/crates/omnyssh-gui/ui/src/lib/components/AppShell.svelte +++ b/crates/omnyssh-gui/ui/src/lib/components/AppShell.svelte @@ -9,6 +9,7 @@ import CommandPalette from './CommandPalette.svelte'; import SupportModal from './SupportModal.svelte'; import KeySetupProgress from '$lib/screens/KeySetupProgress.svelte'; + import PassphrasePrompt from '$lib/screens/PassphrasePrompt.svelte'; import UpdateBanner from './UpdateBanner.svelte'; import { support } from '$lib/stores/support'; import { sidebarCollapsed, isCollapseChord } from '$lib/stores/ui'; @@ -43,5 +44,6 @@ {/if} + diff --git a/crates/omnyssh-gui/ui/src/lib/ipc/commands.ts b/crates/omnyssh-gui/ui/src/lib/ipc/commands.ts index f49bf08..f67f798 100644 --- a/crates/omnyssh-gui/ui/src/lib/ipc/commands.ts +++ b/crates/omnyssh-gui/ui/src/lib/ipc/commands.ts @@ -210,3 +210,9 @@ export async function saveUpdateConfig(config: UpdateConfigDto): Promise { const res = await commands.saveUpdateConfig(config); if (res.status === 'error') throw new Error(res.error.message); } + +/** Decrypt an identity file with `passphrase` and cache it for this process. */ +export async function unlockIdentity(keyPath: string, passphrase: string): Promise { + const res = await commands.unlockIdentity(keyPath, passphrase); + if (res.status === 'error') throw new Error(res.error.message); +} diff --git a/crates/omnyssh-gui/ui/src/lib/ipc/router.test.ts b/crates/omnyssh-gui/ui/src/lib/ipc/router.test.ts index 526dabf..adcaf69 100644 --- a/crates/omnyssh-gui/ui/src/lib/ipc/router.test.ts +++ b/crates/omnyssh-gui/ui/src/lib/ipc/router.test.ts @@ -9,10 +9,12 @@ import { snippetRun, beginRun, clearRun } from '$lib/stores/snippets'; import { sessions } from '$lib/stores/sessions'; import { lastError } from '$lib/stores/notifications'; import { keySetup, dismissKeySetup, beginKeySetup } from '$lib/stores/keySetup'; +import { passphrasePrompt, dismissPassphrasePrompt } from '$lib/stores/passphrase'; import { applyError, applyHostStatusChanged, applyHostsLoaded, + applyKeyPassphraseRequiredEvent, applyKeySetupComplete, applyKeySetupFailed, applyKeySetupProgress, @@ -32,6 +34,7 @@ describe('ipc event router', () => { metrics.set(new Map()); services.set(new Map()); lastError.set(null); + dismissPassphrasePrompt(); }); it('routes a hosts-loaded payload into the hosts store', () => { @@ -201,4 +204,21 @@ describe('ipc event router', () => { expect(get(keySetup)).toEqual({ hostName: 'db-1', phase: { kind: 'rolledBack', result: 'Restored.' } }); dismissKeySetup(); }); + + it('routes a key-passphrase-required payload into the passphrase prompt', () => { + applyKeyPassphraseRequiredEvent({ + hostName: 'web-1', + keyPath: '/home/me/.ssh/id_ed25519' + }); + expect(get(passphrasePrompt)).toEqual({ + hostName: 'web-1', + keyPath: '/home/me/.ssh/id_ed25519' + }); + + applyKeyPassphraseRequiredEvent({ + hostName: 'db-1', + keyPath: '/home/me/.ssh/other' + }); + expect(get(passphrasePrompt)?.hostName).toBe('web-1'); + }); }); diff --git a/crates/omnyssh-gui/ui/src/lib/ipc/router.ts b/crates/omnyssh-gui/ui/src/lib/ipc/router.ts index 360863c..abb570d 100644 --- a/crates/omnyssh-gui/ui/src/lib/ipc/router.ts +++ b/crates/omnyssh-gui/ui/src/lib/ipc/router.ts @@ -35,6 +35,7 @@ import { reduceProgress, reduceRollback } from '$lib/stores/keySetup'; +import { applyKeyPassphraseRequired } from '$lib/stores/passphrase'; import { offerUpdate } from '$lib/stores/update'; import type { UpdateAvailable } from '$lib/bindings'; @@ -152,3 +153,10 @@ export function applyUpdateAvailable(payload: UpdateAvailable): void { export function applyError(message: string): void { lastError.set(message); } + +export function applyKeyPassphraseRequiredEvent(payload: { + hostName: string; + keyPath: string; +}): void { + applyKeyPassphraseRequired(payload); +} diff --git a/crates/omnyssh-gui/ui/src/lib/ipc/subscribe.ts b/crates/omnyssh-gui/ui/src/lib/ipc/subscribe.ts index ca69cc3..5f2d55e 100644 --- a/crates/omnyssh-gui/ui/src/lib/ipc/subscribe.ts +++ b/crates/omnyssh-gui/ui/src/lib/ipc/subscribe.ts @@ -23,7 +23,8 @@ import { applySftpOpDone, applySnippetResult, applyTerminalExited, - applyTransferProgress + applyTransferProgress, + applyKeyPassphraseRequiredEvent } from './router'; export async function startEventBridge(): Promise<() => void> { @@ -47,6 +48,9 @@ export async function startEventBridge(): Promise<() => void> { offs.push(await events.keySetupFailed.listen((e) => applyKeySetupFailed(e.payload))); offs.push(await events.keySetupRollback.listen((e) => applyKeySetupRollback(e.payload))); offs.push(await events.updateAvailable.listen((e) => applyUpdateAvailable(e.payload))); + offs.push( + await events.keyPassphraseRequired.listen((e) => applyKeyPassphraseRequiredEvent(e.payload)) + ); offs.push(await events.error.listen((e) => applyError(e.payload.message))); } catch (err) { offs.forEach((off) => off()); diff --git a/crates/omnyssh-gui/ui/src/lib/screens/HostEditor.svelte b/crates/omnyssh-gui/ui/src/lib/screens/HostEditor.svelte index f5f23ec..0f90b00 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/HostEditor.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/HostEditor.svelte @@ -131,7 +131,7 @@ type="password" bind:value={fields.password} class={field} - placeholder={secretHint ?? 'For initial key setup only'} + placeholder={secretHint ?? 'Login password, not the key passphrase'} autocomplete="off" /> diff --git a/crates/omnyssh-gui/ui/src/lib/screens/PassphrasePrompt.svelte b/crates/omnyssh-gui/ui/src/lib/screens/PassphrasePrompt.svelte new file mode 100644 index 0000000..149a11a --- /dev/null +++ b/crates/omnyssh-gui/ui/src/lib/screens/PassphrasePrompt.svelte @@ -0,0 +1,79 @@ + + +{#if $passphrasePrompt} + {@const prompt = $passphrasePrompt} + +
{ + e.preventDefault(); + void submit(); + }} + > +
+

Unlock SSH key — {prompt.hostName}

+

{prompt.keyPath}

+

+ The passphrase is kept in memory until you quit OmnySSH. It is never written to disk. +

+
+ + {#if localError} +

{localError}

+ {/if} +
+ + +
+
+
+{/if} diff --git a/crates/omnyssh-gui/ui/src/lib/stores/passphrase.test.ts b/crates/omnyssh-gui/ui/src/lib/stores/passphrase.test.ts new file mode 100644 index 0000000..1a1f18f --- /dev/null +++ b/crates/omnyssh-gui/ui/src/lib/stores/passphrase.test.ts @@ -0,0 +1,29 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { get } from 'svelte/store'; +import { + dismissPassphrasePrompt, + passphrasePrompt, + reducePassphraseRequired +} from './passphrase'; + +describe('passphrase prompt store', () => { + beforeEach(() => { + passphrasePrompt.set(null); + }); + + it('opens a prompt when none is showing', () => { + const next = reducePassphraseRequired(null, 'web-1', '/home/me/.ssh/id_ed25519'); + expect(next).toEqual({ hostName: 'web-1', keyPath: '/home/me/.ssh/id_ed25519' }); + }); + + it('keeps the first prompt when another host needs a key', () => { + const open = { hostName: 'web-1', keyPath: '/home/me/.ssh/id_ed25519' }; + expect(reducePassphraseRequired(open, 'db-1', '/home/me/.ssh/other')).toEqual(open); + }); + + it('dismisses the prompt', () => { + passphrasePrompt.set({ hostName: 'web-1', keyPath: '/tmp/key' }); + dismissPassphrasePrompt(); + expect(get(passphrasePrompt)).toBeNull(); + }); +}); diff --git a/crates/omnyssh-gui/ui/src/lib/stores/passphrase.ts b/crates/omnyssh-gui/ui/src/lib/stores/passphrase.ts new file mode 100644 index 0000000..c085117 --- /dev/null +++ b/crates/omnyssh-gui/ui/src/lib/stores/passphrase.ts @@ -0,0 +1,31 @@ +import { writable } from 'svelte/store'; + +// One in-memory passphrase prompt at a time. A second host that shares the same +// key is ignored until this one is dismissed; unlocking caches the passphrase +// for every host that uses the file. + +export interface PassphrasePrompt { + hostName: string; + keyPath: string; +} + +export const passphrasePrompt = writable(null); + +/** Open the prompt unless one is already showing. Pure so the router can test it. */ +export function reducePassphraseRequired( + current: PassphrasePrompt | null, + hostName: string, + keyPath: string +): PassphrasePrompt | null { + return current ?? { hostName, keyPath }; +} + +export function applyKeyPassphraseRequired(payload: PassphrasePrompt): void { + passphrasePrompt.update((current) => + reducePassphraseRequired(current, payload.hostName, payload.keyPath) + ); +} + +export function dismissPassphrasePrompt(): void { + passphrasePrompt.set(null); +} diff --git a/crates/omnyssh/src/app/action.rs b/crates/omnyssh/src/app/action.rs index bb5521d..def38c6 100644 --- a/crates/omnyssh/src/app/action.rs +++ b/crates/omnyssh/src/app/action.rs @@ -41,6 +41,10 @@ pub enum AppAction { ConfirmKeySetup(usize), /// User cancelled the key setup prompt. CancelKeySetup, + /// Submit the passphrase entered for an encrypted identity file. + SubmitPassphrase, + /// Dismiss the passphrase prompt without unlocking the key. + DismissPassphrase, // ----------------------------------------------------------------------- // Detail View actions diff --git a/crates/omnyssh/src/app/actions.rs b/crates/omnyssh/src/app/actions.rs index f4f789f..d44e00b 100644 --- a/crates/omnyssh/src/app/actions.rs +++ b/crates/omnyssh/src/app/actions.rs @@ -1,10 +1,12 @@ //! The central action dispatcher: `process_action` applies an [`AppAction`] to //! shared state, spawns background tasks, and delegates to feature methods. -use super::*; +use anyhow::Context; use omnyssh_core::config::snippets::SnippetScope; use omnyssh_core::ssh::session::SshSession; +use super::*; + impl App { /// Executes an [`AppAction`] that requires access to shared state or the /// terminal (e.g. connecting to SSH). @@ -256,6 +258,37 @@ impl App { self.view.host_list.popup = None; } + AppAction::SubmitPassphrase => { + let Some(mut prompt) = self.view.passphrase_prompt.take() else { + return Ok(()); + }; + let path = prompt.key_path.clone(); + let passphrase = prompt.field.value.clone(); + let result = tokio::task::spawn_blocking(move || { + omnyssh_core::ssh::identity::unlock(&path, &passphrase) + }) + .await + .context("passphrase unlock task panicked")?; + match result { + Ok(()) => { + if let Some(mgr) = &self.poll_manager { + mgr.retry_now(); + } + self.view.status_message = + Some(format!("Unlocked {}. Reconnecting…", prompt.key_path)); + } + Err(e) => { + prompt.error = Some(e.to_string()); + prompt.field = FormField::default(); + self.view.passphrase_prompt = Some(prompt); + } + } + } + + AppAction::DismissPassphrase => { + self.view.passphrase_prompt = None; + } + // --------------------------------------------------------------- // Detail View actions // --------------------------------------------------------------- diff --git a/crates/omnyssh/src/app/file_manager.rs b/crates/omnyssh/src/app/file_manager.rs index a281abc..06e4e72 100644 --- a/crates/omnyssh/src/app/file_manager.rs +++ b/crates/omnyssh/src/app/file_manager.rs @@ -342,6 +342,16 @@ impl App { .await; } Err(e) => { + if let Some((host_name, key_path)) = + omnyssh_core::ssh::session::passphrase_required(&e) + { + let _ = tx + .send(CoreEvent::KeyPassphraseRequired { + host_name, + key_path, + }) + .await; + } let _ = tx .send(CoreEvent::SftpDisconnected { reason: e.to_string(), diff --git a/crates/omnyssh/src/app/host.rs b/crates/omnyssh/src/app/host.rs index 4cb9459..edfc8db 100644 --- a/crates/omnyssh/src/app/host.rs +++ b/crates/omnyssh/src/app/host.rs @@ -17,7 +17,7 @@ pub const FORM_FIELD_LABELS: &[&str] = &[ "User", "Port", "Identity File", - "Password (optional)", + "Password (login, optional)", "Tags (comma-sep)", "Notes", "Monitoring (ssh | tcp | tcp:PORT)", diff --git a/crates/omnyssh/src/app/input.rs b/crates/omnyssh/src/app/input.rs index 485bf24..76b1612 100644 --- a/crates/omnyssh/src/app/input.rs +++ b/crates/omnyssh/src/app/input.rs @@ -19,6 +19,13 @@ impl App { return Ok(None); } + if self.view.passphrase_prompt.is_some() { + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + return Ok(Some(AppAction::Quit)); + } + return Ok(self.handle_passphrase_prompt_key(key)); + } + let screen = self.state.read().await.screen.clone(); // ---------------------------------------------------------------- @@ -200,6 +207,23 @@ impl App { Ok(None) } + fn handle_passphrase_prompt_key(&mut self, key: KeyEvent) -> Option { + let prompt = self.view.passphrase_prompt.as_mut()?; + match key.code { + KeyCode::Esc => Some(AppAction::DismissPassphrase), + KeyCode::Enter => Some(AppAction::SubmitPassphrase), + KeyCode::Backspace => { + prompt.field.backspace(); + None + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + prompt.field.insert_char(c); + None + } + _ => None, + } + } + /// Handles key events when the Terminal screen is active. /// /// Returns an [`AppAction`] to pass to `process_action`, or forwards the diff --git a/crates/omnyssh/src/app/mod.rs b/crates/omnyssh/src/app/mod.rs index c1c81e9..20d6d2e 100644 --- a/crates/omnyssh/src/app/mod.rs +++ b/crates/omnyssh/src/app/mod.rs @@ -151,6 +151,8 @@ pub struct ViewState { pub tick_count: u64, /// Startup update-notification popup, shown when a newer release exists. pub update_popup: Option, + /// Prompt for the passphrase of an encrypted identity file. + pub passphrase_prompt: Option, } impl ViewState { @@ -169,6 +171,7 @@ impl ViewState { keybindings: ParsedKeybindings::default(), tick_count: 0, update_popup: None, + passphrase_prompt: None, } } } @@ -179,6 +182,14 @@ impl Default for ViewState { } } +/// In-memory prompt for an encrypted SSH identity file. +pub struct PassphrasePrompt { + pub host_name: String, + pub key_path: String, + pub field: FormField, + pub error: Option, +} + // --------------------------------------------------------------------------- // App // --------------------------------------------------------------------------- @@ -746,6 +757,20 @@ impl App { )); } + CoreEvent::KeyPassphraseRequired { + host_name, + key_path, + } => { + if self.view.passphrase_prompt.is_none() { + self.view.passphrase_prompt = Some(PassphrasePrompt { + host_name, + key_path, + field: FormField::default(), + error: None, + }); + } + } + // ---------------------------------------------------------------- // Update checker events // ---------------------------------------------------------------- diff --git a/crates/omnyssh/src/ui/mod.rs b/crates/omnyssh/src/ui/mod.rs index bee7b54..54edce9 100644 --- a/crates/omnyssh/src/ui/mod.rs +++ b/crates/omnyssh/src/ui/mod.rs @@ -84,6 +84,10 @@ pub fn render(frame: &mut Frame, state: &AppState, view: &ViewState) { popup::render_help(frame, &view.theme); } + if let Some(prompt) = &view.passphrase_prompt { + popup::render_passphrase_prompt(frame, prompt, &view.theme); + } + // The startup update popup sits above everything else. if let Some(update_popup) = &view.update_popup { popup::render_update(frame, update_popup, &view.theme); diff --git a/crates/omnyssh/src/ui/popup.rs b/crates/omnyssh/src/ui/popup.rs index 3c73014..69479e6 100644 --- a/crates/omnyssh/src/ui/popup.rs +++ b/crates/omnyssh/src/ui/popup.rs @@ -7,8 +7,8 @@ use ratatui::{ }; use crate::app::{ - FormField, HostForm, SnippetForm, SnippetResultEntry, UpdateButton, UpdatePopup, - UpdatePopupPhase, FORM_FIELD_LABELS, SNIPPET_FORM_FIELD_LABELS, UPDATE_BUTTONS, + FormField, HostForm, PassphrasePrompt, SnippetForm, SnippetResultEntry, UpdateButton, + UpdatePopup, UpdatePopupPhase, FORM_FIELD_LABELS, SNIPPET_FORM_FIELD_LABELS, UPDATE_BUTTONS, }; use crate::ui::theme::Theme; use omnyssh_core::ssh::client::Host; @@ -1009,6 +1009,97 @@ pub fn render_broadcast_picker( ); } +/// Renders the passphrase prompt for an encrypted identity file. +pub fn render_passphrase_prompt(frame: &mut Frame, prompt: &PassphrasePrompt, theme: &Theme) { + let area = centred_rect(62, 28, frame.area()); + frame.render_widget(Clear, area); + + let block = Block::default() + .title(format!(" Unlock key — {} ", prompt.host_name)) + .title_alignment(Alignment::Center) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme.accent)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.height < 5 { + return; + } + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(inner); + + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + format!(" {}", prompt.key_path), + Style::default().fg(theme.text_secondary), + ))), + rows[0], + ); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + " Enter passphrase (cached until OmnySSH exits):", + Style::default().fg(theme.text_primary), + ))), + rows[1], + ); + + let masked: String = "*".repeat(prompt.field.value.chars().count()); + let cursor = prompt.field.value.chars().count().min(masked.len()); + let display = format!(" {}| ", &masked[..cursor]); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + display, + Style::default() + .fg(theme.form_focused_fg) + .bg(theme.success_border) + .add_modifier(Modifier::BOLD), + ))), + rows[2], + ); + + if let Some(error) = &prompt.error { + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + format!(" {error}"), + Style::default().fg(theme.text_error), + ))), + rows[3], + ); + } + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + "Enter", + Style::default() + .fg(theme.text_success) + .add_modifier(Modifier::BOLD), + ), + Span::styled(":unlock ", Style::default().fg(theme.text_muted)), + Span::styled( + "Esc", + Style::default() + .fg(theme.text_warning) + .add_modifier(Modifier::BOLD), + ), + Span::styled(":cancel", Style::default().fg(theme.text_muted)), + ])), + rows[5], + ); +} + /// Renders the single-line quick-execute command-input popup. pub fn render_quick_execute_input( frame: &mut Frame,