//! Unlocked-vault session: the shape every vault-mutating command works with. //! //! Holds the derived master key in `Zeroizing<[u8; 32]>` for the lifetime of a //! CLI invocation. Drops it (via Zeroize) when the struct goes out of scope. use std::fs; use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use zeroize::Zeroizing; use relicario_core::{ decrypt_item, decrypt_manifest, decrypt_settings, derive_master_key, encrypt_item, encrypt_manifest, encrypt_settings, imgsecret, Item, ItemId, KdfParams, Manifest, VaultSettings, }; use crate::helpers::vault_dir; /// A vault whose master key has been derived and is held in memory. /// The key is wiped via `Zeroize` when this struct drops. pub struct UnlockedVault { root: PathBuf, master_key: Zeroizing<[u8; 32]>, } impl UnlockedVault { pub fn root(&self) -> &Path { &self.root } pub fn key(&self) -> &Zeroizing<[u8; 32]> { &self.master_key } /// Full interactive unlock flow: locate vault, prompt passphrase, locate /// reference image, derive master key. pub fn unlock_interactive() -> Result { let root = vault_dir()?; let salt = read_salt(&root)?; let params = read_params(&root)?; let image_path = get_image_path()?; let image_bytes = fs::read(&image_path) .with_context(|| format!("failed to read reference image {}", image_path.display()))?; let image_secret = Zeroizing::new(imgsecret::extract(&image_bytes)?); let passphrase = if let Some(p) = crate::test_passphrase_override() { Zeroizing::new(p) } else { Zeroizing::new( rpassword::prompt_password("Passphrase: ") .context("failed to read passphrase")? ) }; let master_key = derive_master_key( passphrase.as_bytes(), &image_secret, &salt, ¶ms, )?; Ok(Self { root, master_key }) } pub fn manifest_path(&self) -> PathBuf { self.root.join("manifest.enc") } pub fn settings_path(&self) -> PathBuf { self.root.join("settings.enc") } pub fn item_path(&self, id: &ItemId) -> PathBuf { self.root.join("items").join(format!("{}.enc", id.as_str())) } pub fn load_manifest(&self) -> Result { let bytes = fs::read(self.manifest_path()).context("failed to read manifest.enc")?; Ok(decrypt_manifest(&bytes, &self.master_key)?) } /// Save the manifest and refresh the plaintext groups.cache. This is the /// canonical "I just mutated the manifest" funnel — every command that /// changes the manifest goes through this method, so cache freshness is /// a compile-time invariant rather than a discipline rule. pub fn after_manifest_change(&self, manifest: &Manifest) -> Result<()> { self.save_manifest_raw(manifest)?; crate::helpers::refresh_groups_cache(&self.root, manifest); Ok(()) } /// Encrypt the manifest and atomically write it. Most callers want /// `after_manifest_change` instead — this method skips the groups.cache /// refresh, leaving shell completion stale until the next mutation. pub(crate) fn save_manifest_raw(&self, manifest: &Manifest) -> Result<()> { let bytes = encrypt_manifest(manifest, &self.master_key)?; atomic_write(&self.manifest_path(), &bytes) } pub fn load_settings(&self) -> Result { let bytes = fs::read(self.settings_path()).context("failed to read settings.enc")?; Ok(decrypt_settings(&bytes, &self.master_key)?) } pub fn save_settings(&self, settings: &VaultSettings) -> Result<()> { let bytes = encrypt_settings(settings, &self.master_key)?; atomic_write(&self.settings_path(), &bytes) } pub fn load_item(&self, id: &ItemId) -> Result { let bytes = fs::read(self.item_path(id)) .with_context(|| format!("failed to read item {}", id.as_str()))?; Ok(decrypt_item(&bytes, &self.master_key)?) } pub fn save_item(&self, item: &Item) -> Result<()> { let path = self.item_path(&item.id); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let bytes = encrypt_item(item, &self.master_key)?; atomic_write(&path, &bytes) } } fn read_salt(root: &Path) -> Result<[u8; 32]> { let data = fs::read(root.join(".relicario").join("salt")) .context("failed to read .relicario/salt")?; if data.len() != 32 { bail!("invalid salt length: {}", data.len()); } let mut salt = [0u8; 32]; salt.copy_from_slice(&data); Ok(salt) } #[derive(serde::Serialize, serde::Deserialize)] pub(crate) struct ParamsFile { pub format_version: u32, pub kdf: ParamsKdf, pub aead: String, pub salt_path: String, } #[derive(serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub(crate) struct ParamsKdf { pub algorithm: String, pub argon2_m: u32, pub argon2_t: u32, pub argon2_p: u32, } impl ParamsFile { pub fn for_new_vault(params: &KdfParams) -> Self { Self { format_version: 2, kdf: ParamsKdf { algorithm: "argon2id-v0x13".into(), argon2_m: params.argon2_m, argon2_t: params.argon2_t, argon2_p: params.argon2_p, }, aead: "xchacha20poly1305".into(), salt_path: ".relicario/salt".into(), } } pub fn into_kdf_params(self) -> KdfParams { KdfParams { argon2_m: self.kdf.argon2_m, argon2_t: self.kdf.argon2_t, argon2_p: self.kdf.argon2_p, } } } fn read_params(root: &Path) -> Result { let s = fs::read_to_string(root.join(".relicario").join("params.json")) .context("failed to read .relicario/params.json")?; let pf: ParamsFile = serde_json::from_str(&s).context("failed to parse params.json")?; Ok(pf.into_kdf_params()) } /// Locate the reference image path via `RELICARIO_IMAGE` env var or interactive prompt. pub fn get_image_path() -> Result { if let Ok(path) = std::env::var("RELICARIO_IMAGE") { return Ok(PathBuf::from(path)); } // Also accept /reference.jpg as a convention. if let Ok(root) = vault_dir() { let default = root.join("reference.jpg"); if default.exists() { return Ok(default); } } eprint!("Reference image path: "); std::io::Write::flush(&mut std::io::stderr())?; let mut line = String::new(); std::io::stdin().read_line(&mut line)?; let trimmed = line.trim(); if trimmed.is_empty() { bail!("no reference image path provided"); } Ok(PathBuf::from(trimmed)) } /// Atomic write: write to .tmp, then rename over . Keeps the /// vault file consistent if we crash mid-write. fn atomic_write(path: &Path, data: &[u8]) -> Result<()> { let mut tmp = path.as_os_str().to_owned(); tmp.push(".tmp"); let tmp = PathBuf::from(tmp); fs::write(&tmp, data).with_context(|| format!("failed to write {}", tmp.display()))?; fs::rename(&tmp, path).with_context(|| format!("failed to rename {}", path.display()))?; Ok(()) } #[cfg(test)] mod tests { use super::*; const FIXTURE: &str = r#"{ "format_version": 2, "kdf": { "algorithm": "argon2id-v0x13", "argon2_m": 65536, "argon2_t": 3, "argon2_p": 4 }, "aead": "xchacha20poly1305", "salt_path": ".relicario/salt" }"#; #[test] fn params_file_round_trips_current_layout() { let pf: ParamsFile = serde_json::from_str(FIXTURE).expect("parse fixture"); assert_eq!(pf.format_version, 2); assert_eq!(pf.kdf.algorithm, "argon2id-v0x13"); assert_eq!(pf.kdf.argon2_m, 65536); assert_eq!(pf.kdf.argon2_t, 3); assert_eq!(pf.kdf.argon2_p, 4); assert_eq!(pf.aead, "xchacha20poly1305"); assert_eq!(pf.salt_path, ".relicario/salt"); let kdf = ParamsFile { format_version: pf.format_version, kdf: ParamsKdf { algorithm: pf.kdf.algorithm.clone(), argon2_m: pf.kdf.argon2_m, argon2_t: pf.kdf.argon2_t, argon2_p: pf.kdf.argon2_p, }, aead: pf.aead.clone(), salt_path: pf.salt_path.clone(), } .into_kdf_params(); assert_eq!(kdf.argon2_m, 65536); assert_eq!(kdf.argon2_t, 3); assert_eq!(kdf.argon2_p, 4); let serialized = serde_json::to_string(&pf).expect("re-serialize"); let pf2: ParamsFile = serde_json::from_str(&serialized).expect("parse re-serialized"); assert_eq!(pf2.format_version, 2); assert_eq!(pf2.kdf.algorithm, "argon2id-v0x13"); assert_eq!(pf2.kdf.argon2_m, 65536); assert_eq!(pf2.kdf.argon2_t, 3); assert_eq!(pf2.kdf.argon2_p, 4); assert_eq!(pf2.aead, "xchacha20poly1305"); assert_eq!(pf2.salt_path, ".relicario/salt"); } #[test] fn for_new_vault_produces_expected_shape() { let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }; let pf = ParamsFile::for_new_vault(¶ms); let v = serde_json::to_value(&pf).expect("to_value"); assert_eq!(v["format_version"], 2); assert_eq!(v["kdf"]["algorithm"], "argon2id-v0x13"); assert_eq!(v["kdf"]["argon2_m"], 65536); assert_eq!(v["kdf"]["argon2_t"], 3); assert_eq!(v["kdf"]["argon2_p"], 4); assert_eq!(v["aead"], "xchacha20poly1305"); assert_eq!(v["salt_path"], ".relicario/salt"); } #[test] fn after_manifest_change_writes_manifest_and_groups_cache() { let dir = tempfile::TempDir::new().unwrap(); let root = dir.path().to_path_buf(); std::fs::create_dir_all(root.join(".relicario")).unwrap(); std::fs::create_dir_all(root.join("items")).unwrap(); let vault = UnlockedVault { root: root.clone(), master_key: Zeroizing::new([0u8; 32]), }; let manifest = Manifest::new(); vault.after_manifest_change(&manifest).unwrap(); assert!(root.join("manifest.enc").exists()); assert!(root.join(".relicario/groups.cache").exists()); } }