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
+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()
}