Files
relicario/crates/relicario-cli/src/main.rs

1265 lines
47 KiB
Rust

//! relicario CLI — the platform layer for the relicario password manager.
//!
//! See module docs for the unlock flow and vault layout.
mod helpers;
mod session;
use std::path::PathBuf;
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(
name = "relicario",
version,
about = "Git-backed password manager with reference-image two-factor unlock"
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Initialize a new vault in the current directory.
Init {
/// Carrier JPEG to embed the secret into.
#[arg(long)]
image: PathBuf,
/// Output path for the reference image (gitignored).
#[arg(long, default_value = "reference.jpg")]
output: PathBuf,
},
/// Add a new item. Type-specific flags populate the core; missing fields
/// are prompted for interactively.
Add {
#[command(subcommand)]
kind: AddKind,
},
/// Print an item. Secrets are masked by default; pass --show to reveal.
Get {
/// Item id or case-insensitive title substring.
query: String,
/// Print secret field values in plaintext.
#[arg(long)]
show: bool,
/// Copy the primary secret (Login.password, Card.number, etc.) to clipboard.
#[arg(long)]
copy: bool,
},
/// List items.
List {
#[arg(long)]
r#type: Option<String>,
#[arg(long)]
group: Option<String>,
#[arg(long)]
tag: Option<String>,
#[arg(long)]
trashed: bool,
},
/// Edit an item interactively.
Edit { query: String },
/// Soft-delete an item (moves to trash; reversible via `restore`).
Rm { query: String },
/// Restore a soft-deleted item.
Restore { query: String },
/// Permanently purge an item (and its attachments).
Purge { query: String },
/// Trash operations.
Trash {
#[command(subcommand)]
action: TrashAction,
},
/// Attach a file to an item.
Attach { query: String, file: PathBuf },
/// List attachments on an item.
Attachments { query: String },
/// Extract an attachment to disk.
Extract {
query: String,
aid: String,
#[arg(long)]
out: Option<PathBuf>,
},
/// Generate a password or passphrase.
Generate {
#[arg(long, default_value_t = 20)]
length: u32,
#[arg(long)]
bip39: bool,
#[arg(long, default_value_t = 5)]
words: u32,
#[arg(long, default_value = "safe")]
symbols: String,
/// Separator for BIP39 words.
#[arg(long, default_value = " ")]
separator: String,
},
/// View or change vault settings.
Settings {
#[command(subcommand)]
action: SettingsAction,
},
/// Sync with the git remote (pull --rebase + push).
Sync,
/// Device management.
Device {
#[command(subcommand)]
action: DeviceAction,
},
/// Lock the vault (no-op in CLI; present for UX parity with the extension).
Lock,
}
#[derive(Subcommand)]
enum AddKind {
Login {
#[arg(long)] title: Option<String>,
#[arg(long)] username: Option<String>,
#[arg(long)] url: Option<String>,
/// Prompt for password (vs reading from stdin or --password).
#[arg(long)] password_prompt: bool,
#[arg(long)] password: Option<String>,
#[arg(long)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] favorite: bool,
},
SecureNote {
#[arg(long)] title: Option<String>,
#[arg(long)] body_prompt: bool,
#[arg(long)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
},
Identity {
#[arg(long)] title: Option<String>,
#[arg(long)] full_name: Option<String>,
#[arg(long)] email: Option<String>,
#[arg(long)] phone: Option<String>,
#[arg(long)] date_of_birth: Option<String>,
#[arg(long)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
},
Card {
#[arg(long)] title: Option<String>,
#[arg(long)] holder: Option<String>,
#[arg(long)] expiry: Option<String>, // MM/YYYY
#[arg(long, default_value = "credit")] kind: String,
#[arg(long)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
},
Key {
#[arg(long)] title: Option<String>,
#[arg(long)] label: Option<String>,
#[arg(long)] algorithm: Option<String>,
#[arg(long)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
},
Document {
#[arg(long)] title: Option<String>,
#[arg(long)] file: PathBuf,
#[arg(long)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
},
Totp {
#[arg(long)] title: Option<String>,
#[arg(long)] issuer: Option<String>,
#[arg(long)] label: Option<String>,
#[arg(long)] secret: Option<String>, // base32
#[arg(long, default_value = "30")] period: u32,
#[arg(long, default_value = "6")] digits: u8,
#[arg(long, default_value = "sha1")] algorithm: String,
#[arg(long)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
},
}
#[derive(Subcommand)]
enum TrashAction {
/// List trashed items.
List,
/// Purge every trashed item past its retention window.
Empty,
}
#[derive(Subcommand)]
enum SettingsAction {
/// Show current settings as JSON.
Show,
/// Set trash retention (e.g., --days 30 or --forever).
TrashRetention {
#[arg(long)] days: Option<u32>,
#[arg(long)] forever: bool,
},
/// Set field history retention.
HistoryRetention {
#[arg(long)] last_n: Option<u32>,
#[arg(long)] days: Option<u32>,
#[arg(long)] forever: bool,
},
/// Set per-attachment max size in bytes.
AttachmentCap {
#[arg(long)] per_attachment_max_bytes: Option<u64>,
#[arg(long)] per_item_max_count: Option<u32>,
#[arg(long)] per_vault_soft_cap_bytes: Option<u64>,
#[arg(long)] per_vault_hard_cap_bytes: Option<u64>,
},
}
#[derive(Subcommand)]
enum DeviceAction {
Add { #[arg(long)] name: String },
List,
Revoke { name: String },
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Init { image, output } => cmd_init(image, output),
Commands::Add { kind } => cmd_add(kind),
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::Edit { query } => cmd_edit(query),
Commands::Rm { query } => cmd_rm(query),
Commands::Restore { query } => cmd_restore(query),
Commands::Purge { query } => cmd_purge(query),
Commands::Trash { action } => cmd_trash(action),
Commands::Attach { query, file } => cmd_attach(query, file),
Commands::Attachments { query } => cmd_attachments(query),
Commands::Extract { query, aid, out } => cmd_extract(query, aid, out),
Commands::Generate { length, bip39, words, symbols, separator } => {
cmd_generate(length, bip39, words, symbols, separator)
}
Commands::Settings { action } => cmd_settings(action),
Commands::Sync => cmd_sync(),
Commands::Device { action } => cmd_device(action),
Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) }
}
}
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 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, &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 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/devices.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<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let item = match kind {
AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite } => {
use relicario_core::item_types::LoginCore;
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
let username = username.or_else(|| prompt_optional("Username").ok().flatten());
let url = url.or_else(|| prompt_optional("URL").ok().flatten());
let parsed_url = match url {
Some(s) => Some(url::Url::parse(&s)
.with_context(|| format!("invalid URL: {s}"))?),
None => None,
};
let password = if let Some(p) = password {
Some(Zeroizing::new(p))
} else if password_prompt {
Some(Zeroizing::new(rpassword::prompt_password("Password: ")?))
} else {
None
};
let core = ItemCore::Login(LoginCore {
username,
password,
url: parsed_url,
totp: None,
});
let mut item = Item::new(title, core);
item.group = group;
item.tags = tags;
item.favorite = favorite;
item
}
AddKind::SecureNote { title, body_prompt, group, tags } => {
use relicario_core::item_types::SecureNoteCore;
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
let body = if body_prompt {
eprintln!("Enter note body; end with Ctrl-D on a blank line:");
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
s
} else {
prompt("Body")?
};
let mut item = Item::new(title, ItemCore::SecureNote(SecureNoteCore {
body: Zeroizing::new(body),
}));
item.group = group;
item.tags = tags;
item
}
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } => {
use relicario_core::item_types::IdentityCore;
use relicario_core::{Item, ItemCore};
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
let dob = match date_of_birth {
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
None => None,
};
let mut item = Item::new(title, ItemCore::Identity(IdentityCore {
full_name,
address: None,
phone,
email,
date_of_birth: dob,
}));
item.group = group;
item.tags = tags;
item
}
AddKind::Card { title, holder, expiry, kind, group, tags } => {
use relicario_core::item_types::{CardCore, CardKind};
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
let number = Zeroizing::new(rpassword::prompt_password("Card number: ")?);
let cvv = Zeroizing::new(rpassword::prompt_password("CVV (blank to skip): ")?);
let cvv = if cvv.is_empty() { None } else { Some(cvv) };
let pin = Zeroizing::new(rpassword::prompt_password("PIN (blank to skip): ")?);
let pin = if pin.is_empty() { None } else { Some(pin) };
let parsed_expiry = match expiry {
Some(s) => Some(parse_month_year(&s)?),
None => None,
};
let parsed_kind = match kind.as_str() {
"credit" => CardKind::Credit,
"debit" => CardKind::Debit,
"gift" => CardKind::Gift,
"loyalty" => CardKind::Loyalty,
"other" => CardKind::Other,
other => anyhow::bail!("unknown card kind: {other}"),
};
let mut item = Item::new(title, ItemCore::Card(CardCore {
number: Some(number),
holder,
expiry: parsed_expiry,
cvv,
pin,
kind: parsed_kind,
}));
item.group = group;
item.tags = tags;
item
}
AddKind::Key { title, label, algorithm, group, tags } => {
use relicario_core::item_types::KeyCore;
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
eprintln!("Paste key material; end with Ctrl-D on a blank line:");
let mut key_material = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?;
if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
let public_key = prompt_optional("Public key (blank to skip)")?;
let mut item = Item::new(title, ItemCore::Key(KeyCore {
key_material: Zeroizing::new(key_material),
label,
public_key,
algorithm,
}));
item.group = group;
item.tags = tags;
item
}
AddKind::Document { title, file, group, tags } => {
use relicario_core::item_types::DocumentCore;
use relicario_core::{
encrypt_attachment, AttachmentRef, Item, ItemCore,
};
use std::fs;
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
let bytes = fs::read(&file)
.with_context(|| format!("failed to read {}", file.display()))?;
let caps = vault.load_settings()?.attachment_caps;
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
let filename = file.file_name()
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
.to_string_lossy()
.into_owned();
let mime_type = guess_mime(&filename);
let primary_attachment = enc.id.clone();
let mut item = Item::new(title, ItemCore::Document(DocumentCore {
filename: filename.clone(),
mime_type: mime_type.clone(),
primary_attachment: primary_attachment.clone(),
}));
item.group = group;
item.tags = tags;
item.attachments.push(AttachmentRef {
id: primary_attachment.clone(),
filename,
mime_type,
size: bytes.len() as u64,
created: item.created,
});
// Persist the attachment blob before we return from the match arm.
let att_dir = vault.root().join("attachments").join(item.id.as_str());
fs::create_dir_all(&att_dir)?;
fs::write(att_dir.join(format!("{}.enc", primary_attachment.as_str())), &enc.bytes)?;
item
}
AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } => {
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpCore, TotpKind};
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
let secret_b32 = match secret {
Some(s) => s,
None => rpassword::prompt_password("TOTP secret (base32): ")?,
};
let secret_bytes = base32_decode_lenient(&secret_b32)?;
let algo = match algorithm.to_ascii_lowercase().as_str() {
"sha1" => TotpAlgorithm::Sha1,
"sha256" => TotpAlgorithm::Sha256,
"sha512" => TotpAlgorithm::Sha512,
other => anyhow::bail!("unknown algorithm: {other}"),
};
let core = TotpCore {
config: TotpConfig {
secret: Zeroizing::new(secret_bytes),
algorithm: algo,
digits,
period_seconds: period,
kind: TotpKind::Totp,
},
issuer,
label,
};
let mut item = Item::new(title, ItemCore::Totp(core));
item.group = group;
item.tags = tags;
item
}
};
vault.save_item(&item)?;
manifest.upsert(&item);
vault.save_manifest(&manifest)?;
let mut paths: Vec<String> = vec![
format!("items/{}.enc", item.id.as_str()),
"manifest.enc".into(),
];
for att in &item.attachments {
paths.push(format!("attachments/{}/{}.enc", item.id.as_str(), att.id.as_str()));
}
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
commit_paths(&vault, &format!("add: {} ({})", item.title, item.id.as_str()), &path_refs)?;
eprintln!("Added: {} (id={})", item.title, item.id.as_str());
Ok(())
}
fn prompt(label: &str) -> Result<String> {
eprint!("{label}: ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
let trimmed = s.trim().to_string();
if trimmed.is_empty() { anyhow::bail!("{label} required"); }
Ok(trimmed)
}
fn prompt_optional(label: &str) -> Result<Option<String>> {
eprint!("{label} (leave blank to skip): ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
let trimmed = s.trim().to_string();
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
}
fn parse_month_year(s: &str) -> Result<relicario_core::MonthYear> {
// Accepts MM/YYYY or MM-YYYY or MM/YY.
let (m_str, y_str) = s.split_once(|c: char| c == '/' || c == '-')
.ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?;
let month: u8 = m_str.parse().context("invalid month")?;
let year: u16 = if y_str.len() == 2 {
2000 + y_str.parse::<u16>().context("invalid 2-digit year")?
} else {
y_str.parse().context("invalid year")?
};
Ok(relicario_core::MonthYear { month, year })
}
fn guess_mime(filename: &str) -> String {
let lower = filename.to_ascii_lowercase();
match lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or("") {
"pdf" => "application/pdf",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"txt" => "text/plain",
"json" => "application/json",
_ => "application/octet-stream",
}.to_string()
}
fn base32_decode_lenient(s: &str) -> Result<Vec<u8>> {
let cleaned: String = s.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>()
.to_ascii_uppercase()
.trim_end_matches('=')
.to_string();
let padded = {
let rem = cleaned.len() % 8;
if rem == 0 { cleaned } else { format!("{}{}", cleaned, "=".repeat(8 - rem)) }
};
data_encoding::BASE32.decode(padded.as_bytes())
.map_err(|e| anyhow::anyhow!("invalid base32: {e}"))
}
fn commit_paths(vault: &crate::session::UnlockedVault, message: &str, paths: &[&str]) -> Result<()> {
let mut args: Vec<&str> = vec!["add"];
args.extend_from_slice(paths);
let status = crate::helpers::git_command(vault.root(), &args).status()?;
if !status.success() { anyhow::bail!("git add failed"); }
let status = crate::helpers::git_command(vault.root(), &["commit", "-m", message]).status()?;
if !status.success() { anyhow::bail!("git commit failed"); }
Ok(())
}
fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
use relicario_core::ItemCore;
use zeroize::Zeroizing;
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let manifest = vault.load_manifest()?;
let entry = resolve_query(&manifest, &query)?;
let item = vault.load_item(&entry.id)?;
println!("ID: {}", item.id.as_str());
println!("Title: {}", item.title);
println!("Type: {:?}", item.r#type);
if let Some(g) = &item.group { println!("Group: {g}"); }
if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); }
println!("Created: {}", crate::helpers::iso8601(item.created));
println!("Modified: {}", crate::helpers::iso8601(item.modified));
if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); }
println!();
let primary_secret: Option<Zeroizing<String>> = match &item.core {
ItemCore::Login(l) => {
if let Some(u) = &l.username { println!("Username: {u}"); }
if let Some(u) = &l.url { println!("URL: {u}"); }
if let Some(p) = &l.password { Some(p.clone()) } else { None }
}
ItemCore::SecureNote(n) => {
if show { println!("Body:\n{}", n.body.as_str()); }
else { println!("Body: ********"); }
None
}
ItemCore::Identity(i) => {
if let Some(v) = &i.full_name { println!("Name: {v}"); }
if let Some(v) = &i.email { println!("Email: {v}"); }
if let Some(v) = &i.phone { println!("Phone: {v}"); }
if let Some(v) = &i.date_of_birth { println!("DOB: {v}"); }
None
}
ItemCore::Card(c) => {
if let Some(h) = &c.holder { println!("Holder: {h}"); }
if let Some(e) = &c.expiry { println!("Expiry: {:02}/{}", e.month, e.year); }
println!("Kind: {:?}", c.kind);
c.number.clone()
}
ItemCore::Key(k) => {
if let Some(l) = &k.label { println!("Label: {l}"); }
if let Some(a) = &k.algorithm { println!("Algo: {a}"); }
if let Some(pk) = &k.public_key { println!("Pubkey: {pk}"); }
Some(k.key_material.clone())
}
ItemCore::Document(d) => {
println!("Filename: {}", d.filename);
println!("MIME: {}", d.mime_type);
None
}
ItemCore::Totp(t) => {
if let Some(i) = &t.issuer { println!("Issuer: {i}"); }
if let Some(l) = &t.label { println!("Label: {l}"); }
println!("Period: {}s", t.config.period_seconds);
println!("Digits: {}", t.config.digits);
None
}
};
if let Some(secret) = primary_secret {
if show {
println!("Secret: {}", secret.as_str());
} else {
println!("Secret: ******** (use --show to reveal, --copy to clipboard)");
}
if copy {
copy_to_clipboard_then_clear(&secret)?;
eprintln!("Copied to clipboard (auto-clears in 30s).");
}
}
Ok(())
}
fn resolve_query<'a>(
manifest: &'a relicario_core::Manifest,
query: &str,
) -> Result<&'a relicario_core::ManifestEntry> {
if let Some(entry) = manifest.items.values().find(|e| e.id.as_str() == query) {
return Ok(entry);
}
let hits: Vec<_> = manifest.search(query);
match hits.len() {
0 => anyhow::bail!("no item matches `{query}`"),
1 => Ok(hits[0]),
_ => {
let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect();
anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", "))
}
}
}
fn copy_to_clipboard_then_clear(secret: &zeroize::Zeroizing<String>) -> Result<()> {
use arboard::Clipboard;
let mut cb = Clipboard::new().context("failed to access clipboard")?;
cb.set_text(secret.as_str().to_string()).context("failed to write clipboard")?;
let cleared_copy = zeroize::Zeroizing::new(secret.as_str().to_owned());
// Unconditional clear (audit M6): spawn a detached thread that waits 30s
// and then rewrites the clipboard with empty string. Even if the user
// copies something else in the interim, we still overwrite once.
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(30));
if let Ok(mut cb) = Clipboard::new() {
let _ = cb.set_text(String::new());
drop(cleared_copy); // zeroize the detached copy
}
});
Ok(())
}
fn cmd_list(
type_filter: Option<String>,
group_filter: Option<String>,
tag_filter: Option<String>,
trashed: bool,
) -> Result<()> {
use relicario_core::ItemType;
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let manifest = vault.load_manifest()?;
let parsed_type: Option<ItemType> = match type_filter.as_deref() {
None => None,
Some("login") => Some(ItemType::Login),
Some("secure_note") | Some("note") => Some(ItemType::SecureNote),
Some("identity") => Some(ItemType::Identity),
Some("card") => Some(ItemType::Card),
Some("key") => Some(ItemType::Key),
Some("document") => Some(ItemType::Document),
Some("totp") => Some(ItemType::Totp),
Some(other) => anyhow::bail!("unknown type filter: {other}"),
};
let mut entries: Vec<_> = manifest.items.values()
.filter(|e| {
if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() }
})
.filter(|e| match parsed_type {
Some(t) => e.r#type == t,
None => true,
})
.filter(|e| group_filter.as_ref().map_or(true, |g| e.group.as_deref() == Some(g.as_str())))
.filter(|e| tag_filter.as_ref().map_or(true, |t| e.tags.iter().any(|x| x == t)))
.collect();
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
if entries.is_empty() {
eprintln!("(no items match)");
return Ok(());
}
println!("{:<16} {:<14} {:<6} {}", "ID", "TYPE", "FAV", "TITLE");
for e in entries {
let fav = if e.favorite { " *" } else { "" };
println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title);
}
Ok(())
}
fn cmd_edit(query: String) -> Result<()> {
use relicario_core::time::now_unix;
use relicario_core::ItemCore;
use zeroize::Zeroizing;
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let entry = resolve_query(&manifest, &query)?;
let id = entry.id.clone();
let _ = entry;
let mut item = vault.load_item(&id)?;
eprintln!("Editing: {} ({}) — leave a prompt blank to keep the current value.",
item.title, item.id.as_str());
// Title
if let Some(v) = prompt_keep("Title", &item.title)? { item.title = v; }
// Group
if let Some(v) = prompt_keep_opt("Group", item.group.as_deref())? { item.group = Some(v); }
// Tags (comma-separated)
if let Some(v) = prompt_keep_opt("Tags (comma-separated)", Some(&item.tags.join(",")))? {
item.tags = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
}
// Core-specific fields. Only Login.password and Card.number/cvv/pin are
// history-tracked from the core path.
match &mut item.core {
ItemCore::Login(l) => {
if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?);
}
if prompt_yesno("Change password?")? {
let old = l.password.clone();
let new_pw = Zeroizing::new(rpassword::prompt_password("New password: ")?);
l.password = Some(new_pw);
if let Some(old_pw) = old {
push_history(&mut item.field_history, "login_password",
Zeroizing::new(old_pw.as_str().to_string()));
}
}
}
ItemCore::SecureNote(n) => {
if prompt_yesno("Edit body?")? {
let old = n.body.clone();
eprintln!("Enter new body; end with Ctrl-D:");
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
n.body = Zeroizing::new(s);
push_history(&mut item.field_history, "secure_note_body",
Zeroizing::new(old.as_str().to_string()));
}
}
ItemCore::Identity(i) => {
if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); }
if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); }
if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); }
}
ItemCore::Card(c) => {
if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
if prompt_yesno("Change card number?")? {
let old = c.number.clone();
c.number = Some(Zeroizing::new(rpassword::prompt_password("New number: ")?));
if let Some(o) = old {
push_history(&mut item.field_history, "card_number",
Zeroizing::new(o.as_str().to_string()));
}
}
}
ItemCore::Key(k) => {
if prompt_yesno("Replace key material?")? {
eprintln!("Paste new key material; end with Ctrl-D:");
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
let old = k.key_material.clone();
k.key_material = Zeroizing::new(s);
push_history(&mut item.field_history, "key_material",
Zeroizing::new(old.as_str().to_string()));
}
}
ItemCore::Document(_) => {
eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
}
ItemCore::Totp(_) => {
eprintln!("TOTP rotation not yet implemented — delete and re-add for now.");
}
}
item.modified = now_unix();
vault.save_item(&item)?;
manifest.upsert(&item);
vault.save_manifest(&manifest)?;
commit_paths(&vault, &format!("edit: {} ({})", item.title, item.id.as_str()),
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
eprintln!("Updated {}", item.id.as_str());
Ok(())
}
fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
eprint!("{label} [{current}]: ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
let trimmed = s.trim().to_string();
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
}
fn prompt_keep_opt(label: &str, current: Option<&str>) -> Result<Option<String>> {
let display = current.unwrap_or("(none)");
eprint!("{label} [{display}]: ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
let trimmed = s.trim().to_string();
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
}
fn prompt_yesno(label: &str) -> Result<bool> {
eprint!("{label} [y/N] ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
Ok(matches!(s.trim().to_ascii_lowercase().as_str(), "y" | "yes"))
}
fn push_history(
history: &mut std::collections::HashMap<relicario_core::FieldId, Vec<relicario_core::item::FieldHistoryEntry>>,
synthetic_key: &str,
old_value: zeroize::Zeroizing<String>,
) {
use relicario_core::item::FieldHistoryEntry;
use relicario_core::time::now_unix;
// Synthetic FieldId for core-level fields — stable per-item (prefixed so
// custom-field UUIDs can't collide).
let fid = relicario_core::FieldId(format!("core:{synthetic_key}"));
history.entry(fid).or_default().push(FieldHistoryEntry {
value: old_value,
replaced_at: now_unix(),
});
}
fn cmd_rm(query: String) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let entry = resolve_query(&manifest, &query)?;
let id = entry.id.clone();
let _ = entry;
let mut item = vault.load_item(&id)?;
item.soft_delete();
vault.save_item(&item)?;
manifest.upsert(&item);
vault.save_manifest(&manifest)?;
commit_paths(&vault, &format!("trash: {} ({})", item.title, item.id.as_str()),
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
eprintln!("Moved to trash: {}", item.title);
Ok(())
}
fn cmd_restore(query: String) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let entry = resolve_query(&manifest, &query)?;
let id = entry.id.clone();
let _ = entry;
let mut item = vault.load_item(&id)?;
item.restore();
vault.save_item(&item)?;
manifest.upsert(&item);
vault.save_manifest(&manifest)?;
commit_paths(&vault, &format!("restore: {} ({})", item.title, item.id.as_str()),
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
eprintln!("Restored: {}", item.title);
Ok(())
}
/// Inner purge: assumes vault is already unlocked and manifest is loaded.
/// Caller is responsible for saving the manifest and committing afterwards.
fn purge_item(
vault: &crate::session::UnlockedVault,
manifest: &mut relicario_core::Manifest,
id: &relicario_core::ItemId,
title: &str,
) -> Result<()> {
use std::fs;
let item_path = vault.item_path(id);
if item_path.exists() { fs::remove_file(&item_path)?; }
let att_dir = vault.root().join("attachments").join(id.as_str());
if att_dir.exists() { fs::remove_dir_all(&att_dir)?; }
manifest.remove(id);
let _ = crate::helpers::git_command(vault.root(), &["rm", "-rf", "--ignore-unmatch",
&format!("items/{}.enc", id.as_str()),
&format!("attachments/{}", id.as_str()),
]).status()?;
// Note: caller adds+commits manifest.enc after processing all purges.
eprintln!("Purged: {title}");
Ok(())
}
fn cmd_purge(query: String) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let entry = resolve_query(&manifest, &query)?;
let id = entry.id.clone();
let title = entry.title.clone();
let _ = entry;
purge_item(&vault, &mut manifest, &id, &title)?;
vault.save_manifest(&manifest)?;
let status = crate::helpers::git_command(vault.root(), &["add", "manifest.enc"]).status()?;
if !status.success() { anyhow::bail!("git add manifest.enc failed"); }
let status = crate::helpers::git_command(vault.root(),
&["commit", "-m", &format!("purge: {} ({})", title, id.as_str())]).status()?;
if !status.success() { anyhow::bail!("git commit failed"); }
Ok(())
}
fn cmd_trash(action: TrashAction) -> Result<()> {
match action {
TrashAction::List => cmd_list(None, None, None, true),
TrashAction::Empty => cmd_trash_empty(),
}
}
fn cmd_trash_empty() -> Result<()> {
use relicario_core::time::now_unix;
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let settings = vault.load_settings()?;
let now = now_unix();
let purgeable: Vec<_> = manifest.items.values()
.filter(|e| match e.trashed_at {
Some(t) => settings.trash_retention.should_purge(t, now),
None => false,
})
.map(|e| (e.id.clone(), e.title.clone()))
.collect();
if purgeable.is_empty() {
eprintln!("nothing past retention window");
return Ok(());
}
let mut purged_titles = Vec::new();
for (id, title) in purgeable {
purge_item(&vault, &mut manifest, &id, &title)?;
purged_titles.push(title);
}
vault.save_manifest(&manifest)?;
let status = crate::helpers::git_command(vault.root(), &["add", "manifest.enc"]).status()?;
if !status.success() { anyhow::bail!("git add manifest.enc failed"); }
let status = crate::helpers::git_command(vault.root(),
&["commit", "-m", &format!("trash empty: purged {} item(s)", purged_titles.len())]).status()?;
if !status.success() { anyhow::bail!("git commit failed"); }
eprintln!("Emptied trash: {} item(s)", purged_titles.len());
Ok(())
}
fn cmd_attach(query: String, file: PathBuf) -> Result<()> {
use std::fs;
use relicario_core::{encrypt_attachment, AttachmentRef};
use relicario_core::time::now_unix;
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let entry = resolve_query(&manifest, &query)?;
let id = entry.id.clone();
let _ = entry;
let mut item = vault.load_item(&id)?;
let settings = vault.load_settings()?;
let caps = settings.attachment_caps;
if item.attachments.len() as u32 >= caps.per_item_max_count {
anyhow::bail!("item already has {} attachments (max {})",
item.attachments.len(), caps.per_item_max_count);
}
let bytes = fs::read(&file)
.with_context(|| format!("failed to read {}", file.display()))?;
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
let filename = file.file_name()
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
.to_string_lossy()
.into_owned();
let mime_type = guess_mime(&filename);
let aref = AttachmentRef {
id: enc.id.clone(),
filename,
mime_type,
size: bytes.len() as u64,
created: now_unix(),
};
let att_dir = vault.root().join("attachments").join(item.id.as_str());
fs::create_dir_all(&att_dir)?;
fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?;
item.attachments.push(aref);
item.modified = now_unix();
vault.save_item(&item)?;
manifest.upsert(&item);
vault.save_manifest(&manifest)?;
let paths = [
format!("items/{}.enc", item.id.as_str()),
"manifest.enc".into(),
format!("attachments/{}/{}.enc", item.id.as_str(), enc.id.as_str()),
];
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
commit_paths(&vault, &format!("attach: {}{} ({})",
file.display(), item.title, item.id.as_str()), &path_refs)?;
eprintln!("Attached {} to {} (aid={})", file.display(), item.title, enc.id.as_str());
Ok(())
}
fn cmd_attachments(query: String) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let manifest = vault.load_manifest()?;
let entry = resolve_query(&manifest, &query)?;
let item = vault.load_item(&entry.id)?;
if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); }
println!("{:<17} {:>12} {:<22} {}", "AID", "SIZE", "MIME", "FILENAME");
for a in &item.attachments {
println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename);
}
Ok(())
}
fn cmd_extract(query: String, aid: String, out: Option<PathBuf>) -> Result<()> {
use std::fs;
use relicario_core::decrypt_attachment;
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let manifest = vault.load_manifest()?;
let entry = resolve_query(&manifest, &query)?;
let item = vault.load_item(&entry.id)?;
let aref = item.attachments.iter().find(|a| a.id.as_str() == aid)
.ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?;
let path = vault.root().join("attachments").join(item.id.as_str())
.join(format!("{}.enc", aid));
let bytes = fs::read(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let plaintext = decrypt_attachment(&bytes, vault.key())?;
let out_path = out.unwrap_or_else(|| PathBuf::from(&aref.filename));
fs::write(&out_path, plaintext.as_slice())
.with_context(|| format!("failed to write {}", out_path.display()))?;
eprintln!("Wrote {} bytes to {}", plaintext.len(), out_path.display());
Ok(())
}
fn cmd_generate(length: u32, bip39: bool, words: u32, symbols: String, separator: String) -> Result<()> {
use relicario_core::{
generate_passphrase, generate_password, Capitalization, CharClasses,
GeneratorRequest, SymbolCharset,
};
let output = if bip39 {
generate_passphrase(&GeneratorRequest::Bip39 {
word_count: words,
separator,
capitalization: Capitalization::Lower,
})?
} else {
let symbol_charset = match symbols.as_str() {
"safe" => SymbolCharset::SafeOnly,
"extended" => SymbolCharset::Extended,
other => SymbolCharset::Custom(other.to_string()),
};
generate_password(&GeneratorRequest::Random {
length,
classes: CharClasses { lower: true, upper: true, digits: true, symbols: true },
symbol_charset,
})?
};
println!("{}", output.as_str());
Ok(())
}
fn cmd_settings(action: SettingsAction) -> Result<()> {
use relicario_core::{HistoryRetention, TrashRetention};
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut settings = vault.load_settings()?;
match action {
SettingsAction::Show => {
println!("{}", serde_json::to_string_pretty(&settings)?);
return Ok(());
}
SettingsAction::TrashRetention { days, forever } => {
settings.trash_retention = match (days, forever) {
(Some(d), false) => TrashRetention::Days(d),
(None, true) => TrashRetention::Forever,
_ => anyhow::bail!("specify exactly one of --days or --forever"),
};
}
SettingsAction::HistoryRetention { last_n, days, forever } => {
settings.field_history_retention = match (last_n, days, forever) {
(Some(n), None, false) => HistoryRetention::LastN(n),
(None, Some(d), false) => HistoryRetention::Days(d),
(None, None, true) => HistoryRetention::Forever,
_ => anyhow::bail!("specify exactly one of --last-n / --days / --forever"),
};
}
SettingsAction::AttachmentCap {
per_attachment_max_bytes, per_item_max_count,
per_vault_soft_cap_bytes, per_vault_hard_cap_bytes,
} => {
if let Some(v) = per_attachment_max_bytes { settings.attachment_caps.per_attachment_max_bytes = v; }
if let Some(v) = per_item_max_count { settings.attachment_caps.per_item_max_count = v; }
if let Some(v) = per_vault_soft_cap_bytes { settings.attachment_caps.per_vault_soft_cap_bytes = v; }
if let Some(v) = per_vault_hard_cap_bytes { settings.attachment_caps.per_vault_hard_cap_bytes = v; }
}
}
vault.save_settings(&settings)?;
commit_paths(&vault, "settings: update", &["settings.enc"])?;
eprintln!("Settings updated.");
Ok(())
}
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,
}