diff --git a/docs/user-manual.md b/docs/user-manual.md index a8727e8..d953e61 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -380,7 +380,7 @@ Connections, disconnections, profile changes, credential access, imports and exp - **Encrypted export**: **File → Export profiles…**, the sidebar footer, the command palette, or **Export…** on a profile's right-click menu. Choose the profiles, set a password and save a `.nexus` file. Credentials saved in the keychain or vault are not included. - **Encrypted import**: **File → Import profiles…** or the sidebar footer. Choose a `.nexus` file (or a `.link` file from Alchementary Link), enter its password, and choose what happens when a profile already exists: **Skip existing**, **Overwrite existing** or **Create duplicates**. -- **JSON export and import**: **Export Profiles (JSON)** and **Import Profiles (JSON)** in the command palette. JSON export includes every profile; tick **Include secrets** to keep inline proxy passwords and TOTP secrets, which are otherwise removed. +- **JSON export and import**: **Export Profiles (JSON)** and **Import Profiles (JSON)** in the command palette. JSON export includes every profile; passwords in login-script `send` steps that answer a password prompt are blanked unless you tick **Include secrets**. Proxy passwords and TOTP secrets live in the keychain or vault, never in the profile, so no export carries them. - **OpenSSH config**: **File → Import ~/.ssh/config** or **Settings → About → Import ~/.ssh/config** creates profiles from your `Host` blocks, including hostnames, usernames, ports, identity files and jump hosts. --- diff --git a/rust/app/src/profile_editor.rs b/rust/app/src/profile_editor.rs index 55f8f23..e601999 100644 --- a/rust/app/src/profile_editor.rs +++ b/rust/app/src/profile_editor.rs @@ -551,8 +551,7 @@ impl ProfileEditor { text(ui, "Proxy Host", &mut c.proxy_host, "proxy.example.com"); num(ui, "Proxy Port", &mut c.proxy_port, 1..=65535); text(ui, "Proxy Username", &mut c.proxy_username, "Optional"); - label(ui, "Proxy Password"); - ui.add(TextEdit::singleline(&mut c.proxy_password).password(true).hint_text("Optional").desired_width(f32::INFINITY)); + secret(ui, "Proxy Password", secrets, SecretKind::ProxyPassword, "Optional; leave blank to keep the saved password"); } }); section(ui, "Advanced", |ui| { diff --git a/rust/core/src/clients/ssh.rs b/rust/core/src/clients/ssh.rs index aee14d3..6553595 100644 --- a/rust/core/src/clients/ssh.rs +++ b/rust/core/src/clients/ssh.rs @@ -15,6 +15,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::sync::mpsc; +use zeroize::Zeroizing; const MAX_PRIVATE_KEY_BYTES: u64 = 1 << 20; /// Bounds what `run_command` buffers, so a hostile server can't stream unlimited output into memory. @@ -224,18 +225,18 @@ async fn blocking(f: impl FnOnce() -> T + Send + 'static) -> tokio::task::spawn_blocking(f).await.expect("blocking task panicked") } -async fn dial(sc: &SshConfig, host: &str, port: u16, timeout: Duration) -> Result { +async fn dial(sc: &SshConfig, proxy_password: &str, host: &str, port: u16, timeout: Duration) -> Result { let fut = async { match sc.proxy_type.to_lowercase().as_str() { - "socks5" => socks5(sc, host, port).await, - "http" => http_connect(sc, host, port).await, + "socks5" => socks5(sc, proxy_password, host, port).await, + "http" => http_connect(sc, proxy_password, host, port).await, _ => Ok(tokio::net::TcpStream::connect((host, port)).await?), } }; tokio::time::timeout(timeout, fut).await.map_err(|_| format!("dial tcp {host}:{port}: i/o timeout"))? } -async fn socks5(sc: &SshConfig, host: &str, port: u16) -> Result { +async fn socks5(sc: &SshConfig, proxy_password: &str, host: &str, port: u16) -> Result { let pport = if sc.proxy_port == 0 { 1080 } else { sc.proxy_port as u16 }; let mut s = tokio::net::TcpStream::connect((sc.proxy_host.as_str(), pport)).await.map_err(|e| format!("SOCKS5 proxy setup failed: {e}"))?; let auth = !sc.proxy_username.is_empty(); @@ -245,7 +246,7 @@ async fn socks5(sc: &SshConfig, host: &str, port: u16) -> Result {} 2 if auth => { - s.write_all(&socks5_auth_request(&sc.proxy_username, &sc.proxy_password)?).await?; + s.write_all(&socks5_auth_request(&sc.proxy_username, proxy_password)?).await?; s.read_exact(&mut r).await?; if r[1] != 0 { return Err("SOCKS5 proxy: authentication failed".into()); @@ -291,12 +292,12 @@ fn socks5_connect_request(host: &str, port: u16) -> Result> { Ok(req) } -async fn http_connect(sc: &SshConfig, host: &str, port: u16) -> Result { +async fn http_connect(sc: &SshConfig, proxy_password: &str, host: &str, port: u16) -> Result { let pport = if sc.proxy_port == 0 { 8080 } else { sc.proxy_port as u16 }; let mut s = tokio::net::TcpStream::connect((sc.proxy_host.as_str(), pport)).await.map_err(|e| format!("HTTP proxy connection failed: {e}"))?; let mut req = format!("CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n"); if !sc.proxy_username.is_empty() { - let cred = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", sc.proxy_username, sc.proxy_password)); + let cred = base64::engine::general_purpose::STANDARD.encode(format!("{}:{proxy_password}", sc.proxy_username)); req += &format!("Proxy-Authorization: Basic {cred}\r\n"); } req += "\r\n"; @@ -422,6 +423,9 @@ pub struct SshClient { forwards: Mutex>, sftp: tokio::sync::Mutex>>, closed: std::sync::atomic::AtomicBool, + /// Credentials that live in the secret store rather than the profile: (TOTP seed, proxy + /// password). Set by the session manager before connecting. + stored: Mutex<(Zeroizing, Zeroizing)>, } impl SshClient { @@ -436,9 +440,15 @@ impl SshClient { forwards: Mutex::new(HashMap::new()), sftp: tokio::sync::Mutex::new(None), closed: false.into(), + stored: Mutex::default(), }) } + /// The TOTP seed (for keyboard-interactive 2FA prompts) and proxy password to use. + pub fn set_stored_secrets(&self, totp_secret: String, proxy_password: String) { + *self.stored.lock() = (Zeroizing::new(totp_secret), Zeroizing::new(proxy_password)); + } + fn sc(&self) -> SshConfig { self.profile.ssh.clone().unwrap_or_else(SshConfig::defaults) } @@ -510,8 +520,12 @@ impl SshClient { None }; + let (totp_secret, proxy_password) = { + let s = self.stored.lock(); + (s.0.to_string(), s.1.clone()) + }; let mut handle = if sc.jump_hosts.is_empty() { - let tcp = dial(&sc, &p.host, port, timeout).await.map_err(|e| format!("SSH connection failed: {e}"))?; + let tcp = dial(&sc, &proxy_password, &p.host, port, timeout).await.map_err(|e| format!("SSH connection failed: {e}"))?; self.handshake(tcp, &p.host, port, &check).await? } else { let (prev, h) = self.via_jumps(&sc, &check, passphrase.clone(), timeout).await?; @@ -524,7 +538,7 @@ impl SshClient { self.handshake(ch.into_stream(), &p.host, port, &check).await? }; - authenticate(&mut handle, Auth { user: &p.username, methods, agent: sc.agent_auth, key, password, totp_secret: sc.totp_secret.clone() }).await?; + authenticate(&mut handle, Auth { user: &p.username, methods, agent: sc.agent_auth, key, password, totp_secret }).await?; let handle = Arc::new(handle); if !shell { *self.handle.lock() = Some(handle); diff --git a/rust/core/src/models.rs b/rust/core/src/models.rs index a20020c..f4563bc 100644 --- a/rust/core/src/models.rs +++ b/rust/core/src/models.rs @@ -262,13 +262,10 @@ pub struct SshConfig { pub max_reconnect_tries: i64, #[serde(deserialize_with = "nd")] pub login_scripts: Vec, - #[serde(rename = "totpSecret")] - pub totp_secret: String, pub proxy_type: String, pub proxy_host: String, pub proxy_port: i64, pub proxy_username: String, - pub proxy_password: String, #[serde(deserialize_with = "nd")] pub jump_hosts: Vec, #[serde(deserialize_with = "nd")] @@ -937,6 +934,13 @@ impl IrcConfig { mod tests { use super::*; + #[test] + fn profiles_never_carry_proxy_passwords_or_totp_seeds() { + let c: SshConfig = serde_json::from_str(r#"{"proxyPassword":"p","totpSecret":"JBSWY3DPEHPK3PXP","proxyUsername":"u"}"#).unwrap(); + let out = serde_json::to_string(&c).unwrap(); + assert!(!out.contains("proxyPassword") && !out.contains("totpSecret") && out.contains("\"proxyUsername\":\"u\""), "{out}"); + } + #[test] fn unknown_enum_values_fall_back_per_field() { let c: SshConfig = serde_json::from_str(r#"{"hostKeyPolicy":"from-the-future","keepaliveInterval":42,"agentForwarding":true}"#).unwrap(); diff --git a/rust/core/src/secrets.rs b/rust/core/src/secrets.rs index 5a5d1ad..83b2fba 100644 --- a/rust/core/src/secrets.rs +++ b/rust/core/src/secrets.rs @@ -30,12 +30,13 @@ pub enum SecretKind { IrcNickServPassword, IrcSshPassword, IrcSshPassphrase, + ProxyPassword, } impl SecretKind { /// Every kind, so deleting a profile can purge them all from the keychain. A new variant /// must be added here as well as to `as_str`. - pub const ALL: [SecretKind; 11] = [ + pub const ALL: [SecretKind; 12] = [ Self::Password, Self::Passphrase, Self::Totp, @@ -47,6 +48,7 @@ impl SecretKind { Self::IrcNickServPassword, Self::IrcSshPassword, Self::IrcSshPassphrase, + Self::ProxyPassword, ]; /// The storage name, shared with the Go app. @@ -63,6 +65,7 @@ impl SecretKind { Self::IrcNickServPassword => "irc-nickserv-password", Self::IrcSshPassword => "irc-ssh-password", Self::IrcSshPassphrase => "irc-ssh-passphrase", + Self::ProxyPassword => "proxy-password", } } diff --git a/rust/core/src/service.rs b/rust/core/src/service.rs index 999ed70..469eada 100644 --- a/rust/core/src/service.rs +++ b/rust/core/src/service.rs @@ -230,8 +230,6 @@ impl Service { if !include_secrets { for p in &mut ps { if let Some(s) = &mut p.ssh { - s.proxy_password.clear(); - s.totp_secret.clear(); redact_login_script(&mut s.login_scripts); } if let Some(t) = &mut p.telnet { diff --git a/rust/core/src/sessions.rs b/rust/core/src/sessions.rs index 36df7e8..7bba87b 100644 --- a/rust/core/src/sessions.rs +++ b/rust/core/src/sessions.rs @@ -628,16 +628,12 @@ impl Manager { fn connect_ssh(self: &Arc, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result { let mut p = p.clone(); - let sc = p.ssh.get_or_insert_with(SshConfig::defaults); - if sc.totp_secret.is_empty() - && let Some(t) = self.secrets.get(&SecretKind::Totp.key(&p.id)) - { - sc.totp_secret = t; - } - let sc = sc.clone(); + let sc = p.ssh.get_or_insert_with(SshConfig::defaults).clone(); let password = self.secret_fn(id, &p.id, SecretKind::Password, sc.secret_mode, prompt.clone()); let passphrase = self.secret_fn(id, &p.id, SecretKind::Passphrase, sc.key_secret_mode, prompt); let c = SshClient::new(&p, self.terminal_callbacks(id, epoch)); + let stored = |k: SecretKind| self.secrets.get(&k.key(&p.id)).unwrap_or_default(); + c.set_stored_secrets(stored(SecretKind::Totp), stored(SecretKind::ProxyPassword)); c.connect(self.host_key_check(id, sc.host_key_policy), Some(password), Some(passphrase))?; for pf in sc.port_forwards.iter().filter(|f| f.enabled) { match c.start_forward(pf.clone()) {