feat(cli): unlock resolves second factor from params hint (image|keyfile)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG
This commit is contained in:
@@ -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<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 = 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<KdfParams> {
|
||||
fn read_params(root: &Path) -> Result<ParamsFile> {
|
||||
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<PathBuf> {
|
||||
Ok(PathBuf::from(trimmed))
|
||||
}
|
||||
|
||||
/// Locate the key file via `RELICARIO_KEYFILE` env var, the
|
||||
/// `<vault_root>/vault.relkey` convention, or an interactive prompt.
|
||||
pub fn get_keyfile_path() -> Result<PathBuf> {
|
||||
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 <path>.tmp, then rename over <path>. Keeps the
|
||||
/// vault file consistent if we crash mid-write.
|
||||
fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
|
||||
|
||||
40
crates/relicario-cli/tests/keyfile_flows.rs
Normal file
40
crates/relicario-cli/tests/keyfile_flows.rs
Normal file
@@ -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"));
|
||||
}
|
||||
Reference in New Issue
Block a user