# Key-File Second Factor — Core + CLI Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make the vault's 256-bit second factor pluggable — carryable by a plain key file as well as the stego image — at the core and CLI layers, chosen at `init` and recorded by a non-secret params hint. **Architecture:** The second factor is already just 32 bytes (`image_secret`); stego is only the transport. We add a key-file armor format and a raw-secret unlock path in `relicario-core`, surface both over `relicario-wasm`, and branch the CLI's factor resolution on a new non-secret `second_factor` field in `params.json`. The Argon2id KDF and AEAD are byte-for-byte unchanged — only the *source* of the 32 bytes differs. **Tech Stack:** Rust (relicario-core, relicario-cli, relicario-wasm), wasm-bindgen, clap. ## Global Constraints - Release target: v0.9.0. - No new crypto primitive: reuse `derive_master_key` (`crates/relicario-core/src/crypto.rs:207`). The key-file path must derive the *same* master key as the stego path for the same 32-byte secret (equivalence is the security argument). - `params.json` `second_factor` is **non-secret** and defaults to `image` when absent (every existing vault keeps working). - The key file (`.relkey`) holds the secret in the clear — it is the "something you have," protected by needing the passphrase too. Same posture as the reference JPEG. Do not imply it is encrypted. - Rust tests use fast Argon2id params (m=256, t=1, p=1). - Capitalize "Relicario" in prose; the binary/command stays lowercase `relicario`. --- ## File Structure - `crates/relicario-core/src/crypto.rs` — add `SecondFactor` enum + `second_factor` field on `KdfParams` (the struct serialized to `params.json`); confirm the 1:1 mapping first. - `crates/relicario-core/src/keyfile.rs` *(new)* — `keyfile_encode` / `keyfile_decode` (armor format, `Zeroizing`). - `crates/relicario-core/src/lib.rs` — `pub mod keyfile;` re-export. - `crates/relicario-wasm/src/lib.rs` — `#[wasm_bindgen]` `keyfile_encode`, `keyfile_decode`, `unlock_with_secret`. - `extension/src/wasm.d.ts` — declare the three (consumed by Plan 5). - `crates/relicario-cli/src/session.rs` — `get_keyfile_path()`; branch `unlock_interactive` on the params hint. - `crates/relicario-cli/src/commands/init.rs` (or wherever `init` lives — locate first) — `--key-file` path. - `crates/relicario-cli/src/main.rs` — clap `--key-file` flag on `init` (and `RELICARIO_KEYFILE` doc in help). - Tests: core unit tests in `keyfile.rs` + `crypto.rs`; `crates/relicario-wasm` equivalence test; `crates/relicario-cli/tests/keyfile_flows.rs` *(new)*. --- ### Task 1: `SecondFactor` hint in `KdfParams` **Files:** - Modify: `crates/relicario-core/src/crypto.rs:157` (`KdfParams`) - Test: `crates/relicario-core/src/crypto.rs` (`#[cfg(test)]`) **Interfaces:** - Produces: `pub enum SecondFactor { Image, Keyfile }` (serde `rename_all = "lowercase"`); `KdfParams.second_factor: SecondFactor` with `#[serde(default)]` (default `Image`). - [ ] **Step 1: Confirm `KdfParams` ⟷ `params.json` is 1:1.** Grep where `params.json` is written/read in `crates/relicario-cli/src` and `backup.rs`; confirm it serializes `KdfParams`. If a wrapper struct is used instead, put `second_factor` there. Note the finding in a comment. - [ ] **Step 2: Write the failing test** ```rust #[test] fn params_default_second_factor_is_image_and_is_backcompat() { // Old params.json (no second_factor) must deserialize as Image. let old = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#; let p: KdfParams = serde_json::from_str(old).unwrap(); assert_eq!(p.second_factor, SecondFactor::Image); // New keyfile params round-trip. let kf = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1,"second_factor":"keyfile"}"#; let p2: KdfParams = serde_json::from_str(kf).unwrap(); assert_eq!(p2.second_factor, SecondFactor::Keyfile); } ``` - [ ] **Step 3: Run to verify it fails** Run: `cargo test -p relicario-core params_default_second_factor` Expected: FAIL — `SecondFactor` not found. - [ ] **Step 4: Implement** ```rust #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] pub enum SecondFactor { #[default] Image, Keyfile, } // in KdfParams: #[serde(default)] pub second_factor: SecondFactor, ``` - [ ] **Step 5: Run to verify it passes** Run: `cargo test -p relicario-core params_default_second_factor` then `cargo test -p relicario-core` (no regressions; check `format_v2`/backup tests still pass since params.json gained an optional field). Expected: PASS. - [ ] **Step 6: Commit** ```bash git add crates/relicario-core/src/crypto.rs git commit -m "feat(core): SecondFactor hint on KdfParams (default image, back-compat)" ``` --- ### Task 2: Key-file armor (`keyfile_encode` / `keyfile_decode`) **Files:** - Create: `crates/relicario-core/src/keyfile.rs` - Modify: `crates/relicario-core/src/lib.rs` (`pub mod keyfile;`) - Test: `crates/relicario-core/src/keyfile.rs` (`#[cfg(test)]`) **Interfaces:** - Produces: `pub fn keyfile_encode(secret: &[u8; 32]) -> Vec`; `pub fn keyfile_decode(bytes: &[u8]) -> Result>`. Format: literal line `relicario-keyfile-v1\n`, then base64 (standard, no-pad-agnostic) of the 32 bytes, then `\n`. - [ ] **Step 1: Write the failing test** ```rust #[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 } } ``` - [ ] **Step 2: Run to verify it fails** Run: `cargo test -p relicario-core keyfile` Expected: FAIL — module/functions not found. - [ ] **Step 3: Implement `keyfile.rs`** ```rust //! 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). use base64::{engine::general_purpose::STANDARD, Engine}; use zeroize::Zeroizing; use crate::error::{RelicarioError, Result}; const HEADER: &str = "relicario-keyfile-v1"; pub fn keyfile_encode(secret: &[u8; 32]) -> Vec { format!("{HEADER}\n{}\n", STANDARD.encode(secret)).into_bytes() } pub fn keyfile_decode(bytes: &[u8]) -> Result> { let text = std::str::from_utf8(bytes) .map_err(|_| RelicarioError::InvalidFormat("key file is not UTF-8".into()))?; let mut lines = text.lines(); if lines.next() != Some(HEADER) { return Err(RelicarioError::InvalidFormat("bad key-file header".into())); } let b64 = lines.next().unwrap_or("").trim(); let decoded = STANDARD.decode(b64) .map_err(|_| RelicarioError::InvalidFormat("key-file body not base64".into()))?; let arr: [u8; 32] = decoded.as_slice().try_into() .map_err(|_| RelicarioError::InvalidFormat("key-file secret must be 32 bytes".into()))?; Ok(Zeroizing::new(arr)) } ``` (Match the real `RelicarioError` variant — use the existing invalid-format/parse variant; `grep RelicarioError crates/relicario-core/src/error.rs` first.) - [ ] **Step 4: Run to verify it passes** Run: `cargo test -p relicario-core keyfile` Expected: PASS (all three). - [ ] **Step 5: Commit** ```bash git add crates/relicario-core/src/keyfile.rs crates/relicario-core/src/lib.rs git commit -m "feat(core): key-file armor (relicario-keyfile-v1) encode/decode" ``` --- ### Task 3: WASM bindings + master-key equivalence **Files:** - Modify: `crates/relicario-wasm/src/lib.rs` - Modify: `extension/src/wasm.d.ts` - Test: `crates/relicario-wasm/src/lib.rs` (`#[cfg(test)]`) **Interfaces:** - Produces (consumed by Plan 5): `keyfile_encode(secret: Uint8Array): Uint8Array`; `keyfile_decode(bytes: Uint8Array): Uint8Array`; `unlock_with_secret(passphrase: string, secret: Uint8Array, salt: Uint8Array, params_json: string): SessionHandle`. - [ ] **Step 1: Write the failing test** (equivalence: same secret ⇒ same master key, proven by cross-decrypt) ```rust #[test] fn unlock_with_secret_matches_unlock_from_jpeg() { let secret = [3u8; 32]; let salt = [1u8; 32]; let params = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#; let jpeg = relicario_core::imgsecret::embed(&make_test_jpeg(), &secret).unwrap(); let h_img = unlock(/*passphrase*/ "pw", &jpeg, &salt, params).unwrap(); let h_key = unlock_with_secret("pw", &secret, &salt, params).unwrap(); // Same key ⇒ a blob encrypted under one handle decrypts under the other. let ct = item_encrypt(&h_img, r#"{"id":"a","core":{"type":"SecureNote","body":"z"}}"#).unwrap(); let pt = item_decrypt(&h_key, &ct).unwrap(); assert!(format!("{pt:?}").contains("SecureNote")); } ``` - [ ] **Step 2: Run to verify it fails** Run: `cargo test -p relicario-wasm unlock_with_secret` Expected: FAIL — `unlock_with_secret` not found. - [ ] **Step 3: Implement** (mirror `unlock` at `lib.rs:49`, skipping `imgsecret::extract`) ```rust #[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)) } #[wasm_bindgen] pub fn keyfile_encode(secret: &[u8]) -> Result, JsError> { let arr: &[u8; 32] = secret.try_into().map_err(|_| JsError::new("secret must be 32 bytes"))?; Ok(relicario_core::keyfile::keyfile_encode(arr)) } #[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()) } ``` - [ ] **Step 4: Run to verify it passes** Run: `cargo test -p relicario-wasm` Expected: PASS. - [ ] **Step 5: Declare in `wasm.d.ts` + build** Add to `extension/src/wasm.d.ts`: ```ts export function keyfile_encode(secret: Uint8Array): Uint8Array; export function keyfile_decode(bytes: Uint8Array): Uint8Array; export function unlock_with_secret(passphrase: string, secret: Uint8Array, salt: Uint8Array, params_json: string): SessionHandle; ``` Run: `cargo build -p relicario-wasm --target wasm32-unknown-unknown` + the project wasm-pack step. - [ ] **Step 6: Commit** ```bash git add crates/relicario-wasm/src/lib.rs extension/src/wasm.d.ts git commit -m "feat(wasm): unlock_with_secret + keyfile encode/decode bindings" ``` --- ### Task 4: CLI unlock branches on the params hint **Files:** - Modify: `crates/relicario-cli/src/session.rs` (`get_keyfile_path`, `unlock_interactive`) - Test: `crates/relicario-cli/tests/keyfile_flows.rs` *(new)* **Interfaces:** - Consumes: `keyfile_decode`, `derive_master_key`, `KdfParams.second_factor`. - Produces: `pub fn get_keyfile_path() -> Result` (mirrors `get_image_path` at `session.rs:165`: `RELICARIO_KEYFILE` env → `/vault.relkey` convention → interactive prompt). `unlock_interactive` reads `second_factor` from params and resolves the image OR the key file accordingly. - [ ] **Step 1: Write the failing integration test** ```rust #[test] fn init_keyfile_then_unlock_keyfile_round_trips() { let dir = tempfile::tempdir().unwrap(); // init with a key file (Task 5 wires the flag; here drive it once that lands) relicario(&dir).args(["init", "--key-file", "vault.relkey"]).env("RELICARIO_PASSPHRASE","correct horse").assert().success(); // unlock + add + get using the key file relicario(&dir) .args(["add", "login", "--title", "gh", "--username", "u", "--password", "p"]) .env("RELICARIO_PASSPHRASE","correct horse") .env("RELICARIO_KEYFILE", dir.path().join("vault.relkey")) .assert().success(); relicario(&dir).args(["get","gh","--show"]) .env("RELICARIO_PASSPHRASE","correct horse") .env("RELICARIO_KEYFILE", dir.path().join("vault.relkey")) .assert().success().stdout(predicates::str::contains("u")); } ``` - [ ] **Step 2: Run to verify it fails** Run: `cargo test -p relicario-cli --test keyfile_flows` Expected: FAIL — `--key-file` unknown / `RELICARIO_KEYFILE` ignored. - [ ] **Step 3: Implement `get_keyfile_path` + the unlock branch** ```rust pub fn get_keyfile_path() -> Result { if let Ok(path) = std::env::var("RELICARIO_KEYFILE") { return Ok(PathBuf::from(path)); } if let Some(root) = find_vault_root() { // mirror get_image_path's convention block let default = root.join("vault.relkey"); if default.exists() { return Ok(default); } } let trimmed = prompt("key file path: ")?; if trimmed.is_empty() { bail!("no key file path provided"); } Ok(PathBuf::from(trimmed)) } ``` In `unlock_interactive` (`session.rs:33`): after loading `params`, branch — `SecondFactor::Image` keeps today's `get_image_path` + `imgsecret::extract`; `SecondFactor::Keyfile` does `keyfile_decode(fs::read(get_keyfile_path()?)?)` → `derive_master_key(passphrase, &secret, &salt, ¶ms)`. Map a missing/garbled key file to a clear `invalid_key_file` error distinct from the wrong-passphrase AEAD failure. - [ ] **Step 4: Run to verify it passes** (after Task 5 wires `init --key-file`; if executing in order, mark this test `#[ignore]` until Task 5, then un-ignore) Run: `cargo test -p relicario-cli --test keyfile_flows` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add crates/relicario-cli/src/session.rs crates/relicario-cli/tests/keyfile_flows.rs git commit -m "feat(cli): unlock resolves second factor from params hint (image|keyfile)" ``` --- ### Task 5: CLI `init --key-file` **Files:** - Modify: the `init` command handler (locate: `grep -rn 'Init\|fn.*init' crates/relicario-cli/src/`), `crates/relicario-cli/src/main.rs` (clap flag) - Test: `crates/relicario-cli/tests/keyfile_flows.rs` (un-ignore Task 4's test) **Interfaces:** - Consumes: `keyfile_encode`; `KdfParams { second_factor: Keyfile, .. }`. - Produces: `relicario init --key-file ` — generates the 32-byte secret with `OsRng`, writes `keyfile_encode(secret)` to ``, derives the master key from passphrase+secret, and writes `params.json` with `second_factor: "keyfile"`. The existing `--image`/`--output` path stays the default and writes `second_factor: "image"`. - [ ] **Step 1: Write the failing test** — un-ignore `init_keyfile_then_unlock_keyfile_round_trips` and add: ```rust #[test] fn init_keyfile_writes_relkey_and_keyfile_params() { let dir = tempfile::tempdir().unwrap(); relicario(&dir).args(["init","--key-file","vault.relkey"]).env("RELICARIO_PASSPHRASE","correct horse").assert().success(); assert!(dir.path().join("vault.relkey").exists()); let params = std::fs::read_to_string(dir.path().join(".relicario/params.json")).unwrap(); assert!(params.contains("\"second_factor\":\"keyfile\"")); } ``` - [ ] **Step 2: Run to verify it fails** Run: `cargo test -p relicario-cli --test keyfile_flows init_keyfile_writes` Expected: FAIL — `--key-file` not a known arg. - [ ] **Step 3: Implement** — add `#[arg(long, conflicts_with = "image")] key_file: Option` to the init args in `main.rs`; in the init handler, when `key_file` is set: `let secret: [u8;32] = OsRng.gen();` → `fs::write(path, keyfile_encode(&secret))` → derive master key from `&secret` → set `KdfParams { second_factor: SecondFactor::Keyfile, ..default }` before writing `params.json`. Reuse the existing init crypto/write path otherwise. - [ ] **Step 4: Run to verify it passes** Run: `cargo test -p relicario-cli --test keyfile_flows` Expected: PASS (both tests). - [ ] **Step 5: Full suite + commit** Run: `cargo test` (workspace) — confirm no personal-vault init/unlock regressions. ```bash git add crates/relicario-cli/src/main.rs crates/relicario-cli/src/commands/ crates/relicario-cli/tests/keyfile_flows.rs git commit -m "feat(cli): init --key-file generates a .relkey second factor" ``` --- ## Hand-off contract (consumed by Plan 5: extension + positioning) - Core: `keyfile_encode(&[u8;32]) -> Vec`, `keyfile_decode(&[u8]) -> Result>`, `SecondFactor { Image, Keyfile }` on `KdfParams` (absent ⇒ `Image`). - WASM / `wasm.d.ts`: `keyfile_encode(Uint8Array): Uint8Array`, `keyfile_decode(Uint8Array): Uint8Array`, `unlock_with_secret(passphrase, secret, salt, params_json): SessionHandle`. - Armor: `relicario-keyfile-v1\n` + base64(32 bytes) + `\n`; extension `.relkey`. - `params.json` carries `"second_factor": "image" | "keyfile"`. Plan 5's setup wizard writes the hint and its unlock reads it to choose the image picker vs the key-file picker, calling `unlock_with_secret` for the key-file path.