Files
nexus/rust/core/src/secrets.rs
T
Andy Dixon 9805681c2c license: release Nexus Terminal under the GNU AGPL v3
- LICENSE: the GNU Affero General Public License, version 3 (verbatim
  from gnu.org).
- SPDX-License-Identifier: AGPL-3.0-only plus a copyright line on every
  first-party source file (Rust, shell, Python, PHP); vendored crates keep
  their own licenses.
- Cargo manifests carry license/authors/repository; the macOS bundle
  copyright and the About page state the license and link the source.
- Release zips (and the macOS app bundle) include LICENSE.
- README: License section.
2026-10-13 09:46:31 +01:00

539 lines
21 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Andy Dixon
//! Credential storage: the OS keychain first, then an Argon2id + AES-256-GCM vault file that is
//! byte-compatible with the Go app's `vault/vault.json`.
use crate::Result;
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
use parking_lot::Mutex;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
use zeroize::Zeroizing;
const VAULT_FILE: &str = "vault.json";
const MAX_VAULT_BYTES: u64 = 10 * 1024 * 1024;
/// A credential stored per profile under `profile:<id>:<kind>`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum SecretKind {
Password,
Passphrase,
Totp,
GithubToken,
VncPassword,
VncSshPassword,
VncSshPassphrase,
IrcServerPassword,
IrcNickServPassword,
IrcSshPassword,
IrcSshPassphrase,
ProxyPassword,
RdpPassword,
RdpSshPassword,
RdpSshPassphrase,
RdpGatewayPassword,
IrcSaslPassword,
}
impl SecretKind {
/// Every kind, so deleting a profile can purge them all from the keychain. A new variant
/// must be added here as well as to `as_str`.
pub const ALL: [SecretKind; 17] = [
Self::Password,
Self::Passphrase,
Self::Totp,
Self::GithubToken,
Self::VncPassword,
Self::VncSshPassword,
Self::VncSshPassphrase,
Self::IrcServerPassword,
Self::IrcNickServPassword,
Self::IrcSshPassword,
Self::IrcSshPassphrase,
Self::ProxyPassword,
Self::RdpPassword,
Self::RdpSshPassword,
Self::RdpSshPassphrase,
Self::RdpGatewayPassword,
Self::IrcSaslPassword,
];
/// The storage name, shared with the Go app.
pub fn as_str(self) -> &'static str {
match self {
Self::Password => "password",
Self::Passphrase => "passphrase",
Self::Totp => "totp",
Self::GithubToken => "github-token",
Self::VncPassword => "vnc-password",
Self::VncSshPassword => "vnc-ssh-password",
Self::VncSshPassphrase => "vnc-ssh-passphrase",
Self::IrcServerPassword => "irc-server-password",
Self::IrcNickServPassword => "irc-nickserv-password",
Self::IrcSshPassword => "irc-ssh-password",
Self::IrcSshPassphrase => "irc-ssh-passphrase",
Self::ProxyPassword => "proxy-password",
Self::RdpPassword => "rdp-password",
Self::RdpSshPassword => "rdp-ssh-password",
Self::RdpSshPassphrase => "rdp-ssh-passphrase",
Self::RdpGatewayPassword => "rdp-gateway-password",
Self::IrcSaslPassword => "irc-sasl-password",
}
}
pub fn key(self, profile_id: &str) -> String {
format!("profile:{profile_id}:{}", self.as_str())
}
}
/// 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 mut key = Zeroizing::new([0u8; 32]);
argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params)
.hash_password_into(password.as_bytes(), salt, &mut *key)
.map_err(|e| e.to_string())?;
Ok(key)
}
pub fn random_bytes<const N: usize>() -> [u8; N] {
let mut b = [0u8; N];
rand::thread_rng().fill_bytes(&mut b);
b
}
/// nonce(12) ‖ ciphertext ‖ tag
pub fn seal(key: &[u8; 32], plaintext: &[u8]) -> Vec<u8> {
let nonce = random_bytes::<12>();
let mut out = nonce.to_vec();
out.extend(Aes256Gcm::new(key.into()).encrypt(Nonce::from_slice(&nonce), plaintext).expect("aes-gcm encrypt"));
out
}
pub fn open(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
if data.len() < 12 {
return Err("ciphertext too short".into());
}
Aes256Gcm::new(key.into()).decrypt(Nonce::from_slice(&data[..12]), &data[12..]).map_err(|_| "decryption failed".into())
}
#[derive(Serialize, Deserialize)]
struct VaultFile {
version: i64,
salt: String,
secrets: BTreeMap<String, String>,
/// A known value sealed under the key, so even an empty vault can tell a wrong master
/// password.
#[serde(default)]
verifier: String,
}
const VERIFIER: &[u8] = b"nexus-vault-verifier";
fn verifier(key: &[u8; 32]) -> String {
B64.encode(seal(key, VERIFIER))
}
struct Unlocked {
key: Zeroizing<[u8; 32]>,
vault: VaultFile,
}
pub struct Vault {
dir: PathBuf,
state: Mutex<Option<Unlocked>>,
}
impl Vault {
pub fn new(dir: PathBuf) -> Vault {
Vault { dir, state: Mutex::new(None) }
}
fn path(&self) -> PathBuf {
self.dir.join(VAULT_FILE)
}
pub fn is_initialised(&self) -> bool {
self.path().exists()
}
pub fn is_unlocked(&self) -> bool {
self.state.lock().is_some()
}
pub fn initialise(&self, password: &str) -> Result<()> {
if self.is_initialised() {
return Err("secrets: vault already initialised".into());
}
let salt = random_bytes::<32>();
let key = derive_key(password, &salt)?;
let vault = VaultFile { version: 1, salt: B64.encode(salt), secrets: BTreeMap::new(), verifier: verifier(&key) };
let u = Unlocked { key, vault };
std::fs::create_dir_all(&self.dir)?;
self.save(&u)?;
*self.state.lock() = Some(u);
Ok(())
}
pub fn unlock(&self, password: &str) -> Result<()> {
let meta = std::fs::metadata(self.path()).map_err(|e| format!("secrets: failed to load vault: {e}"))?;
if meta.len() > MAX_VAULT_BYTES {
return Err("secrets: vault file is too large".into());
}
let vault: VaultFile = serde_json::from_slice(&std::fs::read(self.path())?)?;
let salt = B64.decode(&vault.salt).map_err(|e| format!("secrets: corrupt vault salt: {e}"))?;
let u = Unlocked { key: derive_key(password, &salt)?, vault };
if u.vault.verifier.is_empty() {
return Err(format!(
"secrets: {} was made by an older version and can't be verified; remove it to create a new secret store",
self.path().display()
)
.into());
}
let data = B64.decode(&u.vault.verifier).unwrap_or_default();
open(&u.key, &data).map_err(|_| "secrets: incorrect master password or corrupt vault")?;
*self.state.lock() = Some(u);
Ok(())
}
pub fn lock(&self) {
*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)
}
pub fn get(&self, key: &str) -> Result<Zeroizing<String>> {
let st = self.state.lock();
let u = st.as_ref().ok_or("secrets: vault is locked")?;
let enc = u.vault.secrets.get(key).ok_or_else(|| format!("secrets: key {key:?} not found"))?;
let data = B64.decode(enc).map_err(|e| format!("failed to decode secret: {e}"))?;
let plain = Zeroizing::new(open(&u.key, &data)?);
Ok(Zeroizing::new(String::from_utf8_lossy(&plain).into_owned()))
}
pub fn set(&self, key: &str, value: &str) -> Result<()> {
let mut st = self.state.lock();
let u = st.as_mut().ok_or("secrets: vault is locked")?;
let enc = B64.encode(seal(&u.key, value.as_bytes()));
u.vault.secrets.insert(key.to_string(), enc);
self.save(u)
}
pub fn delete_prefix(&self, prefix: &str) -> Result<()> {
let mut st = self.state.lock();
let u = st.as_mut().ok_or("secrets: vault is locked")?;
u.vault.secrets.retain(|k, _| !k.starts_with(prefix));
self.save(u)
}
pub fn delete(&self, key: &str) -> Result<()> {
let mut st = self.state.lock();
let u = st.as_mut().ok_or("secrets: vault is locked")?;
u.vault.secrets.remove(key);
self.save(u)
}
}
/// 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). The temp
/// file is created exclusively under an unguessable name, so nothing pre-placed in the folder (a
/// symlink in a shared sync folder, say) can be followed or overwritten.
pub fn write_private(path: &std::path::Path, data: &[u8]) -> Result<()> {
use std::io::Write;
let name = path.file_name().ok_or("write_private: path has no file name")?.to_string_lossy().into_owned();
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
let mut tmp = path.to_path_buf();
let written = (|| {
let mut f = loop {
let r = random_bytes::<8>();
tmp = path.with_file_name(format!(".{name}.tmp-{}", r.iter().map(|b| format!("{b:02x}")).collect::<String>()));
match opts.open(&tmp) {
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
r => break r?,
}
};
f.write_all(data)?;
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";
/// Runs a keychain call on a plain thread: the D-Bus backend may start its own async runtime,
/// which panics if the caller is already inside tokio (as SSH authentication is).
fn off_runtime<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
std::thread::spawn(f).join().expect("keychain thread panicked")
}
fn entry(service: &str, key: &str) -> keyring::Result<keyring::Entry> {
keyring::Entry::new(service, key)
}
/// The OS keychain. Every call can fail (no secret service, locked keyring); callers fall back
/// to the vault.
pub struct Keychain {
service: String,
}
impl Keychain {
pub fn new(service: &str) -> Keychain {
Keychain { service: service.into() }
}
/// Probes with a read of an entry that doesn't exist: "no entry" proves the keychain answers,
/// without writing an item at every launch (which wakes D-Bus and can prompt on macOS). If
/// writes later fail anyway, `Secrets::set_checked` falls back to the vault.
pub fn available(&self) -> bool {
let service = self.service.clone();
off_runtime(move || matches!(entry(&service, "__nexus_keychain_probe__").and_then(|e| e.get_password()), Ok(_) | Err(keyring::Error::NoEntry)))
}
pub fn get(&self, key: &str) -> Option<Zeroizing<String>> {
let (service, key) = (self.service.clone(), key.to_string());
off_runtime(move || entry(&service, &key).and_then(|e| e.get_password())).ok().map(Zeroizing::new)
}
pub fn set(&self, key: &str, value: &str) -> Result<()> {
let (service, key2, value) = (self.service.clone(), key.to_string(), value.to_string());
off_runtime(move || entry(&service, &key2).and_then(|e| e.set_password(&value)))
.map_err(|e| format!("keychain: failed to store secret {key:?}: {e}").into())
}
pub fn delete(&self, key: &str) {
let (service, key) = (self.service.clone(), key.to_string());
let _ = off_runtime(move || entry(&service, &key).and_then(|e| e.delete_credential()));
}
}
/// Keychain first, vault second — how every credential lookup in the app works.
pub struct Secrets {
pub vault: Vault,
pub keychain: Keychain,
/// Probed once at startup (the probe writes an item, so don't repeat it per lookup).
pub keychain_ok: bool,
}
impl Secrets {
pub fn new(vault: Vault, keychain: Keychain) -> Secrets {
let keychain_ok = keychain.available();
Secrets { vault, keychain, keychain_ok }
}
pub fn get(&self, key: &str) -> Option<Zeroizing<String>> {
if self.keychain_ok
&& let Some(v) = self.keychain.get(key).filter(|v| !v.is_empty())
{
return Some(v);
}
self.vault.get(key).ok().filter(|v| !v.is_empty())
}
/// Stores where possible: the keychain, else the vault if it's unlocked.
pub fn set_checked(&self, key: &str, value: &str) -> Result<()> {
if self.keychain_ok && self.keychain.set(key, value).is_ok() {
return Ok(());
}
if !self.vault.is_unlocked() {
return Err("secret store is locked; unlock it first".into());
}
self.vault.set(key, value)
}
pub fn delete(&self, key: &str) {
if self.keychain_ok {
self.keychain.delete(key);
}
let _ = self.vault.delete(key);
}
/// 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 {
self.keychain.delete(&kind.key(profile_id));
}
}
let _ = self.vault.delete_prefix(&format!("profile:{profile_id}:"));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vault_round_trip() {
let dir = std::env::temp_dir().join(format!("nexus-vault-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let v = Vault::new(dir.clone());
v.initialise("pw").unwrap();
v.set("a", "1").unwrap();
v.lock();
assert!(v.get("a").is_err());
v.unlock("pw").unwrap();
assert_eq!(*v.get("a").unwrap(), "1");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn empty_vault_rejects_a_wrong_password_and_unverifiable_vaults_are_refused() {
let dir = std::env::temp_dir().join(format!("nexus-vault-verifier-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let v = Vault::new(dir.clone());
v.initialise("pw").unwrap();
v.lock();
assert!(v.unlock("typo").is_err(), "an empty vault must not accept any password");
v.unlock("pw").unwrap();
v.lock();
// A vault without a verifier (older format) is refused rather than trusted.
let path = dir.join(VAULT_FILE);
let mut f: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
f.as_object_mut().unwrap().remove("verifier");
std::fs::write(&path, f.to_string()).unwrap();
assert!(v.unlock("pw").unwrap_err().to_string().contains("older version"));
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().as_str(), v.get("profile:p:password").unwrap().as_str()), ("1", "hunter2"));
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()));
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"), keychain_ok: false };
s.vault.initialise("pw").unwrap();
for kind in SecretKind::ALL {
s.vault.set(&kind.key("p1"), "v").unwrap();
}
s.vault.set("profile:p2:password", "keep").unwrap();
s.delete_profile("p1");
assert!(SecretKind::ALL.iter().all(|k| s.get(&k.key("p1")).is_none()));
assert_eq!(s.get("profile:p2:password").as_deref().map(String::as_str), Some("keep"));
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn write_private_never_follows_links_or_leaves_temp_files() {
use std::os::unix::fs::PermissionsExt;
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 canary = dir.join("canary");
std::fs::write(&canary, b"untouched").unwrap();
// A pre-placed symlink at the destination is replaced, not followed.
let target = dir.join("out");
std::os::unix::fs::symlink(&canary, &target).unwrap();
write_private(&target, b"secret").unwrap();
assert_eq!(std::fs::read(&canary).unwrap(), b"untouched");
assert!(std::fs::symlink_metadata(&target).unwrap().file_type().is_file());
assert_eq!(std::fs::read(&target).unwrap(), b"secret");
assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600);
// No temp file is left behind, and the temp name isn't the old predictable one.
let names: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
assert!(names.iter().all(|n| !n.contains(".tmp-")), "{names:?}");
std::fs::remove_dir_all(dir).unwrap();
}
}