From b08b388d5d9381011cee8df437c7d465bad71970 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 21:02:13 -0400 Subject: [PATCH 1/9] feat(core+cli): SecondFactor hint on ParamsFile (default image, back-compat) Add SecondFactor enum (Image | Keyfile, serde lowercase) to relicario-core::crypto, re-export from lib.rs alongside KdfParams. Add #[serde(default)] second_factor field to ParamsFile in relicario-cli::session so existing params.json files (no field) still parse as Image. for_new_vault() initialises the field to Image. No unlock logic changed; that is deferred to Task 4. TDD: tests written RED first, then GREEN. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG --- crates/relicario-cli/src/session.rs | 34 ++++++++++++++++++++++++++++- crates/relicario-core/src/crypto.rs | 34 +++++++++++++++++++++++++++++ crates/relicario-core/src/lib.rs | 2 +- 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/relicario-cli/src/session.rs b/crates/relicario-cli/src/session.rs index 0e4f0ed..8b2fbbb 100644 --- a/crates/relicario-cli/src/session.rs +++ b/crates/relicario-cli/src/session.rs @@ -12,7 +12,7 @@ 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, + imgsecret, Item, ItemId, KdfParams, Manifest, SecondFactor, VaultSettings, }; use crate::helpers::vault_dir; @@ -119,6 +119,11 @@ pub(crate) struct ParamsFile { pub kdf: ParamsKdf, pub aead: String, pub salt_path: String, + /// Which second-factor container this vault uses to supply its 256-bit secret. + /// Defaults to `Image` (reference JPEG via DCT stego) for back-compat with + /// params.json files written before keyfile support was introduced. + #[serde(default)] + pub second_factor: SecondFactor, } #[derive(serde::Serialize, serde::Deserialize)] @@ -142,6 +147,7 @@ impl ParamsFile { }, aead: "xchacha20poly1305".into(), salt_path: ".relicario/salt".into(), + second_factor: SecondFactor::Image, } } @@ -248,6 +254,32 @@ mod tests { assert_eq!(v["salt_path"], ".relicario/salt"); } + #[test] + fn params_file_second_factor_backcompat() { + // An old params.json without second_factor must deserialize with Image (back-compat). + let pf: ParamsFile = serde_json::from_str(FIXTURE).expect("parse old fixture"); + assert_eq!(pf.second_factor, relicario_core::SecondFactor::Image); + } + + #[test] + fn params_file_second_factor_keyfile() { + // A params.json WITH "second_factor":"keyfile" must deserialize as Keyfile. + let with_keyfile = r#"{ + "format_version": 2, + "kdf": { + "algorithm": "argon2id-v0x13", + "argon2_m": 65536, + "argon2_t": 3, + "argon2_p": 4 + }, + "aead": "xchacha20poly1305", + "salt_path": ".relicario/salt", + "second_factor": "keyfile" +}"#; + let pf: ParamsFile = serde_json::from_str(with_keyfile).expect("parse keyfile fixture"); + assert_eq!(pf.second_factor, relicario_core::SecondFactor::Keyfile); + } + #[test] fn after_manifest_change_writes_manifest_and_groups_cache() { let dir = tempfile::TempDir::new().unwrap(); diff --git a/crates/relicario-core/src/crypto.rs b/crates/relicario-core/src/crypto.rs index 78426b4..bbf7485 100644 --- a/crates/relicario-core/src/crypto.rs +++ b/crates/relicario-core/src/crypto.rs @@ -55,6 +55,29 @@ use zeroize::Zeroizing; use crate::error::{RelicarioError, Result}; +/// Which second-factor container a vault uses to supply its 256-bit secret. +/// +/// This is a non-secret hint stored in params.json so that the CLI (and future +/// clients) know which unlock branch to follow. It does **not** influence the +/// KDF itself; only the container that delivers `image_secret` changes. +/// +/// Serialized as lowercase via `serde(rename_all = "lowercase")`: +/// - `"image"` — default; secret embedded in a reference JPEG via DCT stego +/// - `"keyfile"` — secret stored in a plain key file (Task 4+) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SecondFactor { + /// The secret is embedded in a reference JPEG via DCT steganography. + /// This is the original and default mode for every vault created before + /// keyfile support was introduced. + #[default] + Image, + /// The secret is stored in a plain key file on disk. + /// Vaults with this hint require a key-file path at unlock time instead of + /// a reference image. + Keyfile, +} + /// Current binary format version. Increment this if the ciphertext layout changes. pub const VERSION_BYTE: u8 = 0x02; @@ -417,6 +440,17 @@ mod tests { assert_eq!(VERSION_BYTE, 0x02); } + #[test] + fn second_factor_serde_behavior() { + assert_eq!(serde_json::to_string(&SecondFactor::Image).unwrap(), "\"image\""); + assert_eq!(serde_json::to_string(&SecondFactor::Keyfile).unwrap(), "\"keyfile\""); + assert_eq!( + serde_json::from_str::("\"keyfile\"").unwrap(), + SecondFactor::Keyfile + ); + assert_eq!(SecondFactor::default(), SecondFactor::Image); + } + #[test] fn decrypt_rejects_v1_blob_with_typed_error() { // Construct a v1-style blob: [0x01][24 nonce bytes][16 tag bytes]. diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index 5c1e607..353ebbc 100644 --- a/crates/relicario-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -43,7 +43,7 @@ pub mod error; pub use error::{RelicarioError, Result}; pub mod crypto; -pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE}; +pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, SecondFactor, VERSION_BYTE}; pub mod ids; pub use ids::{AttachmentId, FieldId, ItemId}; From 3b7099c6f435fd2a664c819a4daf650f2eaacae9 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 21:09:40 -0400 Subject: [PATCH 2/9] feat(core): key-file armor (relicario-keyfile-v1) encode/decode Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG --- crates/relicario-core/src/keyfile.rs | 73 ++++++++++++++++++++++++++++ crates/relicario-core/src/lib.rs | 2 + 2 files changed, 75 insertions(+) create mode 100644 crates/relicario-core/src/keyfile.rs diff --git a/crates/relicario-core/src/keyfile.rs b/crates/relicario-core/src/keyfile.rs new file mode 100644 index 0000000..c8a0509 --- /dev/null +++ b/crates/relicario-core/src/keyfile.rs @@ -0,0 +1,73 @@ +//! Key-file armor for the pluggable second factor. The file holds the raw +//! 32-byte secret (base64) behind a version header -- it is the "something +//! you have", not an encrypted artifact (the passphrase is the other factor). +//! +//! The format is intentionally minimal and human-inspectable: +//! +//! ```text +//! relicario-keyfile-v1 +//! +//! ``` +//! +//! Two lines, both terminated with `\n`. There is no encryption here: the file +//! is as sensitive as the raw secret itself. Store it accordingly (encrypted +//! drive, hardware token, offline backup). The passphrase remains the second +//! factor; together they feed Argon2id to derive the master key. + +use base64::{engine::general_purpose::STANDARD, Engine}; +use zeroize::Zeroizing; + +use crate::error::{RelicarioError, Result}; + +const HEADER: &str = "relicario-keyfile-v1"; + +/// Encode a 32-byte secret into the `relicario-keyfile-v1` armor format. +/// +/// Output is: `"relicario-keyfile-v1\n\n"` as UTF-8 bytes. +/// The returned bytes are suitable for writing directly to a `.relkey` file. +pub fn keyfile_encode(secret: &[u8; 32]) -> Vec { + format!("{HEADER}\n{}\n", STANDARD.encode(secret)).into_bytes() +} + +/// Decode a `relicario-keyfile-v1` armored file back to the raw 32-byte secret. +/// +/// Returns [`RelicarioError::Format`] if the header is missing or wrong, if the +/// body is not valid base64, or if the decoded length is not exactly 32 bytes. +pub fn keyfile_decode(bytes: &[u8]) -> Result> { + let text = std::str::from_utf8(bytes) + .map_err(|_| RelicarioError::Format("key file is not UTF-8".into()))?; + let mut lines = text.lines(); + if lines.next() != Some(HEADER) { + return Err(RelicarioError::Format("bad key-file header".into())); + } + let b64 = lines.next().unwrap_or("").trim(); + let decoded = STANDARD + .decode(b64) + .map_err(|_| RelicarioError::Format("key-file body not base64".into()))?; + let arr: [u8; 32] = decoded + .as_slice() + .try_into() + .map_err(|_| RelicarioError::Format("key-file secret must be 32 bytes".into()))?; + Ok(Zeroizing::new(arr)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn round_trip() { + let secret = [9u8; 32]; + let armored = keyfile_encode(&secret); + assert!(std::str::from_utf8(&armored).unwrap().starts_with("relicario-keyfile-v1\n")); + let back = keyfile_decode(&armored).unwrap(); + assert_eq!(*back, secret); + } + #[test] + fn rejects_bad_header() { + assert!(keyfile_decode(b"not-a-keyfile\nAAAA\n").is_err()); + } + #[test] + fn rejects_wrong_length() { + assert!(keyfile_decode(b"relicario-keyfile-v1\nAAAA\n").is_err()); // decodes to <32 bytes + } +} diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index 353ebbc..f362ec2 100644 --- a/crates/relicario-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -84,6 +84,8 @@ pub use vault::{ pub mod imgsecret; +pub mod keyfile; + pub mod backup; pub use backup::{pack_backup, unpack_backup, BackupInput, BackupOutput, BackupItem, BackupAttachment}; From eabc93bd84bd69b27e0172caf9dd0dd231942f9f Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 21:15:11 -0400 Subject: [PATCH 3/9] fix(core): zeroize keyfile decode intermediate + clearer errors + error-arm tests --- crates/relicario-core/src/keyfile.rs | 32 ++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/crates/relicario-core/src/keyfile.rs b/crates/relicario-core/src/keyfile.rs index c8a0509..c84cf73 100644 --- a/crates/relicario-core/src/keyfile.rs +++ b/crates/relicario-core/src/keyfile.rs @@ -40,15 +40,21 @@ pub fn keyfile_decode(bytes: &[u8]) -> Result> { if lines.next() != Some(HEADER) { return Err(RelicarioError::Format("bad key-file header".into())); } - let b64 = lines.next().unwrap_or("").trim(); - let decoded = STANDARD - .decode(b64) - .map_err(|_| RelicarioError::Format("key-file body not base64".into()))?; - let arr: [u8; 32] = decoded - .as_slice() - .try_into() - .map_err(|_| RelicarioError::Format("key-file secret must be 32 bytes".into()))?; - Ok(Zeroizing::new(arr)) + let b64 = match lines.next() { + Some(line) => line.trim(), + None => return Err(RelicarioError::Format("key-file missing body line".into())), + }; + let decoded: Zeroizing> = Zeroizing::new( + STANDARD + .decode(b64) + .map_err(|_| RelicarioError::Format("key-file body not base64".into()))?, + ); + if decoded.len() != 32 { + return Err(RelicarioError::Format("key-file secret must be 32 bytes".into())); + } + let mut arr = Zeroizing::new([0u8; 32]); + arr.copy_from_slice(&decoded); + Ok(arr) } #[cfg(test)] @@ -70,4 +76,12 @@ mod tests { fn rejects_wrong_length() { assert!(keyfile_decode(b"relicario-keyfile-v1\nAAAA\n").is_err()); // decodes to <32 bytes } + #[test] + fn rejects_non_utf8() { + assert!(keyfile_decode(b"\xff\xfe\nAAAA\n").is_err()); + } + #[test] + fn rejects_missing_body() { + assert!(keyfile_decode(b"relicario-keyfile-v1\n").is_err()); + } } From 6cb12990eccf747c666e7c1bd0b64244927b4826 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 21:24:10 -0400 Subject: [PATCH 4/9] feat(wasm): unlock_with_secret + keyfile encode/decode bindings + equivalence proof Adds three WASM bindings for the pluggable second-factor work: - unlock_with_secret: mirrors unlock() but accepts a raw 32-byte secret instead of a carrier image, skipping imgsecret::extract - keyfile_encode: armors a 32-byte secret into relicario-keyfile-v1 format - keyfile_decode: decodes keyfile armor back to the raw 32-byte secret Adds native equivalence test proving that unlock_with_secret and unlock derive an identical Argon2id master key when given the same secret, passphrase, salt, and KDF params. Comparison is direct (master-key bytes), not cross-decrypt, so the test is both native-safe and unambiguous. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG --- crates/relicario-wasm/src/lib.rs | 101 ++++++++++++++++++++++++++++++- extension/src/wasm.d.ts | 5 ++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index f6c6b80..21eb255 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -70,7 +70,46 @@ pub fn lock(handle: &SessionHandle) -> bool { session::remove(handle.0) } -// Subsequent wasm_bindgen fns added in Tasks 19-21. +// ── Pluggable second factor: key-file bindings (Task 3) ────────────────────── + +/// Unlock using a raw 32-byte secret directly, bypassing steganographic +/// extraction. Mirrors `unlock` exactly, except the caller supplies the +/// secret bytes instead of a carrier image. +#[wasm_bindgen] +pub fn unlock_with_secret( + passphrase: &str, + secret: &[u8], + salt: &[u8], + params_json: &str, +) -> Result { + let params: KdfParams = serde_json::from_str(params_json) + .map_err(|e| JsError::new(&format!("params: {e}")))?; + let secret_arr: &[u8; 32] = secret.try_into() + .map_err(|_| JsError::new("secret must be exactly 32 bytes"))?; + let salt_arr: &[u8; 32] = salt.try_into() + .map_err(|_| JsError::new("salt must be exactly 32 bytes"))?; + let master_key = derive_master_key(passphrase.as_bytes(), secret_arr, salt_arr, ¶ms) + .map_err(|e| JsError::new(&e.to_string()))?; + let handle = session::insert(master_key, Zeroizing::new(*secret_arr)); + Ok(SessionHandle(handle)) +} + +/// Encode a 32-byte secret into the `relicario-keyfile-v1` armor format. +/// Returns the UTF-8 bytes suitable for writing to a `.relkey` file. +#[wasm_bindgen] +pub fn keyfile_encode(secret: &[u8]) -> Result, JsError> { + let arr: &[u8; 32] = secret.try_into() + .map_err(|_| JsError::new("secret must be exactly 32 bytes"))?; + Ok(relicario_core::keyfile::keyfile_encode(arr)) +} + +/// Decode a `relicario-keyfile-v1` armored file, returning the raw 32-byte secret. +#[wasm_bindgen] +pub fn keyfile_decode(bytes: &[u8]) -> Result, JsError> { + let s = relicario_core::keyfile::keyfile_decode(bytes) + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(s.to_vec()) +} use serde_wasm_bindgen::Serializer; use relicario_core::{ @@ -573,6 +612,66 @@ mod session_tests { use super::*; use zeroize::Zeroizing; + /// Synthesize a carrier JPEG for embedding tests (mirrors core's private helper). + fn make_test_jpeg(width: u32, height: u32) -> Vec { + use image::codecs::jpeg::JpegEncoder; + use image::{ImageBuffer, ImageEncoder, Rgb}; + let img = ImageBuffer::from_fn(width, height, |x, y| { + Rgb([ + ((x * 7 + y * 13) % 256) as u8, + ((x * 11 + y * 3) % 256) as u8, + ((x * 5 + y * 17) % 256) as u8, + ]) + }); + let mut buf = Vec::new(); + JpegEncoder::new_with_quality(&mut buf, 92) + .write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgb8) + .unwrap(); + buf + } + + /// SECURITY PROOF: both unlock paths derive the identical master key. + /// + /// The key-file path (`unlock_with_secret`) must derive the same Argon2id + /// master key as the stego-image path (`unlock`) when given the same secret, + /// passphrase, salt, and KDF params. This is the core security argument: + /// the key file is simply an alternative transport for the same 32-byte + /// secret that the stego-image carries. + #[test] + fn unlock_with_secret_matches_unlock_from_jpeg() { + session::clear(); + let secret = [3u8; 32]; + let salt = [1u8; 32]; + let params = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#; + + // Image transport: embed the secret into a carrier JPEG, then unlock from it. + let carrier = make_test_jpeg(256, 256); + let stego = imgsecret::embed(&carrier, &secret).expect("embed secret into carrier"); + let h_img = unlock("correct horse", &stego, &salt, params).expect("unlock from jpeg"); + + // Key-file transport: unlock from the raw secret directly. + let h_key = unlock_with_secret("correct horse", &secret, &salt, params) + .expect("unlock_with_secret"); + + // PROOF: both sessions hold a byte-identical master key — the key-file + // path derives the SAME master key as the stego-image path for the + // same secret. We compare stored master keys directly (avoids js-sys, + // which is unavailable natively; stronger than a cross-decrypt anyway). + let extract = |h: u32| { + session::with(h, |k| { + let mut a = [0u8; 32]; + a.copy_from_slice(&k[..]); + a + }) + .expect("session present") + }; + assert_eq!( + extract(h_img.value()), + extract(h_key.value()), + "key-file path must derive the identical master key as the stego-image path" + ); + } + #[test] fn insert_then_remove_clears_entry() { session::clear(); diff --git a/extension/src/wasm.d.ts b/extension/src/wasm.d.ts index 2553999..d8512b1 100644 --- a/extension/src/wasm.d.ts +++ b/extension/src/wasm.d.ts @@ -87,6 +87,11 @@ declare module 'relicario-wasm' { export function wasm_generate_recovery_qr(handle: SessionHandle, passphrase: string): string; export function wasm_unwrap_recovery_qr(payload_b64: string, passphrase: string): Uint8Array; + // Pluggable second factor: key-file bindings (Task 3) + export function unlock_with_secret(passphrase: string, secret: Uint8Array, salt: Uint8Array, params_json: string): SessionHandle; + export function keyfile_encode(secret: Uint8Array): Uint8Array; + export function keyfile_decode(bytes: Uint8Array): Uint8Array; + export default function init(module_or_path?: unknown): Promise; export function initSync(args: { module: WebAssembly.Module }): void; } From e8f66351884cdba691871e629e409ce4f330c732 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 21:37:59 -0400 Subject: [PATCH 5/9] 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")); +} From 2daa3502e985b9205af2a2ec52f0f9bd52fd5028 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 23:32:41 -0400 Subject: [PATCH 6/9] feat(cli): init --key-file generates a .relkey second factor Add `--key-file ` to `relicario init` as a mutually exclusive alternative to `--image`/`--output`. When given, a 32-byte secret is generated with OsRng, armored via `keyfile_encode`, written to the supplied path (gitignored, never committed), and `params.json` records `"second_factor": "keyfile"`. The `--image` arg is now Option; existing image-vault callers are unchanged. `ParamsFile::for_new_vault` now takes the second-factor variant as an explicit argument. Tests: un-ignore init_keyfile_then_unlock_keyfile_round_trips (full round-trip), strengthen its assertion to check for distinctive username "octocat", and add init_keyfile_writes_relkey_and_keyfile_params (armor, params parse, gitignore). Full workspace green (0 failures), clippy clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG --- crates/relicario-cli/src/commands/init.rs | 83 ++++++++++++++++----- crates/relicario-cli/src/main.rs | 9 ++- crates/relicario-cli/src/session.rs | 6 +- crates/relicario-cli/tests/keyfile_flows.rs | 44 +++++++++-- 4 files changed, 112 insertions(+), 30 deletions(-) diff --git a/crates/relicario-cli/src/commands/init.rs b/crates/relicario-cli/src/commands/init.rs index f6612b2..ec974f5 100644 --- a/crates/relicario-cli/src/commands/init.rs +++ b/crates/relicario-cli/src/commands/init.rs @@ -4,12 +4,12 @@ use std::path::PathBuf; use anyhow::{Context, Result}; -pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { +pub fn cmd_init(image: Option, output: PathBuf, key_file: Option) -> Result<()> { use std::fs; use rand::{rngs::OsRng, RngCore}; use relicario_core::{ derive_master_key, encrypt_manifest, encrypt_settings, imgsecret, - validate_passphrase_strength, KdfParams, Manifest, VaultSettings, + validate_passphrase_strength, KdfParams, Manifest, SecondFactor, VaultSettings, }; use zeroize::Zeroizing; @@ -39,17 +39,54 @@ pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { anyhow::bail!("{}. Choose a longer or more entropic phrase.", e); } - // Image secret: 32 random bytes, embedded in the carrier. - let image_secret = { + // 32-byte second-factor secret — identical entropy for both container types. + let secret = { let mut buf = Zeroizing::new([0u8; 32]); OsRng.fill_bytes(buf.as_mut_slice()); buf }; - let carrier = fs::read(&image) - .with_context(|| format!("failed to read carrier image {}", image.display()))?; - let stego = imgsecret::embed(&carrier, &image_secret)?; - fs::write(&output, &stego) - .with_context(|| format!("failed to write reference image {}", output.display()))?; + + // Write the container (key file or reference image) and record which + // second-factor type this vault uses. + let (gitignore_name, second_factor): (String, SecondFactor) = match (&key_file, &image) { + (Some(kf), _) => { + // Key-file mode: write the armored secret in the clear. + // SECURITY: this file holds the 256-bit secret in the clear and + // must be gitignored so it is never pushed to the remote — otherwise + // the server would hold both the ciphertext and the key, defeating + // the two-factor model. + let kf_path = root.join(kf); + fs::write(&kf_path, relicario_core::keyfile::keyfile_encode(&secret)) + .with_context(|| format!("failed to write key file {}", kf_path.display()))?; + let name = kf + .file_name() + .ok_or_else(|| anyhow::anyhow!("key-file path has no filename: {}", kf.display()))? + .to_string_lossy() + .into_owned(); + (name, SecondFactor::Keyfile) + } + (None, Some(img)) => { + // Image mode: embed into a carrier JPEG, write the reference image. + let carrier = fs::read(img) + .with_context(|| format!("failed to read carrier image {}", img.display()))?; + let stego = imgsecret::embed(&carrier, &secret)?; + fs::write(&output, &stego) + .with_context(|| format!("failed to write reference image {}", output.display()))?; + let name = output + .file_name() + .ok_or_else(|| { + anyhow::anyhow!("output path has no filename: {}", output.display()) + })? + .to_string_lossy() + .into_owned(); + (name, SecondFactor::Image) + } + (None, None) => { + anyhow::bail!( + "provide either --image (with --output) or --key-file " + ) + } + }; // Vault salt + KDF params. let mut salt = [0u8; 32]; @@ -57,7 +94,7 @@ pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }; // Derive master key, then persist an empty Manifest + default VaultSettings. - let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)?; + let master_key = derive_master_key(passphrase.as_bytes(), &secret, &salt, ¶ms)?; fs::create_dir_all(&relicario_dir)?; fs::create_dir_all(root.join("items"))?; @@ -65,21 +102,23 @@ pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { fs::write(relicario_dir.join("salt"), salt)?; fs::write( relicario_dir.join("params.json"), - serde_json::to_string_pretty(&crate::session::ParamsFile::for_new_vault(¶ms))?, + serde_json::to_string_pretty(&crate::session::ParamsFile::for_new_vault( + ¶ms, + second_factor, + ))?, )?; let manifest = Manifest::new(); fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?; let settings = VaultSettings::default(); fs::write(root.join("settings.enc"), encrypt_settings(&settings, &master_key)?)?; - // .gitignore excludes the reference image. - let fname = output.file_name() - .ok_or_else(|| anyhow::anyhow!("output path has no filename: {}", output.display()))? - .to_string_lossy(); - let gitignore = format!("{fname}\n"); + // .gitignore excludes the second-factor container (key file or reference image). + let gitignore = format!("{gitignore_name}\n"); fs::write(root.join(".gitignore"), gitignore)?; // git init + initial commit via hardened wrapper. + // NOTE: only the vault metadata is committed; the key file / reference image + // is intentionally excluded (gitignored) so it never reaches the remote. crate::helpers::git_run(&root, &["init"], "init: git init")?; let _ = crate::helpers::git_command(&root, &[ "add", ".gitignore", ".relicario/params.json", @@ -92,7 +131,15 @@ pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { )?; eprintln!("Vault initialized at {}", root.display()); - eprintln!("Reference image: {}", output.display()); - eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor."); + match second_factor { + SecondFactor::Keyfile => { + eprintln!("Key file: {gitignore_name}"); + eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor."); + } + SecondFactor::Image => { + eprintln!("Reference image: {}", output.display()); + eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor."); + } + } Ok(()) } diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 3e38e43..3ff8f89 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -32,12 +32,15 @@ struct Cli { enum Commands { /// Initialize a new vault in the current directory. Init { - /// Carrier JPEG to embed the secret into. + /// Carrier JPEG to embed the secret into (image second factor). #[arg(long)] - image: PathBuf, + image: Option, /// Output path for the reference image (gitignored). #[arg(long, default_value = "reference.jpg")] output: PathBuf, + /// Generate a key-file second factor at this path instead of a reference image (gitignored). + #[arg(long, conflicts_with = "image")] + key_file: Option, }, /// Add a new item. Type-specific flags populate the core; missing fields @@ -628,7 +631,7 @@ pub(crate) enum OrgAddKind { fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { - Commands::Init { image, output } => commands::init::cmd_init(image, output), + Commands::Init { image, output, key_file } => commands::init::cmd_init(image, output, key_file), Commands::Add { kind } => commands::add::cmd_add(kind), Commands::Get { query, show, copy } => commands::get::cmd_get(query, show, copy), Commands::List { r#type, group, tag, trashed } => commands::list::cmd_list(r#type, group, tag, trashed), diff --git a/crates/relicario-cli/src/session.rs b/crates/relicario-cli/src/session.rs index df25f12..6f5a3ce 100644 --- a/crates/relicario-cli/src/session.rs +++ b/crates/relicario-cli/src/session.rs @@ -148,7 +148,7 @@ pub(crate) struct ParamsKdf { } impl ParamsFile { - pub fn for_new_vault(params: &KdfParams) -> Self { + pub fn for_new_vault(params: &KdfParams, second_factor: SecondFactor) -> Self { Self { format_version: 2, kdf: ParamsKdf { @@ -159,7 +159,7 @@ impl ParamsFile { }, aead: "xchacha20poly1305".into(), salt_path: ".relicario/salt".into(), - second_factor: SecondFactor::Image, + second_factor, } } @@ -277,7 +277,7 @@ mod tests { #[test] fn for_new_vault_produces_expected_shape() { let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }; - let pf = ParamsFile::for_new_vault(¶ms); + let pf = ParamsFile::for_new_vault(¶ms, relicario_core::SecondFactor::Image); let v = serde_json::to_value(&pf).expect("to_value"); assert_eq!(v["format_version"], 2); assert_eq!(v["kdf"]["algorithm"], "argon2id-v0x13"); diff --git a/crates/relicario-cli/tests/keyfile_flows.rs b/crates/relicario-cli/tests/keyfile_flows.rs index 4672656..5369b73 100644 --- a/crates/relicario-cli/tests/keyfile_flows.rs +++ b/crates/relicario-cli/tests/keyfile_flows.rs @@ -8,13 +8,12 @@ fn relicario(dir: &tempfile::TempDir) -> Command { } /// 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`. +/// RELICARIO_KEYFILE. #[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) + // init with a key file relicario(&dir) .args(["init", "--key-file", "vault.relkey"]) .env("RELICARIO_TEST_PASSPHRASE", "correct horse") @@ -23,18 +22,51 @@ fn init_keyfile_then_unlock_keyfile_round_trips() { // add an item using the generated key file relicario(&dir) - .args(["add", "login", "--title", "gh", "--username", "u", "--password", "p"]) + .args(["add", "login", "--title", "gh", "--username", "octocat", "--password", "hunter2"]) .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 + // get the item — distinctive username 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")); + .stdout(predicates::str::contains("octocat")); +} + +/// Unit-level: init with --key-file writes the .relkey armor, records +/// second_factor=keyfile in params.json, and gitignores the key file. +#[test] +fn init_keyfile_writes_relkey_and_keyfile_params() { + let dir = tempfile::tempdir().unwrap(); + relicario(&dir) + .args(["init", "--key-file", "vault.relkey"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .assert() + .success(); + + // .relkey exists and is armored + let relkey = std::fs::read_to_string(dir.path().join("vault.relkey")).unwrap(); + assert!( + relkey.starts_with("relicario-keyfile-v1\n"), + "relkey must be armored: {relkey}" + ); + + // params.json records the keyfile hint (parse — robust to pretty-print spacing) + let params: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(dir.path().join(".relicario/params.json")).unwrap(), + ) + .unwrap(); + assert_eq!(params["second_factor"], "keyfile"); + + // SECURITY: the in-the-clear .relkey must be gitignored + let gitignore = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap(); + assert!( + gitignore.contains("vault.relkey"), + ".relkey must be gitignored: {gitignore}" + ); } From 23c8bbea655c851866693debc0ddfa6d1f50076d Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 23:43:31 -0400 Subject: [PATCH 7/9] test(cli): assert .relkey never enters git tree + init failure-path coverage + display polish Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG --- crates/relicario-cli/src/commands/init.rs | 10 ++-- crates/relicario-cli/src/main.rs | 2 +- crates/relicario-cli/tests/keyfile_flows.rs | 61 +++++++++++++++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/crates/relicario-cli/src/commands/init.rs b/crates/relicario-cli/src/commands/init.rs index ec974f5..e4ee0da 100644 --- a/crates/relicario-cli/src/commands/init.rs +++ b/crates/relicario-cli/src/commands/init.rs @@ -48,7 +48,7 @@ pub fn cmd_init(image: Option, output: PathBuf, key_file: Option { // Key-file mode: write the armored secret in the clear. // SECURITY: this file holds the 256-bit secret in the clear and @@ -63,7 +63,7 @@ pub fn cmd_init(image: Option, output: PathBuf, key_file: Option { // Image mode: embed into a carrier JPEG, write the reference image. @@ -79,7 +79,7 @@ pub fn cmd_init(image: Option, output: PathBuf, key_file: Option { anyhow::bail!( @@ -133,11 +133,11 @@ pub fn cmd_init(image: Option, output: PathBuf, key_file: Option { - eprintln!("Key file: {gitignore_name}"); + eprintln!("Key file: {container_display}"); eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor."); } SecondFactor::Image => { - eprintln!("Reference image: {}", output.display()); + eprintln!("Reference image: {container_display}"); eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor."); } } diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 3ff8f89..21167d5 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -35,7 +35,7 @@ enum Commands { /// Carrier JPEG to embed the secret into (image second factor). #[arg(long)] image: Option, - /// Output path for the reference image (gitignored). + /// Output path for the reference image (gitignored); ignored when --key-file is used. #[arg(long, default_value = "reference.jpg")] output: PathBuf, /// Generate a key-file second factor at this path instead of a reference image (gitignored). diff --git a/crates/relicario-cli/tests/keyfile_flows.rs b/crates/relicario-cli/tests/keyfile_flows.rs index 5369b73..6608938 100644 --- a/crates/relicario-cli/tests/keyfile_flows.rs +++ b/crates/relicario-cli/tests/keyfile_flows.rs @@ -70,3 +70,64 @@ fn init_keyfile_writes_relkey_and_keyfile_params() { ".relkey must be gitignored: {gitignore}" ); } + +/// SECURITY INVARIANT: the in-the-clear .relkey second factor must NEVER enter +/// the git tree — not in the init commit, and not even after an aggressive `git add -A`. +/// The remote/server must only ever receive opaque ciphertext, never the secret. +#[test] +fn relkey_secret_never_enters_git_tree() { + let dir = tempfile::tempdir().unwrap(); + relicario(&dir) + .args(["init", "--key-file", "vault.relkey"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .assert() + .success(); + + let git_tracked = |label: &str| { + let out = Command::new("git") + .current_dir(dir.path()) + .args(["ls-files"]) + .output() + .expect("run git ls-files"); + let files = String::from_utf8_lossy(&out.stdout).into_owned(); + assert!( + !files.contains("vault.relkey"), + "{label}: key file leaked into git tree:\n{files}" + ); + }; + + // Not in the init commit. + git_tracked("after init commit"); + + // Survives an aggressive add -A (gitignore must hold). + let status = Command::new("git") + .current_dir(dir.path()) + .args(["add", "-A"]) + .status() + .expect("run git add -A"); + assert!(status.success(), "git add -A failed"); + git_tracked("after git add -A"); +} + +/// init with no second factor must fail with a clear, actionable error. +#[test] +fn init_without_a_factor_fails() { + let dir = tempfile::tempdir().unwrap(); + relicario(&dir) + .args(["init"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .assert() + .failure() + .stderr(predicates::str::contains("either --image")); +} + +/// --image and --key-file are mutually exclusive (clap rejects before the handler). +#[test] +fn init_with_both_factors_fails() { + let dir = tempfile::tempdir().unwrap(); + relicario(&dir) + .args(["init", "--image", "carrier.jpg", "--key-file", "vault.relkey"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .assert() + .failure(); +} From 2203d817ec576396550e126c7267613602d6ea96 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 26 Jun 2026 00:11:50 -0400 Subject: [PATCH 8/9] fix(cli): resolve second factor via shared helper for recovery-qr + backup (key-file parity); wasm equivalence negative control Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG --- crates/relicario-cli/src/commands/backup.rs | 12 ++++-- .../relicario-cli/src/commands/recovery_qr.rs | 26 ++++++------ crates/relicario-cli/src/session.rs | 40 ++++++++++++------- crates/relicario-cli/tests/keyfile_flows.rs | 18 +++++++++ crates/relicario-wasm/src/lib.rs | 10 +++++ 5 files changed, 75 insertions(+), 31 deletions(-) diff --git a/crates/relicario-cli/src/commands/backup.rs b/crates/relicario-cli/src/commands/backup.rs index 73e91e4..5acace4 100644 --- a/crates/relicario-cli/src/commands/backup.rs +++ b/crates/relicario-cli/src/commands/backup.rs @@ -103,10 +103,14 @@ pub(super) fn cmd_backup_export( // Optional reference image. let image_bytes = if include_image { - let path = match image { - Some(p) => p, - None => crate::session::get_image_path()?, - }; + let pf = crate::session::read_params(&root)?; + if pf.second_factor == relicario_core::SecondFactor::Keyfile { + anyhow::bail!( + "--include-image is not applicable to a key-file vault (it has no reference image); \ + back up your .relkey file directly" + ); + } + let path = match image { Some(p) => p, None => crate::session::get_image_path()? }; Some(fs::read(&path) .with_context(|| format!("failed to read reference image {}", path.display()))?) } else { diff --git a/crates/relicario-cli/src/commands/recovery_qr.rs b/crates/relicario-cli/src/commands/recovery_qr.rs index 7aaf08d..4525a56 100644 --- a/crates/relicario-cli/src/commands/recovery_qr.rs +++ b/crates/relicario-cli/src/commands/recovery_qr.rs @@ -12,21 +12,23 @@ pub fn cmd_recovery_qr(cmd: RecoveryQrCmd) -> Result<()> { } fn cmd_recovery_qr_generate() -> Result<()> { - use relicario_core::{generate_recovery_qr, imgsecret}; + use relicario_core::generate_recovery_qr; use zeroize::Zeroizing; - let image_path = crate::session::get_image_path()?; - let image_bytes = std::fs::read(&image_path) - .with_context(|| format!("read reference image {}", image_path.display()))?; - let image_secret = imgsecret::extract(&image_bytes) - .context("extract image secret")?; + let root = crate::helpers::vault_dir()?; + let pf = crate::session::read_params(&root)?; + let secret = crate::session::resolve_second_factor_secret(&root, pf.second_factor)?; - let passphrase = Zeroizing::new( - rpassword::prompt_password("Enter vault passphrase: ") - .context("read passphrase")? - ); + let passphrase = if let Some(p) = crate::test_passphrase_override() { + Zeroizing::new(p) + } else { + Zeroizing::new( + rpassword::prompt_password("Enter vault passphrase: ") + .context("read passphrase")? + ) + }; - let payload = generate_recovery_qr(passphrase.as_str(), &image_secret) + let payload = generate_recovery_qr(passphrase.as_str(), &secret) .map_err(|e| anyhow::anyhow!("{e}"))?; use qrcode::{EcLevel, QrCode, render::unicode}; @@ -64,6 +66,6 @@ fn cmd_recovery_qr_unwrap() -> Result<()> { let secret = unwrap_recovery_qr(&bytes, passphrase.as_str()) .map_err(|e| anyhow::anyhow!("{e}"))?; - println!("image_secret: {}", hex::encode(secret.as_ref())); + println!("secret: {}", hex::encode(secret.as_ref())); Ok(()) } diff --git a/crates/relicario-cli/src/session.rs b/crates/relicario-cli/src/session.rs index 6f5a3ce..63acf75 100644 --- a/crates/relicario-cli/src/session.rs +++ b/crates/relicario-cli/src/session.rs @@ -36,20 +36,7 @@ impl UnlockedVault { 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 secret = resolve_second_factor_secret(&root, pf.second_factor)?; let passphrase = if let Some(p) = crate::test_passphrase_override() { Zeroizing::new(p) @@ -172,7 +159,7 @@ impl ParamsFile { } } -fn read_params(root: &Path) -> Result { +pub(crate) 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")?; serde_json::from_str(&s).context("failed to parse params.json") @@ -220,6 +207,29 @@ pub fn get_keyfile_path() -> Result { Ok(PathBuf::from(trimmed)) } +/// Resolve the vault's 32-byte second-factor secret per the params hint: +/// extract it from the reference image, or decode it from the key file. +pub(crate) fn resolve_second_factor_secret( + root: &Path, + second_factor: SecondFactor, +) -> Result> { + let _ = root; // retained in the API for future vault-relative resolution + match 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()))?; + Ok(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") + } + } +} + /// 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 index 6608938..d771a47 100644 --- a/crates/relicario-cli/tests/keyfile_flows.rs +++ b/crates/relicario-cli/tests/keyfile_flows.rs @@ -121,6 +121,24 @@ fn init_without_a_factor_fails() { .stderr(predicates::str::contains("either --image")); } +/// recovery-qr generate must work for a key-file vault (was broken: it hard-coded image extraction). +#[test] +fn recovery_qr_generate_works_for_keyfile_vault() { + let dir = tempfile::tempdir().unwrap(); + relicario(&dir) + .args(["init", "--key-file", "vault.relkey"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .assert() + .success(); + relicario(&dir) + .args(["recovery-qr", "generate"]) + .env("RELICARIO_TEST_PASSPHRASE", "correct horse") + .env("RELICARIO_KEYFILE", dir.path().join("vault.relkey")) + .assert() + .success() + .stdout(predicates::str::contains("Recovery QR generated")); +} + /// --image and --key-file are mutually exclusive (clap rejects before the handler). #[test] fn init_with_both_factors_fails() { diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index 21eb255..a314e2a 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -670,6 +670,16 @@ mod session_tests { extract(h_key.value()), "key-file path must derive the identical master key as the stego-image path" ); + + // Negative control: a DIFFERENT secret must derive a DIFFERENT master key + // (guards against a hypothetical constant/secret-insensitive KDF regression). + let h_other = unlock_with_secret("correct horse", &[4u8; 32], &salt, params) + .expect("unlock_with_secret other"); + assert_ne!( + extract(h_img.value()), + extract(h_other.value()), + "a different secret must derive a different master key" + ); } #[test] From 110f02e4e3ce320d7bf3df4cd2104ced258450c5 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 26 Jun 2026 00:19:55 -0400 Subject: [PATCH 9/9] refactor(cli): drop unused param from resolve_second_factor_secret (simplify pass) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG --- crates/relicario-cli/src/commands/recovery_qr.rs | 2 +- crates/relicario-cli/src/session.rs | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/relicario-cli/src/commands/recovery_qr.rs b/crates/relicario-cli/src/commands/recovery_qr.rs index 4525a56..f1adc1a 100644 --- a/crates/relicario-cli/src/commands/recovery_qr.rs +++ b/crates/relicario-cli/src/commands/recovery_qr.rs @@ -17,7 +17,7 @@ fn cmd_recovery_qr_generate() -> Result<()> { let root = crate::helpers::vault_dir()?; let pf = crate::session::read_params(&root)?; - let secret = crate::session::resolve_second_factor_secret(&root, pf.second_factor)?; + let secret = crate::session::resolve_second_factor_secret(pf.second_factor)?; let passphrase = if let Some(p) = crate::test_passphrase_override() { Zeroizing::new(p) diff --git a/crates/relicario-cli/src/session.rs b/crates/relicario-cli/src/session.rs index 63acf75..c6fde19 100644 --- a/crates/relicario-cli/src/session.rs +++ b/crates/relicario-cli/src/session.rs @@ -36,7 +36,7 @@ impl UnlockedVault { let pf = read_params(&root)?; let params = pf.to_kdf_params(); - let secret = resolve_second_factor_secret(&root, pf.second_factor)?; + let secret = resolve_second_factor_secret(pf.second_factor)?; let passphrase = if let Some(p) = crate::test_passphrase_override() { Zeroizing::new(p) @@ -210,10 +210,8 @@ pub fn get_keyfile_path() -> Result { /// Resolve the vault's 32-byte second-factor secret per the params hint: /// extract it from the reference image, or decode it from the key file. pub(crate) fn resolve_second_factor_secret( - root: &Path, second_factor: SecondFactor, ) -> Result> { - let _ = root; // retained in the API for future vault-relative resolution match second_factor { SecondFactor::Image => { let image_path = get_image_path()?;