feat(cli): init --key-file generates a .relkey second factor

Add `--key-file <path>` 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<PathBuf>;
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG
This commit is contained in:
adlee-was-taken
2026-06-25 23:32:41 -04:00
parent e8f6635188
commit 2daa3502e9
4 changed files with 112 additions and 30 deletions

View File

@@ -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<PathBuf>, output: PathBuf, key_file: Option<PathBuf>) -> 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 <carrier> (with --output) or --key-file <path>"
)
}
};
// 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, &params)?;
let master_key = derive_master_key(passphrase.as_bytes(), &secret, &salt, &params)?;
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(&params))?,
serde_json::to_string_pretty(&crate::session::ParamsFile::for_new_vault(
&params,
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(())
}

View File

@@ -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<PathBuf>,
/// 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<PathBuf>,
},
/// 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),

View File

@@ -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(&params);
let pf = ParamsFile::for_new_vault(&params, 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");

View File

@@ -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}"
);
}