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:
@@ -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.
|
- 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.
|
- 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.
|
- 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
|
### SSH capabilities
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -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).
|
- **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.
|
- **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.
|
- **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**.
|
- **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.
|
- **Help**: built-in help topics, including connection types, shortcuts and troubleshooting.
|
||||||
- **About**: version, **Check for Updates**, and **Import ~/.ssh/config**.
|
- **About**: version, **Check for Updates**, and **Import ~/.ssh/config**.
|
||||||
|
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ impl App {
|
|||||||
"terminal" => SettingsTab::Terminal,
|
"terminal" => SettingsTab::Terminal,
|
||||||
"security" => SettingsTab::Security,
|
"security" => SettingsTab::Security,
|
||||||
"knownhosts" => SettingsTab::KnownHosts,
|
"knownhosts" => SettingsTab::KnownHosts,
|
||||||
|
"diagnostics" => SettingsTab::Diagnostics,
|
||||||
"help" => SettingsTab::Help,
|
"help" => SettingsTab::Help,
|
||||||
"about" => SettingsTab::About,
|
"about" => SettingsTab::About,
|
||||||
_ => SettingsTab::General,
|
_ => SettingsTab::General,
|
||||||
|
|||||||
+154
-10
@@ -88,11 +88,16 @@ pub fn strength_meter(ui: &mut Ui, pw: &str) {
|
|||||||
/// Create or unlock the vault.
|
/// Create or unlock the vault.
|
||||||
struct MasterPrompt {
|
struct MasterPrompt {
|
||||||
new: bool,
|
new: bool,
|
||||||
|
/// Changing the password: the current one (re-encrypts the vault under `pw`).
|
||||||
|
change: Option<String>,
|
||||||
pw: String,
|
pw: String,
|
||||||
confirm: String,
|
confirm: String,
|
||||||
error: 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)]
|
#[derive(Default)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
open: bool,
|
open: bool,
|
||||||
@@ -105,6 +110,8 @@ pub struct Settings {
|
|||||||
/// Add-CA form: (host pattern, public key), shown when Some.
|
/// Add-CA form: (host pattern, public key), shown when Some.
|
||||||
new_ca: Option<(String, String)>,
|
new_ca: Option<(String, String)>,
|
||||||
sync_password: String,
|
sync_password: String,
|
||||||
|
creds: Option<Credentials>,
|
||||||
|
creds_job: Option<Job<Credentials>>,
|
||||||
default_shell: String,
|
default_shell: String,
|
||||||
help_topic: usize,
|
help_topic: usize,
|
||||||
master: Option<MasterPrompt>,
|
master: Option<MasterPrompt>,
|
||||||
@@ -117,6 +124,7 @@ impl Settings {
|
|||||||
pub fn open(&mut self, tab: SettingsTab) {
|
pub fn open(&mut self, tab: SettingsTab) {
|
||||||
self.open = true;
|
self.open = true;
|
||||||
self.fresh = true;
|
self.fresh = true;
|
||||||
|
self.creds = None;
|
||||||
self.tab = tab;
|
self.tab = tab;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +163,7 @@ impl Settings {
|
|||||||
SettingsTab::Terminal => self.terminal(ui),
|
SettingsTab::Terminal => self.terminal(ui),
|
||||||
SettingsTab::Security => self.security(ui, s),
|
SettingsTab::Security => self.security(ui, s),
|
||||||
SettingsTab::KnownHosts => self.known_hosts(ui, s),
|
SettingsTab::KnownHosts => self.known_hosts(ui, s),
|
||||||
|
SettingsTab::Diagnostics => self.diagnostics(ui, s),
|
||||||
SettingsTab::Help => self.help(ui),
|
SettingsTab::Help => self.help(ui),
|
||||||
SettingsTab::About => self.about(ui, s),
|
SettingsTab::About => self.about(ui, s),
|
||||||
}
|
}
|
||||||
@@ -210,6 +219,7 @@ impl Settings {
|
|||||||
(SettingsTab::Terminal, icon::TERMINAL_WINDOW, "Terminal"),
|
(SettingsTab::Terminal, icon::TERMINAL_WINDOW, "Terminal"),
|
||||||
(SettingsTab::Security, icon::SHIELD_CHECK, "Security"),
|
(SettingsTab::Security, icon::SHIELD_CHECK, "Security"),
|
||||||
(SettingsTab::KnownHosts, icon::FINGERPRINT, "Known Hosts"),
|
(SettingsTab::KnownHosts, icon::FINGERPRINT, "Known Hosts"),
|
||||||
|
(SettingsTab::Diagnostics, icon::STETHOSCOPE, "Diagnostics"),
|
||||||
(SettingsTab::Help, icon::BOOK_OPEN, "Help"),
|
(SettingsTab::Help, icon::BOOK_OPEN, "Help"),
|
||||||
(SettingsTab::About, icon::INFO, "About"),
|
(SettingsTab::About, icon::INFO, "About"),
|
||||||
] {
|
] {
|
||||||
@@ -371,20 +381,71 @@ impl Settings {
|
|||||||
ui.label("Working…");
|
ui.label("Working…");
|
||||||
} else if !init {
|
} else if !init {
|
||||||
if widgets::primary(ui, icon::SHIELD_PLUS, "Initialise Secret Store").clicked() {
|
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 {
|
} else if !unlocked {
|
||||||
if widgets::primary(ui, icon::LOCK_OPEN, "Unlock").clicked() {
|
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() {
|
} else {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if widgets::button(ui, icon::LOCK, "Lock").clicked() {
|
||||||
s.svc.secrets.vault.lock();
|
s.svc.secrets.vault.lock();
|
||||||
s.info("Secret store locked");
|
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);
|
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) {
|
fn sync_section(&mut self, ui: &mut Ui, s: &mut Shared) {
|
||||||
ui.add_space(12.0);
|
ui.add_space(12.0);
|
||||||
widgets::section(ui, "Profile sync");
|
widgets::section(ui, "Profile sync");
|
||||||
@@ -453,17 +514,29 @@ impl Settings {
|
|||||||
let mut cancel = false;
|
let mut cancel = false;
|
||||||
let r = egui::Modal::new(egui::Id::new("master-password")).show(ctx, |ui| {
|
let r = egui::Modal::new(egui::Id::new("master-password")).show(ctx, |ui| {
|
||||||
ui.set_width(380.0);
|
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(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."
|
"Choose a strong master password. This protects all saved credentials."
|
||||||
} else {
|
} else {
|
||||||
"Enter your master password to unlock the secret store."
|
"Enter your master password to unlock the secret store."
|
||||||
});
|
});
|
||||||
ui.add_space(4.0);
|
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));
|
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();
|
r.request_focus();
|
||||||
}
|
}
|
||||||
let mut enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
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.add_space(6.0);
|
||||||
ui.horizontal(|ui| {
|
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();
|
cancel = ui.button("Cancel").clicked();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -488,11 +568,13 @@ impl Settings {
|
|||||||
} else if m.new && m.pw != m.confirm {
|
} else if m.new && m.pw != m.confirm {
|
||||||
m.error = "Passwords do not match.".into();
|
m.error = "Passwords do not match.".into();
|
||||||
} else {
|
} 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();
|
let svc = s.svc.clone();
|
||||||
self.vault_job = Some(Job::spawn(ctx, move || {
|
self.vault_job = Some(Job::spawn(ctx, move || {
|
||||||
let v = &svc.secrets.vault;
|
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}"))
|
v.initialise(&pw).map(|_| "Secret store created").map_err(|e| format!("Failed to initialise: {e}"))
|
||||||
} else {
|
} else {
|
||||||
v.unlock(&pw).map(|_| "Secret store unlocked").map_err(|e| format!("Unlock failed: {e}"))
|
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 -----------------------------------------------------------------------------
|
// --- Help -----------------------------------------------------------------------------
|
||||||
|
|
||||||
fn help(&mut self, ui: &mut Ui) {
|
fn help(&mut self, ui: &mut Ui) {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ pub enum SettingsTab {
|
|||||||
Terminal,
|
Terminal,
|
||||||
Security,
|
Security,
|
||||||
KnownHosts,
|
KnownHosts,
|
||||||
|
Diagnostics,
|
||||||
Help,
|
Help,
|
||||||
About,
|
About,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,6 +203,34 @@ impl Vault {
|
|||||||
*self.state.lock() = None;
|
*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<()> {
|
fn save(&self, u: &Unlocked) -> Result<()> {
|
||||||
let data = serde_json::to_vec_pretty(&u.vault)?;
|
let data = serde_json::to_vec_pretty(&u.vault)?;
|
||||||
write_private(&self.path(), &data)
|
write_private(&self.path(), &data)
|
||||||
@@ -352,6 +380,25 @@ impl Secrets {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Removes every credential saved for a profile from both stores.
|
/// 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) {
|
pub fn delete_profile(&self, profile_id: &str) {
|
||||||
if self.keychain_ok {
|
if self.keychain_ok {
|
||||||
for kind in SecretKind::ALL {
|
for kind in SecretKind::ALL {
|
||||||
@@ -399,6 +446,24 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(dir).unwrap();
|
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]
|
#[test]
|
||||||
fn private_writes_are_atomic_and_owner_only() {
|
fn private_writes_are_atomic_and_owner_only() {
|
||||||
let dir = std::env::temp_dir().join(format!("nexus-wp-{}", std::process::id()));
|
let dir = std::env::temp_dir().join(format!("nexus-wp-{}", std::process::id()));
|
||||||
|
|||||||
@@ -122,6 +122,23 @@ impl Service {
|
|||||||
self.secrets.set_checked(SYNC_PASSWORD, password)
|
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 {
|
pub fn has_sync_password(&self) -> bool {
|
||||||
self.secrets.get(SYNC_PASSWORD).is_some()
|
self.secrets.get(SYNC_PASSWORD).is_some()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user