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:
+1
-1
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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