SshConfig no longer has proxyPassword or totpSecret fields, so neither can sit in clear in nexus.db or travel in an export. The proxy password is a SecretKind (profile:<id>:proxy-password) edited like other saved credentials and purged with the profile; the session manager hands both it and the TOTP seed to the SSH client from the keychain/vault at connect time. This drops compatibility with Go-era profiles and .link/.nexus files that carry these values inline: they are ignored on read and must be re-entered (SEC-20261012-011).
454 lines
17 KiB
Rust
454 lines
17 KiB
Rust
//! 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,
|
|
}
|
|
|
|
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; 12] = [
|
|
Self::Password,
|
|
Self::Passphrase,
|
|
Self::Totp,
|
|
Self::GithubToken,
|
|
Self::VncPassword,
|
|
Self::VncSshPassword,
|
|
Self::VncSshPassphrase,
|
|
Self::IrcServerPassword,
|
|
Self::IrcNickServPassword,
|
|
Self::IrcSshPassword,
|
|
Self::IrcSshPassphrase,
|
|
Self::ProxyPassword,
|
|
];
|
|
|
|
/// 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",
|
|
}
|
|
}
|
|
|
|
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. Absent in vaults written by Go or older builds; added on the next write.
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
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 };
|
|
// The verifier must open; without one (older vaults) one entry must decrypt, and an empty
|
|
// vault accepts any password, as in Go.
|
|
let check = if u.vault.verifier.is_empty() { u.vault.secrets.values().next() } else { Some(&u.vault.verifier) };
|
|
if let Some(v) = check {
|
|
let data = B64.decode(v).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;
|
|
}
|
|
|
|
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<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}"))?;
|
|
Ok(String::from_utf8_lossy(&open(&u.key, &data)?).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);
|
|
if u.vault.verifier.is_empty() {
|
|
u.vault.verifier = verifier(&u.key);
|
|
}
|
|
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).
|
|
pub fn write_private(path: &std::path::Path, data: &[u8]) -> Result<()> {
|
|
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();
|
|
opts.write(true).create(true).truncate(true);
|
|
#[cfg(unix)]
|
|
std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
|
|
let written = (|| {
|
|
let mut f = opts.open(&tmp)?;
|
|
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";
|
|
/// The service name Alchementary Link wrote under; read as a fallback (§7 of the port plan).
|
|
pub const LEGACY_KEYCHAIN_SERVICE: &str = "AlchementaryLink";
|
|
|
|
/// 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> {
|
|
// go-keyring's Windows target is "service:user"; keyring's default differs.
|
|
if cfg!(windows) { keyring::Entry::new_with_target(&format!("{service}:{key}"), service, key) } else { 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,
|
|
legacy: Option<String>,
|
|
}
|
|
|
|
impl Keychain {
|
|
pub fn new(service: &str, legacy: Option<&str>) -> Keychain {
|
|
Keychain { service: service.into(), legacy: legacy.map(Into::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)))
|
|
}
|
|
|
|
/// Reads a secret; a miss under the current service falls back to the legacy one and copies
|
|
/// the value forward. Legacy items are never deleted, so the old app keeps working.
|
|
pub fn get(&self, key: &str) -> Option<String> {
|
|
let (service, key2) = (self.service.clone(), key.to_string());
|
|
if let Ok(v) = off_runtime(move || entry(&service, &key2).and_then(|e| e.get_password())) {
|
|
return Some(v);
|
|
}
|
|
let (legacy, key2) = (self.legacy.clone()?, key.to_string());
|
|
let v = off_runtime(move || entry(&legacy, &key2).and_then(|e| e.get_password())).ok()?;
|
|
let _ = self.set(key, &v);
|
|
Some(v)
|
|
}
|
|
|
|
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<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.
|
|
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 reads_go_vault_fixture() {
|
|
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/vault");
|
|
let v = Vault::new(dir);
|
|
assert!(v.is_initialised());
|
|
assert!(v.unlock("wrong").is_err());
|
|
v.unlock("correct horse battery staple").unwrap();
|
|
assert_eq!(v.get("profile:00000000-0000-0000-0000-000000000001:password").unwrap(), "s3cret-ssh");
|
|
assert_eq!(v.get("profile:00000000-0000-0000-0000-000000000001:totp").unwrap(), "JBSWY3DPEHPK3PXP");
|
|
}
|
|
|
|
#[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_old_vaults_gain_a_verifier() {
|
|
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();
|
|
// A vault from Go/older builds (no verifier) keeps working and gains one on write.
|
|
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();
|
|
v.lock();
|
|
v.unlock("anything").unwrap();
|
|
v.lock();
|
|
v.unlock("pw").unwrap();
|
|
v.set("a", "1").unwrap();
|
|
v.lock();
|
|
assert!(std::fs::read_to_string(&path).unwrap().contains("verifier"));
|
|
assert!(v.unlock("typo").is_err());
|
|
v.unlock("pw").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 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(), Some("keep"));
|
|
std::fs::remove_dir_all(dir).unwrap();
|
|
}
|
|
}
|