feat(cli): relicario init creates a format-v2 vault

Prompts for a strong passphrase (zxcvbn gate via core), generates a
32-byte image secret, embeds it in the carrier JPEG, writes the
standard vault skeleton, and makes an initial git commit via the
hardened git_command helper.
This commit is contained in:
adlee-was-taken
2026-04-19 22:02:53 -04:00
parent 15e6ed9c75
commit a50099a066

View File

@@ -7,7 +7,7 @@ mod session;
use std::path::PathBuf;
use anyhow::{bail, Result};
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
#[derive(Parser)]
@@ -256,7 +256,94 @@ fn main() -> Result<()> {
}
}
fn cmd_init(_image: PathBuf, _output: PathBuf) -> Result<()> { bail!("not yet implemented"); }
fn cmd_init(image: PathBuf, output: 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,
};
use zeroize::Zeroizing;
let root = std::env::current_dir()?;
let relicario_dir = root.join(".relicario");
if relicario_dir.exists() {
anyhow::bail!(".relicario/ already exists in {}", root.display());
}
// Passphrase with strength gate (audit H3).
let passphrase = Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?);
let confirm = Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?);
if passphrase.as_str() != confirm.as_str() {
anyhow::bail!("passphrases do not match");
}
if let Err(e) = validate_passphrase_strength(&passphrase) {
anyhow::bail!("{}. Choose a longer or more entropic phrase.", e);
}
// Image secret: 32 random bytes, embedded in the carrier.
let mut image_secret = [0u8; 32];
OsRng.fill_bytes(&mut image_secret);
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()))?;
// Vault salt + KDF params.
let mut salt = [0u8; 32];
OsRng.fill_bytes(&mut salt);
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)?;
fs::create_dir_all(&relicario_dir)?;
fs::create_dir_all(root.join("items"))?;
fs::create_dir_all(root.join("attachments"))?;
fs::write(relicario_dir.join("salt"), salt)?;
fs::write(
relicario_dir.join("params.json"),
serde_json::to_string_pretty(&ParamsFile {
format_version: 2,
kdf: ParamsKdf {
algorithm: "argon2id-v0x13".into(),
argon2_m: params.argon2_m,
argon2_t: params.argon2_t,
argon2_p: params.argon2_p,
},
aead: "xchacha20poly1305".into(),
salt_path: ".relicario/salt".into(),
})?,
)?;
fs::write(relicario_dir.join("devices.json"), b"[]")?;
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 gitignore = format!("{}\n", output.file_name().unwrap().to_string_lossy());
fs::write(root.join(".gitignore"), gitignore)?;
// git init + initial commit via hardened wrapper.
let status = crate::helpers::git_command(&root, &["init"]).status()?;
if !status.success() { anyhow::bail!("git init failed"); }
let _ = crate::helpers::git_command(&root, &[
"add", ".gitignore", ".relicario/params.json", ".relicario/devices.json",
"manifest.enc", "settings.enc",
]).status()?;
let status = crate::helpers::git_command(&root, &[
"commit", "-m", "init: new relicario vault (format v2)",
]).status()?;
if !status.success() { anyhow::bail!("git commit failed"); }
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.");
Ok(())
}
fn cmd_add(_kind: AddKind) -> Result<()> { bail!("not yet implemented"); }
fn cmd_get(_query: String, _show: bool, _copy: bool) -> Result<()> { bail!("not yet implemented"); }
fn cmd_list(_t: Option<String>, _g: Option<String>, _tag: Option<String>, _trashed: bool) -> Result<()> { bail!("not yet implemented"); }
@@ -272,3 +359,20 @@ fn cmd_generate(_l: u32, _b: bool, _w: u32, _s: String, _sep: String) -> Result<
fn cmd_settings(_a: SettingsAction) -> Result<()> { bail!("not yet implemented"); }
fn cmd_sync() -> Result<()> { bail!("not yet implemented"); }
fn cmd_device(_a: DeviceAction) -> Result<()> { bail!("not yet implemented"); }
#[derive(serde::Serialize)]
struct ParamsFile {
format_version: u32,
kdf: ParamsKdf,
aead: String,
salt_path: String,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "snake_case")]
struct ParamsKdf {
algorithm: String,
argon2_m: u32,
argon2_t: u32,
argon2_p: u32,
}