Files
relicario/docs/superpowers/plans/2026-06-20-v0.9.0-keyfile-core-cli.md
adlee-was-taken 74cee8ac67 docs(plans): v0.9.0 implementation plans — 5 streams across 2 specs
Full-TDD per-stream plans for the v0.9.0 multi-agent train:
- org-a-foundation (A0+A1): WASM org_unwrap_key + multi-context SW session +
  org config + grant-filtered manifest read.
- org-b-read-ui (A2): org switcher + grant-filtered browse/read + offline banner.
- org-c-write (A3): GO/NO-GO signing spike first, then commitSigned + org write
  handlers + UI. Spike-gated; NO-GO ships read-only.
- keyfile-core-cli (B1+B2): core armor + unlock_with_secret + params hint +
  WASM bindings + CLI init/unlock --key-file.
- keyfile-ext-positioning (B3+B4): setup container choice + unlock + the
  README/DESIGN/CRYPTO/FORMATS positioning pivot.

Cross-plan contracts pinned and self-reviewed for consistency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQbgrP6KQW5pibjbPEoTSs
2026-06-21 09:35:44 -04:00

18 KiB

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.rspub 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.rsget_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 KdfParamsparams.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

#[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
#[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
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

#[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
//! 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
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)

#[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)
#[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:

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
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

#[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
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
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:

#[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.

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.