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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user