From 672c71e26fdd69896ccf6e30011a4ec9c32b26aa Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Mon, 12 Oct 2026 10:02:08 +0100 Subject: [PATCH] fix: proxy passwords and TOTP seeds live only in the secret store SshConfig no longer has proxyPassword or totpSecret fields, so neither can sit in clear in nexus.db or travel in an export. The proxy password is a SecretKind (profile::proxy-password) edited like other saved credentials and purged with the profile; the session manager hands both it and the TOTP seed to the SSH client from the keychain/vault at connect time. This drops compatibility with Go-era profiles and .link/.nexus files that carry these values inline: they are ignored on read and must be re-entered (SEC-20261012-011). --- docs/user-manual.md | 2 +- rust/app/src/profile_editor.rs | 3 +-- rust/core/src/clients/ssh.rs | 32 +++++++++++++++++++++++--------- rust/core/src/models.rs | 10 +++++++--- rust/core/src/secrets.rs | 5 ++++- rust/core/src/service.rs | 2 -- rust/core/src/sessions.rs | 10 +++------- 7 files changed, 39 insertions(+), 25 deletions(-) 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()) {