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:<id>: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).
This commit is contained in:
Andy Dixon
2026-10-12 10:02:08 +01:00
parent 2e11d40034
commit 672c71e26f
7 changed files with 39 additions and 25 deletions
+1 -2
View File
@@ -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| {
+23 -9
View File
@@ -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<T: Send + 'static>(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<tokio::net::TcpStream> {
async fn dial(sc: &SshConfig, proxy_password: &str, host: &str, port: u16, timeout: Duration) -> Result<tokio::net::TcpStream> {
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<tokio::net::TcpStream> {
async fn socks5(sc: &SshConfig, proxy_password: &str, host: &str, port: u16) -> Result<tokio::net::TcpStream> {
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<tokio::net::Tcp
match r[1] {
0 => {}
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<Vec<u8>> {
Ok(req)
}
async fn http_connect(sc: &SshConfig, host: &str, port: u16) -> Result<tokio::net::TcpStream> {
async fn http_connect(sc: &SshConfig, proxy_password: &str, host: &str, port: u16) -> Result<tokio::net::TcpStream> {
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<HashMap<String, Forward>>,
sftp: tokio::sync::Mutex<Option<Arc<russh_sftp::client::SftpSession>>>,
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<String>, Zeroizing<String>)>,
}
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);
+7 -3
View File
@@ -262,13 +262,10 @@ pub struct SshConfig {
pub max_reconnect_tries: i64,
#[serde(deserialize_with = "nd")]
pub login_scripts: Vec<LoginScriptStep>,
#[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<JumpHost>,
#[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();
+4 -1
View File
@@ -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",
}
}
-2
View File
@@ -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 {
+3 -7
View File
@@ -628,16 +628,12 @@ impl Manager {
fn connect_ssh(self: &Arc<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> {
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()) {