From 2daa3502e985b9205af2a2ec52f0f9bd52fd5028 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 23:32:41 -0400 Subject: [PATCH] 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}" + ); }