feat: credentials page, master password change and a Diagnostics tab

- Settings → Security lists every profile's stored credentials (kind and
  whether it's in the keychain or the vault; the keychain is probed per
  kind on a background thread since it can't be listed) with Forget.
- Change Master Password re-encrypts every vault entry under a fresh
  salt and key with a new verifier, after checking the current password,
  in one atomic write (Vault::change_password; tested that the old
  password stops working and every value survives).
- Settings → Diagnostics shows version, platform, data/database/log
  paths and sizes, keychain and vault state, counts, sync and launch
  settings, with Copy Diagnostics; it never includes credentials.
  Openable with --open settings:diagnostics.
This commit is contained in:
Andy Dixon
2026-10-12 19:26:52 +01:00
parent 1c590f47ff
commit 5acb41158c
7 changed files with 244 additions and 14 deletions
+1
View File
@@ -252,6 +252,7 @@ impl App {
"terminal" => SettingsTab::Terminal,
"security" => SettingsTab::Security,
"knownhosts" => SettingsTab::KnownHosts,
"diagnostics" => SettingsTab::Diagnostics,
"help" => SettingsTab::Help,
"about" => SettingsTab::About,
_ => SettingsTab::General,
+156 -12
View File
@@ -88,11 +88,16 @@ pub fn strength_meter(ui: &mut Ui, pw: &str) {
/// Create or unlock the vault.
struct MasterPrompt {
new: bool,
/// Changing the password: the current one (re-encrypts the vault under `pw`).
change: Option<String>,
pw: String,
confirm: String,
error: String,
}
/// (profile id, profile name, kind, "keychain"/"vault") per stored credential.
type Credentials = Vec<(String, String, nexus_core::secrets::SecretKind, &'static str)>;
#[derive(Default)]
pub struct Settings {
open: bool,
@@ -105,6 +110,8 @@ pub struct Settings {
/// Add-CA form: (host pattern, public key), shown when Some.
new_ca: Option<(String, String)>,
sync_password: String,
creds: Option<Credentials>,
creds_job: Option<Job<Credentials>>,
default_shell: String,
help_topic: usize,
master: Option<MasterPrompt>,
@@ -117,6 +124,7 @@ impl Settings {
pub fn open(&mut self, tab: SettingsTab) {
self.open = true;
self.fresh = true;
self.creds = None;
self.tab = tab;
}
@@ -155,6 +163,7 @@ impl Settings {
SettingsTab::Terminal => self.terminal(ui),
SettingsTab::Security => self.security(ui, s),
SettingsTab::KnownHosts => self.known_hosts(ui, s),
SettingsTab::Diagnostics => self.diagnostics(ui, s),
SettingsTab::Help => self.help(ui),
SettingsTab::About => self.about(ui, s),
}
@@ -210,6 +219,7 @@ impl Settings {
(SettingsTab::Terminal, icon::TERMINAL_WINDOW, "Terminal"),
(SettingsTab::Security, icon::SHIELD_CHECK, "Security"),
(SettingsTab::KnownHosts, icon::FINGERPRINT, "Known Hosts"),
(SettingsTab::Diagnostics, icon::STETHOSCOPE, "Diagnostics"),
(SettingsTab::Help, icon::BOOK_OPEN, "Help"),
(SettingsTab::About, icon::INFO, "About"),
] {
@@ -371,20 +381,71 @@ impl Settings {
ui.label("Working…");
} else if !init {
if widgets::primary(ui, icon::SHIELD_PLUS, "Initialise Secret Store").clicked() {
self.master = Some(MasterPrompt { new: true, pw: String::new(), confirm: String::new(), error: String::new() });
self.master = Some(MasterPrompt { new: true, change: None, pw: String::new(), confirm: String::new(), error: String::new() });
}
} else if !unlocked {
if widgets::primary(ui, icon::LOCK_OPEN, "Unlock").clicked() {
self.master = Some(MasterPrompt { new: false, pw: String::new(), confirm: String::new(), error: String::new() });
self.master = Some(MasterPrompt { new: false, change: None, pw: String::new(), confirm: String::new(), error: String::new() });
}
} else if widgets::button(ui, icon::LOCK, "Lock").clicked() {
s.svc.secrets.vault.lock();
s.info("Secret store locked");
} else {
ui.horizontal(|ui| {
if widgets::button(ui, icon::LOCK, "Lock").clicked() {
s.svc.secrets.vault.lock();
s.info("Secret store locked");
}
if widgets::button(ui, icon::KEY, "Change Master Password…").clicked() {
self.master =
Some(MasterPrompt { new: true, change: Some(String::new()), pw: String::new(), confirm: String::new(), error: String::new() });
}
});
}
});
self.credentials_section(ui, s);
self.sync_section(ui, s);
}
/// Stored credentials per profile, with Forget. Loaded in the background (keychain probes).
fn credentials_section(&mut self, ui: &mut Ui, s: &mut Shared) {
let t = Tokens::get(ui.ctx());
ui.add_space(12.0);
widgets::section(ui, "Stored credentials");
if let Some(list) = poll(&mut self.creds_job) {
self.creds = Some(list);
}
if self.creds.is_none() && self.creds_job.is_none() {
let svc = s.svc.clone();
self.creds_job = Some(Job::spawn(ui.ctx(), move || svc.credential_inventory()));
}
let Some(list) = &self.creds else {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Checking the keychain and vault…");
});
return;
};
if list.is_empty() {
widgets::hint(ui, "No saved credentials (the vault must be unlocked to see what it holds).");
return;
}
let mut forget = None;
egui::Grid::new("stored-creds").num_columns(4).striped(true).spacing(vec2(14.0, 5.0)).show(ui, |ui| {
for (id, name, kind, at) in list {
ui.label(name);
ui.label(RichText::new(kind.as_str()).font(theme::mono(11.5)));
ui.label(RichText::new(*at).color(t.text_dim));
if ui.button(RichText::new(format!("{} Forget", icon::TRASH)).color(t.danger)).clicked() {
forget = Some((id.clone(), *kind));
}
ui.end_row();
}
});
if let Some((id, kind)) = forget {
s.svc.forget_credential(&id, kind);
s.success(format!("Forgot the {} credential", kind.as_str()));
self.creds = None;
}
}
fn sync_section(&mut self, ui: &mut Ui, s: &mut Shared) {
ui.add_space(12.0);
widgets::section(ui, "Profile sync");
@@ -453,17 +514,29 @@ impl Settings {
let mut cancel = false;
let r = egui::Modal::new(egui::Id::new("master-password")).show(ctx, |ui| {
ui.set_width(380.0);
let title = if m.new { "Create Master Password" } else { "Unlock Secret Store" };
let title = if m.change.is_some() {
"Change Master Password"
} else if m.new {
"Create Master Password"
} else {
"Unlock Secret Store"
};
ui.label(RichText::new(format!("{} {title}", icon::KEY)).font(theme::semibold(15.0)));
ui.label(if m.new {
ui.label(if m.change.is_some() {
"Every saved credential is re-encrypted under the new password."
} else if m.new {
"Choose a strong master password. This protects all saved credentials."
} else {
"Enter your master password to unlock the secret store."
});
ui.add_space(4.0);
ui.label("Master password");
if let Some(old) = &mut m.change {
ui.label("Current password");
ui.add(egui::TextEdit::singleline(old).password(true).desired_width(f32::INFINITY));
}
ui.label(if m.change.is_some() { "New password" } else { "Master password" });
let r = ui.add(egui::TextEdit::singleline(&mut m.pw).password(true).hint_text("Password…").desired_width(f32::INFINITY));
if !r.has_focus() && m.pw.is_empty() && m.confirm.is_empty() {
if m.change.is_none() && !r.has_focus() && m.pw.is_empty() && m.confirm.is_empty() {
r.request_focus();
}
let mut enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
@@ -478,7 +551,14 @@ impl Settings {
}
ui.add_space(6.0);
ui.horizontal(|ui| {
submit = widgets::primary(ui, icon::CHECK, if m.new { "Create" } else { "Unlock" }).clicked() || enter;
let label = if m.change.is_some() {
"Change"
} else if m.new {
"Create"
} else {
"Unlock"
};
submit = widgets::primary(ui, icon::CHECK, label).clicked() || enter;
cancel = ui.button("Cancel").clicked();
});
});
@@ -488,11 +568,13 @@ impl Settings {
} else if m.new && m.pw != m.confirm {
m.error = "Passwords do not match.".into();
} else {
let (new, pw) = (m.new, std::mem::take(&mut m.pw));
let (new, pw, change) = (m.new, std::mem::take(&mut m.pw), m.change.take());
let svc = s.svc.clone();
self.vault_job = Some(Job::spawn(ctx, move || {
let v = &svc.secrets.vault;
if new {
if let Some(old) = change {
v.change_password(&old, &pw).map(|_| "Master password changed").map_err(|e| format!("Couldn't change the master password: {e}"))
} else if new {
v.initialise(&pw).map(|_| "Secret store created").map_err(|e| format!("Failed to initialise: {e}"))
} else {
v.unlock(&pw).map(|_| "Secret store unlocked").map_err(|e| format!("Unlock failed: {e}"))
@@ -607,6 +689,68 @@ impl Settings {
}
}
// --- Diagnostics ----------------------------------------------------------------------
/// What a bug report needs, without secrets: versions, paths, storage and sync status.
fn diagnostics_report(s: &Shared) -> Vec<(&'static str, String)> {
let cfg = &s.svc.config;
let size = |p: &std::path::Path| std::fs::metadata(p).map_or("—".to_string(), |m| widgets::human_size(m.len()));
let sec = &s.svc.secrets;
let known = s.svc.known_hosts();
let cas = known.iter().filter(|k| k.key_type == nexus_core::known_hosts::CA_KEY_TYPE).count();
let yes = |b: bool| if b { "yes" } else { "no" }.to_string();
vec![
("Version", nexus_core::service::VERSION.to_string()),
("Platform", format!("{} {}", std::env::consts::OS, std::env::consts::ARCH)),
("Data folder", cfg.data_dir.display().to_string()),
("Database", format!("{} ({})", cfg.db_path.display(), size(&cfg.db_path))),
("Log", format!("{} ({})", cfg.data_dir.join("nexus.log").display(), size(&cfg.data_dir.join("nexus.log")))),
("Recordings", cfg.recordings_dir.display().to_string()),
("OS keychain", if sec.keychain_ok { "available".into() } else { "unavailable — credentials go to the vault".into() }),
(
"Vault",
match (sec.vault.is_initialised(), sec.vault.is_unlocked()) {
(false, _) => "not set up".into(),
(true, false) => "locked".into(),
(true, true) => format!("unlocked, {} entr{}", sec.vault.keys().len(), if sec.vault.keys().len() == 1 { "y" } else { "ies" }),
},
),
("Profiles", s.profiles.len().to_string()),
("Known hosts", format!("{} ({cas} SSH certificate authorit{})", known.len() - cas, if cas == 1 { "y" } else { "ies" })),
("Open sessions", s.panes.len().to_string()),
(
"Profile sync",
if s.prefs.sync_folder.is_empty() {
"off".into()
} else {
format!("{} (password stored: {})", s.prefs.sync_folder, yes(s.svc.has_sync_password()))
},
),
("Update check at launch", yes(s.prefs.check_for_updates)),
("Restore tabs", yes(s.prefs.restore_sessions)),
]
}
fn diagnostics(&mut self, ui: &mut Ui, s: &mut Shared) {
let t = Tokens::get(ui.ctx());
let report = Self::diagnostics_report(s);
widgets::hint(ui, "Useful when reporting a problem. It contains paths and counts, never credentials.");
ui.add_space(4.0);
egui::Grid::new("diagnostics").num_columns(2).striped(true).spacing(vec2(16.0, 6.0)).show(ui, |ui| {
for (k, v) in &report {
ui.label(RichText::new(*k).color(t.text_dim));
ui.add(egui::Label::new(RichText::new(v).font(theme::mono(12.0))).wrap());
ui.end_row();
}
});
ui.add_space(8.0);
if widgets::button(ui, icon::COPY, "Copy Diagnostics").clicked() {
let text: String = report.iter().map(|(k, v)| format!("{k}: {v}\n")).collect();
ui.ctx().copy_text(text);
s.success("Diagnostics copied");
}
}
// --- Help -----------------------------------------------------------------------------
fn help(&mut self, ui: &mut Ui) {
+1
View File
@@ -40,6 +40,7 @@ pub enum SettingsTab {
Terminal,
Security,
KnownHosts,
Diagnostics,
Help,
About,
}
+65
View File
@@ -203,6 +203,34 @@ impl Vault {
*self.state.lock() = None;
}
/// Re-encrypts every entry under `new` (fresh salt, key and verifier) after checking `old`.
/// One atomic write: a crash leaves either the old vault or the new one.
pub fn change_password(&self, old: &str, new: &str) -> Result<()> {
if new.is_empty() {
return Err("secrets: the new master password can't be empty".into());
}
self.unlock(old)?;
let mut st = self.state.lock();
let u = st.as_mut().ok_or("secrets: vault is locked")?;
let mut plain = Vec::with_capacity(u.vault.secrets.len());
for (k, v) in &u.vault.secrets {
let data = B64.decode(v).map_err(|e| format!("secrets: corrupt entry {k:?}: {e}"))?;
plain.push((k.clone(), Zeroizing::new(open(&u.key, &data)?)));
}
let salt = random_bytes::<32>();
let key = derive_key(new, &salt)?;
let secrets = plain.iter().map(|(k, v)| (k.clone(), B64.encode(seal(&key, v)))).collect();
let next = Unlocked { vault: VaultFile { version: 1, salt: B64.encode(salt), secrets, verifier: verifier(&key) }, key };
self.save(&next)?;
*u = next;
Ok(())
}
/// Stored keys (not values); empty while locked.
pub fn keys(&self) -> Vec<String> {
self.state.lock().as_ref().map(|u| u.vault.secrets.keys().cloned().collect()).unwrap_or_default()
}
fn save(&self, u: &Unlocked) -> Result<()> {
let data = serde_json::to_vec_pretty(&u.vault)?;
write_private(&self.path(), &data)
@@ -352,6 +380,25 @@ impl Secrets {
}
/// Removes every credential saved for a profile from both stores.
/// Which credentials a profile has stored, and where. Probes the keychain once per kind
/// (it can't be listed), so call it off the UI thread.
pub fn stored_for(&self, profile_id: &str) -> Vec<(SecretKind, &'static str)> {
let vault: std::collections::HashSet<String> = self.vault.keys().into_iter().collect();
SecretKind::ALL
.iter()
.filter_map(|&k| {
let key = k.key(profile_id);
if self.keychain_ok && self.keychain.get(&key).is_some() {
Some((k, "keychain"))
} else if vault.contains(&key) {
Some((k, "vault"))
} else {
None
}
})
.collect()
}
pub fn delete_profile(&self, profile_id: &str) {
if self.keychain_ok {
for kind in SecretKind::ALL {
@@ -399,6 +446,24 @@ mod tests {
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn changing_the_master_password_keeps_every_secret() {
let dir = std::env::temp_dir().join(format!("nexus-vault-rekey-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let v = Vault::new(dir.clone());
v.initialise("old").unwrap();
v.set("a", "1").unwrap();
v.set("profile:p:password", "hunter2").unwrap();
assert!(v.change_password("wrong", "new").is_err());
v.change_password("old", "new").unwrap();
v.lock();
assert!(v.unlock("old").is_err(), "the old password no longer opens it");
v.unlock("new").unwrap();
assert_eq!((v.get("a").unwrap(), v.get("profile:p:password").unwrap()), ("1".to_string(), "hunter2".to_string()));
assert_eq!(v.keys(), ["a", "profile:p:password"]);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn private_writes_are_atomic_and_owner_only() {
let dir = std::env::temp_dir().join(format!("nexus-wp-{}", std::process::id()));
+17
View File
@@ -122,6 +122,23 @@ impl Service {
self.secrets.set_checked(SYNC_PASSWORD, password)
}
/// Every profile's stored credentials: (profile id, profile name, kind, "keychain"/"vault").
/// Slow-ish (keychain probes); run off the UI thread.
pub fn credential_inventory(&self) -> Vec<(String, String, crate::secrets::SecretKind, &'static str)> {
let mut out = vec![];
for p in self.profiles() {
for (kind, at) in self.secrets.stored_for(&p.id) {
out.push((p.id.clone(), p.display_name.clone(), kind, at));
}
}
out
}
pub fn forget_credential(&self, profile_id: &str, kind: crate::secrets::SecretKind) {
self.secrets.delete(&kind.key(profile_id));
self.store.add_audit("secret.delete", profile_id, "", &format!("Forgot {} credential", kind.as_str()));
}
pub fn has_sync_password(&self) -> bool {
self.secrets.get(SYNC_PASSWORD).is_some()
}