fix: purge keychain credentials on profile delete, atomic owner-only secret writes, zeroize derived keys

- Secrets::delete_profile removes every profile credential kind from the OS
  keychain as well as the vault (previously only the vault was purged, so
  keychain passwords/tokens outlived the profile).
- write_private writes to a sibling temp file and renames it into place, so a
  crash mid-write cannot truncate the vault or an export, and a pre-existing
  world-readable file is replaced rather than reused.
- Argon2-derived vault/export keys are Zeroizing and wiped on drop.
- Recordings (which capture typed input), JSON exports and --data-dir
  directories are created owner-only.
This commit is contained in:
Andy Dixon
2026-10-12 07:20:11 +01:00
parent 8766846a3b
commit 9cf221779b
8 changed files with 102 additions and 23 deletions
+1
View File
@@ -2843,6 +2843,7 @@ dependencies = [
"ureq", "ureq",
"uuid", "uuid",
"webpki-roots", "webpki-roots",
"zeroize",
] ]
[[package]] [[package]]
+1 -2
View File
@@ -82,8 +82,7 @@ fn main() -> eframe::Result {
let config = match data_dir { let config = match data_dir {
Some(d) => { Some(d) => {
let c = AppConfig::in_dir(d.into()); let c = AppConfig::in_dir(d.into());
std::fs::create_dir_all(&c.vault_dir).ok(); nexus_core::config::create_private_dir(&c.data_dir).and_then(|_| nexus_core::config::create_private_dir(&c.vault_dir)).map(|_| c)
Ok(c)
} }
None => AppConfig::load(), None => AppConfig::load(),
}; };
+6 -1
View File
@@ -210,7 +210,12 @@ impl Transfer {
.add_filter("JSON", &["json"]) .add_filter("JSON", &["json"])
.save_file() .save_file()
{ {
match s.svc.export_json(*include_secrets).map_err(|e| e.to_string()).and_then(|j| std::fs::write(&path, j).map_err(|e| e.to_string())) { match s
.svc
.export_json(*include_secrets)
.map_err(|e| e.to_string())
.and_then(|j| nexus_core::secrets::write_private(&path, j.as_bytes()).map_err(|e| e.to_string()))
{
Ok(()) => { Ok(()) => {
s.success(format!("Exported {} profile(s) to {}", s.profiles.len(), path.display())); s.success(format!("Exported {} profile(s) to {}", s.profiles.len(), path.display()));
close = true; close = true;
+1
View File
@@ -21,6 +21,7 @@ tokio = { version = "1.53.2", features = ["full"] }
sha2 = "0.11.0" sha2 = "0.11.0"
hmac = "0.13.0" hmac = "0.13.0"
sha1 = "0.11.0" sha1 = "0.11.0"
zeroize = "1"
portable-pty = "0.9.0" portable-pty = "0.9.0"
serialport = { version = "4", default-features = false } serialport = { version = "4", default-features = false }
des = "0.9" des = "0.9"
+2 -1
View File
@@ -51,7 +51,8 @@ impl AppConfig {
} }
} }
fn create_private_dir(d: &Path) -> std::io::Result<()> { /// Creates `d` (and parents) readable only by the owner.
pub fn create_private_dir(d: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(d)?; std::fs::create_dir_all(d)?;
#[cfg(unix)] #[cfg(unix)]
std::fs::set_permissions(d, std::os::unix::fs::PermissionsExt::from_mode(0o700))?; std::fs::set_permissions(d, std::os::unix::fs::PermissionsExt::from_mode(0o700))?;
+2 -2
View File
@@ -41,7 +41,7 @@ pub fn encrypt_and_export(profiles: &[Profile], password: &str, path: &Path) ->
let salt = random_bytes::<32>(); let salt = random_bytes::<32>();
let nonce = random_bytes::<12>(); let nonce = random_bytes::<12>();
let key = derive_key(password, &salt)?; let key = derive_key(password, &salt)?;
let ct = Aes256Gcm::new((&key).into()).encrypt(Nonce::from_slice(&nonce), plain.as_slice()).map_err(|e| e.to_string())?; let ct = Aes256Gcm::new((&*key).into()).encrypt(Nonce::from_slice(&nonce), plain.as_slice()).map_err(|e| e.to_string())?;
let mut out = vec![VERSION]; let mut out = vec![VERSION];
out.extend(salt); out.extend(salt);
out.extend(nonce); out.extend(nonce);
@@ -64,7 +64,7 @@ pub fn decrypt_and_import(path: &Path, password: &str) -> Result<Vec<Profile>> {
return Err(format!("exportimport: unsupported format version {}", data[0]).into()); return Err(format!("exportimport: unsupported format version {}", data[0]).into());
} }
let key = derive_key(password, &data[1..33])?; let key = derive_key(password, &data[1..33])?;
let plain = Aes256Gcm::new((&key).into()) let plain = Aes256Gcm::new((&*key).into())
.decrypt(Nonce::from_slice(&data[33..45]), &data[45..]) .decrypt(Nonce::from_slice(&data[33..45]), &data[45..])
.map_err(|_| "exportimport: decryption failed — wrong password or corrupt file")?; .map_err(|_| "exportimport: decryption failed — wrong password or corrupt file")?;
let bundle: Bundle = serde_json::from_slice(&plain).map_err(|e| format!("exportimport: invalid bundle JSON: {e}"))?; let bundle: Bundle = serde_json::from_slice(&plain).map_err(|e| format!("exportimport: invalid bundle JSON: {e}"))?;
+11 -1
View File
@@ -116,7 +116,12 @@ impl Recorder {
fn with_limit(dir: &Path, id: &str, title: &str, cols: i64, rows: i64, max_bytes: u64) -> Result<Recorder> { fn with_limit(dir: &Path, id: &str, title: &str, cols: i64, rows: i64, max_bytes: u64) -> Result<Recorder> {
mkdir_private(dir).map_err(|e| format!("recording: failed to create directory: {e}"))?; mkdir_private(dir).map_err(|e| format!("recording: failed to create directory: {e}"))?;
let path = dir.join(format!("{id}.cast")); let path = dir.join(format!("{id}.cast"));
let mut f = File::create(&path).map_err(|e| format!("recording: failed to create file: {e}"))?; // Recordings capture typed input (passwords included): owner-only, like the vault.
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
let mut f = opts.open(&path).map_err(|e| format!("recording: failed to create file: {e}"))?;
let start = Instant::now(); let start = Instant::now();
let hdr = Header { let hdr = Header {
version: 2, version: 2,
@@ -391,6 +396,11 @@ mod tests {
assert_eq!(rec.event_count(), 4); assert_eq!(rec.event_count(), 4);
rec.close().unwrap(); rec.close().unwrap();
rec.close().unwrap(); // double close is a no-op rec.close().unwrap(); // double close is a no-op
#[cfg(unix)]
{
let mode = std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(dir.join("test-session-001.cast")).unwrap().permissions());
assert_eq!(mode & 0o777, 0o600);
}
let text = std::fs::read_to_string(dir.join("test-session-001.cast")).unwrap(); let text = std::fs::read_to_string(dir.join("test-session-001.cast")).unwrap();
let lines: Vec<&str> = text.trim().lines().collect(); let lines: Vec<&str> = text.trim().lines().collect();
assert_eq!(lines.len(), 5); assert_eq!(lines.len(), 5);
+78 -16
View File
@@ -11,16 +11,32 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Mutex; use std::sync::Mutex;
use zeroize::Zeroizing;
const VAULT_FILE: &str = "vault.json"; const VAULT_FILE: &str = "vault.json";
const MAX_VAULT_BYTES: u64 = 10 * 1024 * 1024; const MAX_VAULT_BYTES: u64 = 10 * 1024 * 1024;
/// Argon2id with the Go parameters: t=3, m=128 MiB, p=4, 32-byte key. /// Every credential kind the session manager stores under `profile:<id>:<kind>`.
pub fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; 32]> { 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",
];
/// 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]>> {
let params = argon2::Params::new(128 * 1024, 3, 4, Some(32)).map_err(|e| e.to_string())?; let params = argon2::Params::new(128 * 1024, 3, 4, Some(32)).map_err(|e| e.to_string())?;
let mut key = [0u8; 32]; let mut key = Zeroizing::new([0u8; 32]);
argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params) argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params)
.hash_password_into(password.as_bytes(), salt, &mut key) .hash_password_into(password.as_bytes(), salt, &mut *key)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Ok(key) Ok(key)
} }
@@ -54,16 +70,10 @@ struct VaultFile {
} }
struct Unlocked { struct Unlocked {
key: [u8; 32], key: Zeroizing<[u8; 32]>,
vault: VaultFile, vault: VaultFile,
} }
impl Drop for Unlocked {
fn drop(&mut self) {
self.key.fill(0);
}
}
pub struct Vault { pub struct Vault {
dir: PathBuf, dir: PathBuf,
state: Mutex<Option<Unlocked>>, state: Mutex<Option<Unlocked>>,
@@ -155,17 +165,27 @@ impl Vault {
} }
} }
/// Writes a file readable only by the owner. /// Writes a file readable only by the owner, atomically: the data goes to a sibling temp file
/// that replaces `path` only once it is fully written and synced, so a crash can't leave a
/// truncated vault or export behind (and an existing file's wider mode is never kept).
pub fn write_private(path: &std::path::Path, data: &[u8]) -> Result<()> { pub fn write_private(path: &std::path::Path, data: &[u8]) -> Result<()> {
use std::io::Write; use std::io::Write;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let tmp = path.with_file_name(format!(".{name}.tmp-{}", std::process::id()));
let mut opts = std::fs::OpenOptions::new(); let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true); opts.write(true).create(true).truncate(true);
#[cfg(unix)] #[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600); std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
let mut f = opts.open(path)?; let written = (|| {
f.write_all(data)?; let mut f = opts.open(&tmp)?;
f.sync_all()?; f.write_all(data)?;
Ok(()) f.sync_all()?;
std::fs::rename(&tmp, path)
})();
if written.is_err() {
let _ = std::fs::remove_file(&tmp);
}
Ok(written?)
} }
pub const KEYCHAIN_SERVICE: &str = "NexusTerminal"; pub const KEYCHAIN_SERVICE: &str = "NexusTerminal";
@@ -270,7 +290,13 @@ impl Secrets {
let _ = self.vault.delete(key); let _ = self.vault.delete(key);
} }
/// Removes every credential saved for a profile from both stores.
pub fn delete_profile(&self, profile_id: &str) { 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}"));
}
}
let _ = self.vault.delete_prefix(&format!("profile:{profile_id}:")); let _ = self.vault.delete_prefix(&format!("profile:{profile_id}:"));
} }
} }
@@ -303,4 +329,40 @@ mod tests {
assert_eq!(v.get("a").unwrap(), "1"); assert_eq!(v.get("a").unwrap(), "1");
std::fs::remove_dir_all(dir).unwrap(); 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()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("secret.bin");
// A pre-existing world-readable file is replaced, not reused.
std::fs::write(&path, b"old").unwrap();
#[cfg(unix)]
std::fs::set_permissions(&path, std::os::unix::fs::PermissionsExt::from_mode(0o644)).unwrap();
write_private(&path, b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
#[cfg(unix)]
assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&path).unwrap().permissions()) & 0o777, 0o600);
// No temp file is left behind, on success or when the target directory is unwritable.
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
assert!(write_private(&dir.join("missing").join("x"), b"x").is_err());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn deleting_a_profile_clears_every_vault_kind() {
let dir = std::env::temp_dir().join(format!("nexus-delprof-{}", std::process::id()));
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();
}
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_eq!(s.get("profile:p2:password").as_deref(), Some("keep"));
std::fs::remove_dir_all(dir).unwrap();
}
} }