/// AES-256-GCM encrypted identity vault with Argon2id key derivation. use aes_gcm::{aead::{Aead, KeyInit}, Aes256Gcm, Nonce}; use argon2::{password_hash::SaltString, Argon2, Params, Version}; use rand::rngs::OsRng; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use zeroize::Zeroize; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Identity { pub name: String, pub protocol: String, pub credentials: String, pub created_at: chrono::DateTime, } #[derive(Debug, Serialize, Deserialize)] struct VaultBlob { salt: String, nonce: String, ciphertext: String, version: u32 } #[derive(Debug)] pub struct Vault { identities: Vec, key: [u8; 32], /// Base64 salt used to derive `key`. Must be reused verbatim on every /// flush -- generating a fresh salt per-flush would desync it from the /// key already in memory, making the vault undecryptable even with the /// correct password (the bug this field exists to prevent). salt: String, vault_path: PathBuf, } impl Drop for Vault { fn drop(&mut self) { self.key.zeroize(); } } fn vault_dir() -> PathBuf { dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".nirc") } fn vault_path() -> PathBuf { vault_dir().join("vault.json") } impl Vault { pub fn create(password: &str) -> anyhow::Result { Self::create_at(&vault_path(), password) } pub fn unlock(password: &str) -> anyhow::Result { Self::unlock_at(&vault_path(), password) } /// Create a new vault at an explicit path. `create()` is a thin wrapper /// over this using the default `~/.nirc/vault.json` location; tests use /// this directly with an isolated temp path so parallel test runs don't /// race on the same on-disk file. pub fn create_at(path: &std::path::Path, password: &str) -> anyhow::Result { if path.exists() { std::fs::remove_file(path)?; } if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let salt = SaltString::generate(&mut OsRng); let key = derive_key(password, &salt)?; let vault = Self { identities: Vec::new(), key, salt: salt.to_string(), vault_path: path.to_path_buf() }; vault.flush()?; Ok(vault) } /// Unlock a vault at an explicit path. See `create_at`. pub fn unlock_at(path: &std::path::Path, password: &str) -> anyhow::Result { let raw = std::fs::read_to_string(path)?; let blob: VaultBlob = serde_json::from_str(&raw)?; let salt = SaltString::from_b64(&blob.salt).map_err(|e| anyhow::anyhow!("invalid salt: {e}"))?; let key = derive_key(password, &salt)?; let cipher = Aes256Gcm::new_from_slice(&key) .map_err(|e| anyhow::anyhow!("cipher init: {e}"))?; let nonce_bytes = base64_url_decode(&blob.nonce)?; let nonce = Nonce::from_slice(&nonce_bytes); let ct_bytes = base64_url_decode(&blob.ciphertext)?; let pt = cipher.decrypt(nonce, ct_bytes.as_ref()) .map_err(|_| anyhow::anyhow!("wrong password or corrupted vault"))?; let plaintext = String::from_utf8(pt)?; let identities: Vec = if plaintext.is_empty() { Vec::new() } else { serde_json::from_str(&plaintext)? }; Ok(Self { identities, key, salt: blob.salt, vault_path: path.to_path_buf() }) } fn flush(&self) -> anyhow::Result<()> { let plaintext = serde_json::to_string(&self.identities)?; let cipher = Aes256Gcm::new_from_slice(&self.key) .map_err(|e| anyhow::anyhow!("cipher init: {e}"))?; let nonce_bytes = rand::random::<[u8; 12]>(); let nonce = Nonce::from_slice(&nonce_bytes); let ct = cipher.encrypt(nonce, plaintext.as_bytes()) .map_err(|e| anyhow::anyhow!("encrypt: {e}"))?; let blob = VaultBlob { salt: self.salt.clone(), nonce: base64_url_encode(&nonce_bytes), ciphertext: base64_url_encode(&ct), version: 1 }; let json = serde_json::to_string_pretty(&blob)?; let tmp = self.vault_path.with_extension("json.tmp"); std::fs::write(&tmp, &json)?; std::fs::rename(&tmp, &self.vault_path)?; Ok(()) } pub fn add_identity(&mut self, id: Identity) -> anyhow::Result<()> { self.identities.push(id); self.flush() } pub fn list_id(&self) -> &[Identity] { &self.identities } pub fn remove_identity(&mut self, name: &str) -> anyhow::Result { let before = self.identities.len(); self.identities.retain(|i| i.name != name); if self.identities.len() < before { self.flush()?; Ok(true) } else { Ok(false) } } pub fn lock(self) { drop(self); } } fn derive_key(password: &str, salt: &SaltString) -> anyhow::Result<[u8; 32]> { let params = Params::new(65536, 3, 2, Some(32)) .map_err(|e| anyhow::anyhow!("argon2 params: {e}"))?; let argon2 = Argon2::new(argon2::Algorithm::Argon2id, Version::V0x13, params); let mut key = [0u8; 32]; argon2.hash_password_into(password.as_bytes(), salt.as_ref().as_bytes(), &mut key) .map_err(|e| anyhow::anyhow!("argon2 hash: {e}"))?; let out = key; key.zeroize(); // Wipe the stack copy before returning. Ok(out) } fn base64_url_encode(data: &[u8]) -> String { use base64::Engine; base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data) } fn base64_url_decode(s: &str) -> anyhow::Result> { use base64::Engine; Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(s)?) } #[cfg(test)] mod tests { use super::*; #[test] fn create_and_unlock_roundtrip() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("vault.json"); let mut vault = Vault::create_at(&path, "testpass").unwrap(); vault.add_identity(Identity { name: "libera".into(), protocol: "irc".into(), credentials: "nick=testuser".into(), created_at: chrono::Utc::now() }).unwrap(); drop(vault); let vault2 = Vault::unlock_at(&path, "testpass").unwrap(); assert_eq!(vault2.list_id().len(), 1); assert_eq!(vault2.list_id()[0].name, "libera"); } #[test] fn wrong_password_fails() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("vault.json"); Vault::create_at(&path, "correct").unwrap(); assert!(Vault::unlock_at(&path, "wrong").is_err()); } #[test] fn remove_identity() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("vault.json"); let mut vault = Vault::create_at(&path, "pass").unwrap(); vault.add_identity(Identity { name: "a".into(), protocol: "irc".into(), credentials: "x".into(), created_at: chrono::Utc::now() }).unwrap(); vault.add_identity(Identity { name: "b".into(), protocol: "matrix".into(), credentials: "y".into(), created_at: chrono::Utc::now() }).unwrap(); assert_eq!(vault.list_id().len(), 2); assert!(vault.remove_identity("a").unwrap()); assert_eq!(vault.list_id().len(), 1); assert!(!vault.remove_identity("nonexistent").unwrap()); } }