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:
@@ -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, ¶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(())
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user