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
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.jsonsecond_factoris non-secret and defaults toimagewhen 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— addSecondFactorenum +second_factorfield onKdfParams(the struct serialized toparams.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(); branchunlock_interactiveon the params hint.crates/relicario-cli/src/commands/init.rs(or whereverinitlives — locate first) —--key-filepath.crates/relicario-cli/src/main.rs— clap--key-fileflag oninit(andRELICARIO_KEYFILEdoc in help).- Tests: core unit tests in
keyfile.rs+crypto.rs;crates/relicario-wasmequivalence 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 }(serderename_all = "lowercase");KdfParams.second_factor: SecondFactorwith#[serde(default)](defaultImage). -
Step 1: Confirm
KdfParams⟷params.jsonis 1:1. Grep whereparams.jsonis written/read incrates/relicario-cli/srcandbackup.rs; confirm it serializesKdfParams. If a wrapper struct is used instead, putsecond_factorthere. 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 linerelicario-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
unlockatlib.rs:49, skippingimgsecret::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, ¶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<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>(mirrorsget_image_pathatsession.rs:165:RELICARIO_KEYFILEenv →<vault_root>/vault.relkeyconvention → interactive prompt).unlock_interactivereadssecond_factorfrom 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, ¶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
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
initcommand 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 withOsRng, writeskeyfile_encode(secret)to<path>, derives the master key from passphrase+secret, and writesparams.jsonwithsecond_factor: "keyfile". The existing--image/--outputpath stays the default and writessecond_factor: "image". -
Step 1: Write the failing test — un-ignore
init_keyfile_then_unlock_keyfile_round_tripsand 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 inmain.rs; in the init handler, whenkey_fileis set:let secret: [u8;32] = OsRng.gen();→fs::write(path, keyfile_encode(&secret))→ derive master key from&secret→ setKdfParams { second_factor: SecondFactor::Keyfile, ..default }before writingparams.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 }onKdfParams(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.jsoncarries"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, callingunlock_with_secretfor the key-file path.