refactor(cli): move cmd_init into commands/init.rs (carries inline ParamsFile)
This commit is contained in:
125
crates/relicario-cli/src/commands/init.rs
Normal file
125
crates/relicario-cli/src/commands/init.rs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
//! `relicario init` — bootstrap a fresh vault in the current directory.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
pub 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).
|
||||||
|
// RELICARIO_TEST_PASSPHRASE is a test-only escape hatch that bypasses the
|
||||||
|
// TTY prompt so integration tests can run without a real TTY.
|
||||||
|
let passphrase = if let Some(p) = crate::test_passphrase_override() {
|
||||||
|
Zeroizing::new(p)
|
||||||
|
} else {
|
||||||
|
Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?)
|
||||||
|
};
|
||||||
|
let confirm = if crate::test_passphrase_override().is_some() {
|
||||||
|
passphrase.clone()
|
||||||
|
} else {
|
||||||
|
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 image_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()))?;
|
||||||
|
|
||||||
|
// 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, ¶ms)?;
|
||||||
|
|
||||||
|
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(),
|
||||||
|
})?,
|
||||||
|
)?;
|
||||||
|
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");
|
||||||
|
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/salt", "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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
@@ -7,4 +7,5 @@
|
|||||||
//! `use crate::commands::*`.
|
//! `use crate::commands::*`.
|
||||||
|
|
||||||
pub mod generate;
|
pub mod generate;
|
||||||
|
pub mod init;
|
||||||
pub mod rate;
|
pub mod rate;
|
||||||
|
|||||||
@@ -426,7 +426,7 @@ enum RecoveryQrCmd {
|
|||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::Init { image, output } => cmd_init(image, output),
|
Commands::Init { image, output } => commands::init::cmd_init(image, output),
|
||||||
Commands::Add { kind } => cmd_add(kind),
|
Commands::Add { kind } => cmd_add(kind),
|
||||||
Commands::Get { query, show, copy } => cmd_get(query, show, copy),
|
Commands::Get { query, show, copy } => cmd_get(query, show, copy),
|
||||||
Commands::List { r#type, group, tag, trashed } => cmd_list(r#type, group, tag, trashed),
|
Commands::List { r#type, group, tag, trashed } => cmd_list(r#type, group, tag, trashed),
|
||||||
@@ -508,108 +508,6 @@ pub(crate) fn test_backup_passphrase_override() -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
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).
|
|
||||||
// RELICARIO_TEST_PASSPHRASE is a test-only escape hatch that bypasses the
|
|
||||||
// TTY prompt so integration tests can run without a real TTY.
|
|
||||||
let passphrase = if let Some(p) = test_passphrase_override() {
|
|
||||||
Zeroizing::new(p)
|
|
||||||
} else {
|
|
||||||
Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?)
|
|
||||||
};
|
|
||||||
let confirm = if test_passphrase_override().is_some() {
|
|
||||||
passphrase.clone()
|
|
||||||
} else {
|
|
||||||
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 image_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()))?;
|
|
||||||
|
|
||||||
// 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, ¶ms)?;
|
|
||||||
|
|
||||||
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(),
|
|
||||||
})?,
|
|
||||||
)?;
|
|
||||||
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");
|
|
||||||
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/salt", "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<()> {
|
fn cmd_add(kind: AddKind) -> Result<()> {
|
||||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
let mut manifest = vault.load_manifest()?;
|
let mut manifest = vault.load_manifest()?;
|
||||||
@@ -2129,22 +2027,6 @@ fn cmd_status() -> Result<()> {
|
|||||||
println!("Last export: {last_backup_str}");
|
println!("Last export: {last_backup_str}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
#[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,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Device management ─────────────────────────────────────────────────────────
|
// ── Device management ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user