diff --git a/docs/overview.md b/docs/overview.md index 4a04528..e5d694a 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -78,6 +78,7 @@ Use Nexus Terminal when you need to: - Inline images in the iTerm2 format (`imgcat`, `wezterm imgcat`, `chafa -f iterm`…): PNG, JPEG and GIF, sized in cells, pixels or percent, scrolling with the text. - Shell integration (OSC 133): jump between prompts, copy the last command's output, and see each command's exit status and duration beside its prompt. - Status-bar latency for the active SSH session. +- Settings → Diagnostics summarises versions, paths, credential storage and sync status for bug reports; Settings → Security lists stored credentials per profile and changes the vault master password. ### SSH capabilities diff --git a/docs/user-manual.md b/docs/user-manual.md index 33e931b..51748fc 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -450,8 +450,9 @@ Open settings with **Cmd+,** (macOS) or **Ctrl+,** (Windows/Linux), the sidebar - **General**: app theme (Pro, Midnight or Light), default protocol, restore tabs at launch (reopens the last session's tabs and split panes: saved connections reconnect, local shells start in the directory they were in; Quick Connect tabs aren't restored), confirm on close, confirm on disconnect, remote clipboard writes (OSC 52 and VNC; a toast shows what was copied), multi-line paste confirmation, the launch-time update check, status bar, sidebar width, and local shell (leave empty for the system default; **Browse…** to pick one). - **Terminal**: colour scheme, font family, font size, scrollback lines and cursor style. These are the defaults for new profiles; profiles can override them. -- **Security**: keychain and vault status; initialise, unlock or lock the secret store. -- **Known Hosts**: saved host keys with first-seen and last-seen times, and **Remove**. +- **Security**: keychain and vault status; initialise, unlock or lock the secret store; **Change Master Password…** (re-encrypts every saved credential under the new password in one step); **Stored credentials**, listing each profile's saved passwords, passphrases and tokens and whether they're in the keychain or the vault, with **Forget**; and profile sync. +- **Known Hosts**: saved host keys with first-seen and last-seen times, and **Remove**; import/export `~/.ssh/known_hosts` and trusted SSH certificate authorities. +- **Diagnostics**: version, platform, data, database and log locations, keychain and vault state, counts and sync status, with **Copy Diagnostics** for bug reports (no credentials are included). - **Help**: built-in help topics, including connection types, shortcuts and troubleshooting. - **About**: version, **Check for Updates**, and **Import ~/.ssh/config**. diff --git a/rust/app/src/app.rs b/rust/app/src/app.rs index 6003da9..8598252 100644 --- a/rust/app/src/app.rs +++ b/rust/app/src/app.rs @@ -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, diff --git a/rust/app/src/settings.rs b/rust/app/src/settings.rs index 9fc08e9..6931c17 100644 --- a/rust/app/src/settings.rs +++ b/rust/app/src/settings.rs @@ -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, 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, + creds_job: Option>, default_shell: String, help_topic: usize, master: Option, @@ -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) { diff --git a/rust/app/src/state.rs b/rust/app/src/state.rs index 334b053..06f43fa 100644 --- a/rust/app/src/state.rs +++ b/rust/app/src/state.rs @@ -40,6 +40,7 @@ pub enum SettingsTab { Terminal, Security, KnownHosts, + Diagnostics, Help, About, } diff --git a/rust/core/src/secrets.rs b/rust/core/src/secrets.rs index 6d899d5..9d5e4f7 100644 --- a/rust/core/src/secrets.rs +++ b/rust/core/src/secrets.rs @@ -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 { + 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 = 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())); diff --git a/rust/core/src/service.rs b/rust/core/src/service.rs index e871e37..7478762 100644 --- a/rust/core/src/service.rs +++ b/rust/core/src/service.rs @@ -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() }