diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index a613c66..a1d056b 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -38,6 +38,7 @@ //! creates a git commit, preserving an audit log of all vault changes. mod helpers; +mod session; use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; diff --git a/crates/relicario-cli/src/session.rs b/crates/relicario-cli/src/session.rs new file mode 100644 index 0000000..672b81f --- /dev/null +++ b/crates/relicario-cli/src/session.rs @@ -0,0 +1,139 @@ +//! 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 = imgsecret::extract(&image_bytes)?; + + let passphrase = 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 { + let s = fs::read_to_string(root.join(".relicario").join("params.json")) + .context("failed to read .relicario/params.json")?; + let params: KdfParams = serde_json::from_str(&s).context("failed to parse params.json")?; + Ok(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 tmp = path.with_extension("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(()) +}