From e8f66351884cdba691871e629e409ce4f330c732 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 21:37:59 -0400 Subject: [PATCH] feat(cli): unlock resolves second factor from params hint (image|keyfile) Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG --- crates/relicario-cli/src/session.rs | 56 +++++++++++++++++---- crates/relicario-cli/tests/keyfile_flows.rs | 40 +++++++++++++++ 2 files changed, 85 insertions(+), 11 deletions(-) create mode 100644 crates/relicario-cli/tests/keyfile_flows.rs diff --git a/crates/relicario-cli/src/session.rs b/crates/relicario-cli/src/session.rs index 8b2fbbb..df25f12 100644 --- a/crates/relicario-cli/src/session.rs +++ b/crates/relicario-cli/src/session.rs @@ -28,16 +28,28 @@ 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. + /// Full interactive unlock flow: locate vault, prompt passphrase, resolve + /// the second factor (reference image or key file), 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 pf = read_params(&root)?; + let params = pf.to_kdf_params(); + + let secret: Zeroizing<[u8; 32]> = match pf.second_factor { + SecondFactor::Image => { + let image_path = get_image_path()?; + let image_bytes = fs::read(&image_path) + .with_context(|| format!("failed to read reference image {}", image_path.display()))?; + Zeroizing::new(imgsecret::extract(&image_bytes)?) + } + SecondFactor::Keyfile => { + let kf_path = get_keyfile_path()?; + let kf_bytes = fs::read(&kf_path) + .with_context(|| format!("failed to read key file {}", kf_path.display()))?; + relicario_core::keyfile::keyfile_decode(&kf_bytes).context("invalid key file")? + } + }; let passphrase = if let Some(p) = crate::test_passphrase_override() { Zeroizing::new(p) @@ -50,7 +62,7 @@ impl UnlockedVault { let master_key = derive_master_key( passphrase.as_bytes(), - &image_secret, + &secret, &salt, ¶ms, )?; @@ -160,11 +172,10 @@ impl ParamsFile { } } -fn read_params(root: &Path) -> Result { +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.to_kdf_params()) + serde_json::from_str(&s).context("failed to parse params.json") } /// Locate the reference image path via `RELICARIO_IMAGE` env var or interactive prompt. @@ -186,6 +197,29 @@ pub fn get_image_path() -> Result { Ok(PathBuf::from(trimmed)) } +/// Locate the key file via `RELICARIO_KEYFILE` env var, the +/// `/vault.relkey` convention, or an interactive prompt. +pub fn get_keyfile_path() -> Result { + if let Ok(path) = std::env::var("RELICARIO_KEYFILE") { + return Ok(PathBuf::from(path)); + } + if let Ok(root) = vault_dir() { + let default = root.join("vault.relkey"); + if default.exists() { + return Ok(default); + } + } + eprint!("Key file 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 key file 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<()> { diff --git a/crates/relicario-cli/tests/keyfile_flows.rs b/crates/relicario-cli/tests/keyfile_flows.rs new file mode 100644 index 0000000..4672656 --- /dev/null +++ b/crates/relicario-cli/tests/keyfile_flows.rs @@ -0,0 +1,40 @@ +use assert_cmd::prelude::*; +use std::process::Command; + +fn relicario(dir: &tempfile::TempDir) -> Command { + let mut cmd = Command::cargo_bin("relicario").unwrap(); + cmd.current_dir(dir.path()); + cmd +} + +/// Full round-trip: init a vault with a key file, then add + get an item using +/// RELICARIO_KEYFILE. Ignored until Task 5 lands `init --key-file`. +#[test] +#[ignore = "un-ignored in Task 5 once init --key-file lands"] +fn init_keyfile_then_unlock_keyfile_round_trips() { + let dir = tempfile::tempdir().unwrap(); + + // init with a key file (Task 5 wires the flag) + relicario(&dir) + .args(["init", "--key-file", "vault.relkey"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .assert() + .success(); + + // add an item using the generated key file + relicario(&dir) + .args(["add", "login", "--title", "gh", "--username", "u", "--password", "p"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .env("RELICARIO_KEYFILE", dir.path().join("vault.relkey")) + .assert() + .success(); + + // get the item — username "u" must appear in stdout + relicario(&dir) + .args(["get", "gh", "--show"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .env("RELICARIO_KEYFILE", dir.path().join("vault.relkey")) + .assert() + .success() + .stdout(predicates::str::contains("u")); +}