# 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<u8>`; `pub fn keyfile_decode(bytes: &[u8]) -> Result<Zeroizing<[u8; 32]>>`. 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<u8> {
    format!("{HEADER}\n{}\n", STANDARD.encode(secret)).into_bytes()
}

pub fn keyfile_decode(bytes: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
    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<SessionHandle, JsError> {
    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, &params)
        .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<Vec<u8>, 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<Vec<u8>, 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<PathBuf>` (mirrors `get_image_path` at `session.rs:165`: `RELICARIO_KEYFILE` env → `<vault_root>/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<PathBuf> {
    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, &params)`. 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 <path>` — generates the 32-byte secret with `OsRng`, writes `keyfile_encode(secret)` to `<path>`, 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<PathBuf>` 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<u8>`, `keyfile_decode(&[u8]) -> Result<Zeroizing<[u8;32]>>`, `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.
