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(())
}