//! 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)?) } pub fn save_manifest(&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) } fn read_params(root: &Path) -> Result { // params.json layout: { "format_version": 2, "kdf": { "argon2_m": ..., ... }, ... } // We extract only the "kdf" sub-object and deserialize it as KdfParams. #[derive(serde::Deserialize)] struct ParamsFile { kdf: KdfParams, } 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.kdf) } /// 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(()) }