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:
@@ -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| {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user