fix: redact scripted login passwords from secret-free exports; secret kinds are an enum

- A JSON export without secrets now blanks login-script 'send' steps that
  answer a password-like 'wait' prompt (SSH and Telnet), not just the
  proxy password and inline TOTP seed (SEC-20261012-011, partial).
- Credential kinds were string literals in the session manager, profile
  editor and secret store; they are now SecretKind with one ALL list, so
  the profile-delete purge can't miss a kind (F19).

Moving proxyPassword out of the profile record is left open: it changes
.link/.nexus compatibility with the Go app and needs an owner decision.
This commit is contained in:
Andy Dixon
2026-10-12 09:16:14 +01:00
parent 3843351365
commit 3b490fa6c3
4 changed files with 142 additions and 61 deletions
+59 -19
View File
@@ -16,20 +16,60 @@ use zeroize::Zeroizing;
const VAULT_FILE: &str = "vault.json";
const MAX_VAULT_BYTES: u64 = 10 * 1024 * 1024;
/// Every credential kind the session manager stores under `profile:<id>:<kind>`.
pub const PROFILE_SECRET_KINDS: [&str; 11] = [
"password",
"passphrase",
"totp",
"github-token",
"vnc-password",
"vnc-ssh-password",
"vnc-ssh-passphrase",
"irc-server-password",
"irc-nickserv-password",
"irc-ssh-password",
"irc-ssh-passphrase",
];
/// A credential stored per profile under `profile:<id>:<kind>`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum SecretKind {
Password,
Passphrase,
Totp,
GithubToken,
VncPassword,
VncSshPassword,
VncSshPassphrase,
IrcServerPassword,
IrcNickServPassword,
IrcSshPassword,
IrcSshPassphrase,
}
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] = [
Self::Password,
Self::Passphrase,
Self::Totp,
Self::GithubToken,
Self::VncPassword,
Self::VncSshPassword,
Self::VncSshPassphrase,
Self::IrcServerPassword,
Self::IrcNickServPassword,
Self::IrcSshPassword,
Self::IrcSshPassphrase,
];
/// The storage name, shared with the Go app.
pub fn as_str(self) -> &'static str {
match self {
Self::Password => "password",
Self::Passphrase => "passphrase",
Self::Totp => "totp",
Self::GithubToken => "github-token",
Self::VncPassword => "vnc-password",
Self::VncSshPassword => "vnc-ssh-password",
Self::VncSshPassphrase => "vnc-ssh-passphrase",
Self::IrcServerPassword => "irc-server-password",
Self::IrcNickServPassword => "irc-nickserv-password",
Self::IrcSshPassword => "irc-ssh-password",
Self::IrcSshPassphrase => "irc-ssh-passphrase",
}
}
pub fn key(self, profile_id: &str) -> String {
format!("profile:{profile_id}:{}", self.as_str())
}
}
/// Argon2id with the Go parameters: t=3, m=128 MiB, p=4, 32-byte key. The key is wiped on drop.
pub fn derive_key(password: &str, salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
@@ -306,8 +346,8 @@ impl Secrets {
/// Removes every credential saved for a profile from both stores.
pub fn delete_profile(&self, profile_id: &str) {
if self.keychain_ok {
for kind in PROFILE_SECRET_KINDS {
self.keychain.delete(&format!("profile:{profile_id}:{kind}"));
for kind in SecretKind::ALL {
self.keychain.delete(&kind.key(profile_id));
}
}
let _ = self.vault.delete_prefix(&format!("profile:{profile_id}:"));
@@ -395,12 +435,12 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
let s = Secrets { vault: Vault::new(dir.clone()), keychain: Keychain::new("NexusTerminalTest", None), keychain_ok: false };
s.vault.initialise("pw").unwrap();
for kind in PROFILE_SECRET_KINDS {
s.vault.set(&format!("profile:p1:{kind}"), "v").unwrap();
for kind in SecretKind::ALL {
s.vault.set(&kind.key("p1"), "v").unwrap();
}
s.vault.set("profile:p2:password", "keep").unwrap();
s.delete_profile("p1");
assert!(PROFILE_SECRET_KINDS.iter().all(|k| s.get(&format!("profile:p1:{k}")).is_none()));
assert!(SecretKind::ALL.iter().all(|k| s.get(&k.key("p1")).is_none()));
assert_eq!(s.get("profile:p2:password").as_deref(), Some("keep"));
std::fs::remove_dir_all(dir).unwrap();
}
+43 -5
View File
@@ -5,6 +5,7 @@
use crate::clients::sftp::{self, FileEntry};
use crate::config::AppConfig;
use crate::models::*;
use crate::secrets::SecretKind;
use crate::secrets::{KEYCHAIN_SERVICE, Keychain, LEGACY_KEYCHAIN_SERVICE, Secrets, Vault};
use crate::sessions::Manager;
use crate::storage::Store;
@@ -127,21 +128,21 @@ impl Service {
// --- credentials --------------------------------------------------------------------
pub fn set_profile_secret(&self, profile_id: &str, kind: &str, value: &str) -> Result<()> {
self.secrets.set_checked(&format!("profile:{profile_id}:{kind}"), value)
pub fn set_profile_secret(&self, profile_id: &str, kind: SecretKind, value: &str) -> Result<()> {
self.secrets.set_checked(&kind.key(profile_id), value)
}
pub fn has_totp(&self, profile_id: &str) -> bool {
self.secrets.get(&format!("profile:{profile_id}:totp")).is_some()
self.secrets.get(&SecretKind::Totp.key(profile_id)).is_some()
}
pub fn totp_code(&self, profile_id: &str) -> Result<String> {
let s = self.secrets.get(&format!("profile:{profile_id}:totp")).ok_or("no TOTP secret configured for this profile")?;
let s = self.secrets.get(&SecretKind::Totp.key(profile_id)).ok_or("no TOTP secret configured for this profile")?;
crate::clients::ssh::totp_code(&s).map_err(|e| format!("failed to generate TOTP code: {e}").into())
}
pub fn delete_totp(&self, profile_id: &str) {
self.secrets.delete(&format!("profile:{profile_id}:totp"));
self.secrets.delete(&SecretKind::Totp.key(profile_id));
}
// --- known hosts, prefs, snippets, audit --------------------------------------------
@@ -226,6 +227,10 @@ impl Service {
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 {
redact_login_script(&mut t.login_scripts);
}
}
}
@@ -390,3 +395,36 @@ impl Service {
self.store.list_audit(limit).unwrap_or_default()
}
}
/// Blanks `send` steps that answer a password-like prompt (the `wait` before them), the documented
/// way to script a login, so a "without secrets" export doesn't carry them.
fn redact_login_script(steps: &mut [LoginScriptStep]) {
let mut after_secret_prompt = false;
for s in steps {
match s.kind.as_str() {
"wait" => {
let v = s.value.to_lowercase();
after_secret_prompt = ["password", "passphrase", "passcode", "pin", "token", "secret"].iter().any(|k| v.contains(k));
}
"send" if after_secret_prompt => {
s.value.clear();
after_secret_prompt = false;
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn login_script_passwords_are_redacted() {
let step = |k: &str, v: &str| LoginScriptStep { kind: k.into(), value: v.into(), timeout: 0 };
let mut steps = vec![step("wait", "login:"), step("send", "andy"), step("wait", "Password:"), step("send", "mysecretpassword"), step("send", "uptime")];
redact_login_script(&mut steps);
let values: Vec<&str> = steps.iter().map(|s| s.value.as_str()).collect();
assert_eq!(values, ["login:", "andy", "Password:", "", "uptime"]);
}
}
+22 -19
View File
@@ -8,7 +8,7 @@ use crate::clients::ssh::{HostKeyCheck, HostKeyInfo, SecretFn, SshClient};
use crate::clients::{self, Callbacks, github, irc, raw, serial, telnet};
use crate::models::*;
use crate::recording::{self, Recorder};
use crate::secrets::Secrets;
use crate::secrets::{SecretKind, Secrets};
use crate::storage::Store;
use parking_lot::Mutex;
use std::collections::{HashMap, HashSet};
@@ -482,10 +482,10 @@ impl Manager {
/// are saved once the connection succeeds; session-only values are asked once per session and
/// reused on reconnects (so e.g. a NickServ password entered once keeps identifying after a
/// drop).
pub fn secret_fn(&self, session: &str, profile_id: &str, kind: &str, mode: SecretMode, prompt: Prompt) -> SecretFn {
let key = format!("profile:{profile_id}:{kind}");
pub fn secret_fn(&self, session: &str, profile_id: &str, kind: SecretKind, mode: SecretMode, prompt: Prompt) -> SecretFn {
let key = kind.key(profile_id);
let (secrets, store) = (self.secrets.clone(), self.store.clone());
let (pid, kind) = (profile_id.to_string(), kind.to_string());
let (pid, kind) = (profile_id.to_string(), kind.as_str());
let state: SessionSecrets = self.sessions.lock().get(session).map(|s| s.session_secrets.clone()).unwrap_or_default();
Arc::new(move || {
if mode == SecretMode::Saved {
@@ -629,13 +629,13 @@ impl Manager {
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(&format!("profile:{}:totp", p.id))
&& let Some(t) = self.secrets.get(&SecretKind::Totp.key(&p.id))
{
sc.totp_secret = t;
}
let sc = sc.clone();
let password = self.secret_fn(id, &p.id, "password", sc.secret_mode, prompt.clone());
let passphrase = self.secret_fn(id, &p.id, "passphrase", sc.key_secret_mode, prompt);
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));
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) {
@@ -672,8 +672,10 @@ impl Manager {
c.auth_methods = vec![if method.is_empty() { "publickey".into() } else { method.into() }];
c.private_key_path = key.into();
c.agent_auth = agent;
let password = (method == "password").then(|| self.secret_fn(id, profile_id, &format!("{kind}-ssh-password"), mode, prompt.clone()));
let passphrase = self.secret_fn(id, profile_id, &format!("{kind}-ssh-passphrase"), mode, prompt);
let (pw_kind, pp_kind) =
if kind == "vnc" { (SecretKind::VncSshPassword, SecretKind::VncSshPassphrase) } else { (SecretKind::IrcSshPassword, SecretKind::IrcSshPassphrase) };
let password = (method == "password").then(|| self.secret_fn(id, profile_id, pw_kind, mode, prompt.clone()));
let passphrase = self.secret_fn(id, profile_id, pp_kind, mode, prompt);
let t = SshClient::new(&tp, Callbacks::noop());
t.connect_tunnel_only(self.host_key_check(id, HostKeyPolicy::Ask), password, Some(passphrase))?;
Ok(t)
@@ -681,7 +683,7 @@ impl Manager {
fn connect_github(self: &Arc<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> {
let gh = p.github.clone().ok_or_else(|| format!("GitHub configuration missing on profile {:?}", p.id))?;
let token = self.secret_fn(id, &p.id, "github-token", gh.secret_mode, prompt.clone())().map_err(|e| format!("obtaining GitHub token: {e}"))?;
let token = self.secret_fn(id, &p.id, SecretKind::GithubToken, gh.secret_mode, prompt.clone())().map_err(|e| format!("obtaining GitHub token: {e}"))?;
if token.is_empty() {
return err("GitHub token cannot be empty".into());
}
@@ -694,7 +696,7 @@ impl Manager {
let retry = auth.then(|| prompt("GitHub token did not work. Please enter a valid token:").ok()).flatten().filter(|t| !t.is_empty());
let Some(t) = retry else { return err(format!("GitHub authentication failed: {e}")) };
if gh.secret_mode == SecretMode::Saved {
let _ = self.with(id, |s| s.session_secrets.lock().pending.insert(format!("profile:{}:github-token", p.id), t.clone()));
let _ = self.with(id, |s| s.session_secrets.lock().pending.insert(SecretKind::GithubToken.key(&p.id), t.clone()));
}
client = github::Client::new(&gh.base_url, &t);
client.validate_token().map_err(|e| format!("GitHub authentication failed: {e}"))?
@@ -742,12 +744,13 @@ impl Manager {
fn connect_irc(self: &Arc<Self>, id: &str, epoch: u64, p: &Profile, prompt: Prompt) -> Result<Client> {
let ic = p.irc.clone().ok_or("IRC configuration missing")?;
let server_pass = if ic.server_password_required {
self.secret_fn(id, &p.id, "irc-server-password", ic.server_password_mode, prompt.clone())().map_err(|e| format!("IRC server password: {e}"))?
self.secret_fn(id, &p.id, SecretKind::IrcServerPassword, ic.server_password_mode, prompt.clone())()
.map_err(|e| format!("IRC server password: {e}"))?
} else {
String::new()
};
let ns_pass = if ic.nick_serv_identify {
self.secret_fn(id, &p.id, "irc-nickserv-password", ic.nick_serv_mode, prompt.clone())().map_err(|e| format!("NickServ password: {e}"))?
self.secret_fn(id, &p.id, SecretKind::IrcNickServPassword, ic.nick_serv_mode, prompt.clone())().map_err(|e| format!("NickServ password: {e}"))?
} else {
String::new()
};
@@ -852,7 +855,7 @@ impl Manager {
} else {
None
};
let password = self.secret_fn(id, &p.id, "vnc-password", vc.secret_mode, prompt);
let password = self.secret_fn(id, &p.id, SecretKind::VncPassword, vc.secret_mode, prompt);
let cb = self.terminal_callbacks(id, epoch);
let (me, sid) = (self.clone(), id.to_string());
let (me2, sid2) = (self.clone(), id.to_string());
@@ -1474,7 +1477,7 @@ mod tests {
}
}));
let p = Profile { host: "127.0.0.1".into(), port: 1, ..Profile::new(Protocol::Raw) };
let key = format!("profile:{}:password", p.id);
let key = SecretKind::Password.key(&p.id);
let info = |id: &str| SessionInfo {
id: id.into(),
profile_id: p.id.clone(),
@@ -1489,7 +1492,7 @@ mod tests {
error: String::new(),
};
let asked = Arc::new(AtomicUsize::new(0));
let resolve = |m: &Arc<Manager>, id: &str| m.secret_fn(id, &p.id, "password", SecretMode::Saved, counting_prompt(asked.clone()))().unwrap();
let resolve = |m: &Arc<Manager>, id: &str| m.secret_fn(id, &p.id, SecretKind::Password, SecretMode::Saved, counting_prompt(asked.clone()))().unwrap();
// Locked store: the credential can't be saved, and the user is told.
let e = m.new_session(info("s0"), Some(p.clone()), String::new(), SessionSecrets::default());
@@ -1545,17 +1548,17 @@ mod tests {
let asked = Arc::new(AtomicUsize::new(0));
// Two separate resolutions (as two connects of the same session make) prompt once.
for _ in 0..2 {
let f = m.secret_fn("s1", &p.id, "irc-nickserv-password", SecretMode::SessionOnly, counting_prompt(asked.clone()));
let f = m.secret_fn("s1", &p.id, SecretKind::IrcNickServPassword, SecretMode::SessionOnly, counting_prompt(asked.clone()));
assert_eq!(f().unwrap(), "hunter2");
}
assert_eq!(asked.load(Ordering::SeqCst), 1);
// Ask-every-time still prompts each time.
let f = m.secret_fn("s1", &p.id, "irc-nickserv-password", SecretMode::AskEveryTime, counting_prompt(asked.clone()));
let f = m.secret_fn("s1", &p.id, SecretKind::IrcNickServPassword, SecretMode::AskEveryTime, counting_prompt(asked.clone()));
f().unwrap();
assert_eq!(asked.load(Ordering::SeqCst), 2);
// An explicit reconnect carries the cache to the new session id.
let new = m.reconnect("s1").unwrap();
let f = m.secret_fn(&new, &p.id, "irc-nickserv-password", SecretMode::SessionOnly, counting_prompt(asked.clone()));
let f = m.secret_fn(&new, &p.id, SecretKind::IrcNickServPassword, SecretMode::SessionOnly, counting_prompt(asked.clone()));
assert_eq!(f().unwrap(), "hunter2");
assert_eq!(asked.load(Ordering::SeqCst), 2);
m.close_all();