feat(cli): add UnlockedVault session wrapping master_key in Zeroizing
Provides load/save helpers for Manifest/Settings/Item; atomic_write keeps vault files consistent across crashes. main.rs is transiently broken against the old Entry API — Task 5+ rewrites the command handlers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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};
|
||||
|
||||
139
crates/relicario-cli/src/session.rs
Normal file
139
crates/relicario-cli/src/session.rs
Normal file
@@ -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<Self> {
|
||||
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<Manifest> {
|
||||
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<VaultSettings> {
|
||||
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<Item> {
|
||||
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<KdfParams> {
|
||||
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<PathBuf> {
|
||||
if let Ok(path) = std::env::var("RELICARIO_IMAGE") {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
// Also accept <vault_root>/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 <path>.tmp, then rename over <path>. 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user