Phase 2A of the fullscreen UX redesign — 8 form-level smart-input affordances (URL fill-from-tab + hostname chip, group autocomplete, password reveal + strength bar, TOTP live preview + QR decode, notes monospace toggle), shared between popup and fullscreen vault tabs via the new extension/src/shared/form-affordances/ module set. CLI parity: - relicario rate <passphrase> (zxcvbn score / guess estimate) - relicario completions <SHELL> (bash/zsh/fish via clap_complete) - --group <TAB> dynamic enumeration via .relicario/groups.cache (plaintext leak surface; opt out with RELICARIO_NO_GROUPS_CACHE=1) - --totp-qr <path> on add login + edit (rqrr decode) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2264 lines
84 KiB
Rust
2264 lines
84 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::{CommandFactory, Parser, Subcommand};
|
|
use clap_complete::{generate, Shell};
|
|
|
|
#[derive(Parser)]
|
|
#[command(
|
|
name = "relicario",
|
|
version,
|
|
about = "Relicario — 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,
|
|
/// Decode an `otpauth://` QR image to set the TOTP secret (login items only).
|
|
#[arg(long, value_name = "PATH")]
|
|
totp_qr: Option<PathBuf>,
|
|
},
|
|
|
|
/// View captured field history for an item. Values are masked by
|
|
/// default; pass `--show` to reveal them.
|
|
History {
|
|
query: String,
|
|
#[arg(long)]
|
|
show: bool,
|
|
/// Filter to a single field (matches against the synthetic key,
|
|
/// e.g. `login_password`, `card_number`, `totp_secret`).
|
|
#[arg(long)]
|
|
field: Option<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,
|
|
},
|
|
|
|
/// Backup operations: pack and unpack `.relbak` archives.
|
|
Backup {
|
|
#[command(subcommand)]
|
|
action: BackupAction,
|
|
},
|
|
|
|
/// Import items from another password manager into the unlocked vault.
|
|
Import {
|
|
#[command(subcommand)]
|
|
action: ImportAction,
|
|
},
|
|
|
|
/// 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>,
|
|
},
|
|
|
|
/// Remove an individual attachment from an item (deletes the encrypted
|
|
/// blob and updates the item + manifest). Use `purge` to drop the entire
|
|
/// item and all its attachments at once.
|
|
Detach { query: String, aid: String },
|
|
|
|
/// Generate a password or passphrase. When run inside an initialized
|
|
/// vault, falls back to `settings generator-defaults` for unspecified
|
|
/// flags; outside a vault, uses built-in defaults (length 20, safe
|
|
/// symbol set, 5 BIP39 words, space separator).
|
|
Generate {
|
|
#[arg(long)]
|
|
length: Option<u32>,
|
|
#[arg(long)]
|
|
bip39: bool,
|
|
#[arg(long)]
|
|
words: Option<u32>,
|
|
#[arg(long)]
|
|
symbols: Option<String>,
|
|
/// Separator for BIP39 words.
|
|
#[arg(long)]
|
|
separator: Option<String>,
|
|
},
|
|
|
|
/// View or change vault settings.
|
|
Settings {
|
|
#[command(subcommand)]
|
|
action: SettingsAction,
|
|
},
|
|
|
|
/// Sync with the git remote (pull --rebase + push).
|
|
Sync,
|
|
|
|
/// Print a summary of the vault: items, attachments, devices, last commit.
|
|
Status,
|
|
|
|
/// Device management.
|
|
Device {
|
|
#[command(subcommand)]
|
|
action: DeviceAction,
|
|
},
|
|
|
|
/// Lock the vault (no-op in CLI; present for UX parity with the extension).
|
|
Lock,
|
|
|
|
/// Emit a shell completion script for the given shell.
|
|
///
|
|
/// For `--group <TAB>` autocomplete, the bash/zsh/fish scripts read
|
|
/// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file,
|
|
/// which the CLI refreshes on every manifest read. Set
|
|
/// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion
|
|
/// will fall back to no value enumeration).
|
|
///
|
|
/// Pipe stdout to your shell's completion location (e.g.
|
|
/// `relicario completions bash > /etc/bash_completion.d/relicario`).
|
|
Completions {
|
|
#[arg(value_enum)]
|
|
shell: Shell,
|
|
},
|
|
|
|
/// Rate a passphrase with zxcvbn — prints score (0-4) and estimated
|
|
/// guesses. Informational only; does not gate vault operations.
|
|
///
|
|
/// Pass `-` as the argument to read one line from stdin instead, which
|
|
/// keeps the passphrase out of shell history.
|
|
Rate {
|
|
/// Passphrase to score, or `-` to read from stdin.
|
|
passphrase: String,
|
|
},
|
|
}
|
|
|
|
#[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,
|
|
/// Decode an `otpauth://` QR image to fill the TOTP secret.
|
|
#[arg(long, value_name = "PATH")] totp_qr: Option<PathBuf>,
|
|
},
|
|
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>,
|
|
},
|
|
/// Update the default password / passphrase generator settings used by
|
|
/// `relicario generate` when run inside this vault. Pass `--bip39` or
|
|
/// `--random` to switch mode; per-attribute flags update fields of the
|
|
/// chosen mode.
|
|
GeneratorDefaults {
|
|
/// Switch the default mode to random-character password.
|
|
#[arg(long, conflicts_with = "bip39")]
|
|
random: bool,
|
|
/// Switch the default mode to BIP39 passphrase.
|
|
#[arg(long, conflicts_with = "random")]
|
|
bip39: bool,
|
|
/// Random mode: total password length.
|
|
#[arg(long)] length: Option<u32>,
|
|
/// BIP39 mode: number of words.
|
|
#[arg(long)] words: Option<u32>,
|
|
/// Random mode: symbol charset (`safe`, `extended`, or a custom literal).
|
|
#[arg(long)] symbols: Option<String>,
|
|
/// BIP39 mode: word separator.
|
|
#[arg(long)] separator: Option<String>,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum DeviceAction {
|
|
Add { #[arg(long)] name: String },
|
|
List,
|
|
Revoke { name: String },
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum BackupAction {
|
|
/// Pack the local vault into a single encrypted `.relbak` file.
|
|
/// Backup passphrase is independent of the vault passphrase.
|
|
Export {
|
|
/// Output `.relbak` path.
|
|
out: PathBuf,
|
|
/// Bundle the reference JPEG into the encrypted envelope.
|
|
#[arg(long)]
|
|
include_image: bool,
|
|
/// Override the reference image path (defaults to the vault's
|
|
/// `reference.jpg` or `RELICARIO_IMAGE`).
|
|
#[arg(long)]
|
|
image: Option<PathBuf>,
|
|
/// Skip bundling `.git/` history.
|
|
#[arg(long)]
|
|
no_history: bool,
|
|
},
|
|
/// Unpack a `.relbak` file into a fresh vault directory.
|
|
Restore {
|
|
/// Input `.relbak` path.
|
|
input: PathBuf,
|
|
/// Target directory (must NOT already contain `.relicario/`).
|
|
/// Defaults to the current directory.
|
|
#[arg(default_value = ".")]
|
|
target: PathBuf,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum ImportAction {
|
|
/// Import a LastPass CSV export into the unlocked vault.
|
|
/// Each row creates a new item with a freshly-minted ID; title
|
|
/// collisions are kept (no dedup). Failed rows are skipped and
|
|
/// reported on stderr.
|
|
Lastpass {
|
|
/// Path to the LastPass-format CSV export.
|
|
csv: PathBuf,
|
|
},
|
|
}
|
|
|
|
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, totp_qr } => cmd_edit(query, totp_qr),
|
|
Commands::History { query, show, field } => cmd_history(query, show, field),
|
|
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::Backup { action } => cmd_backup(action),
|
|
Commands::Import { action } => cmd_import(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::Detach { query, aid } => cmd_detach(query, aid),
|
|
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::Status => cmd_status(),
|
|
Commands::Device { action } => cmd_device(action),
|
|
Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) }
|
|
Commands::Completions { shell } => {
|
|
let mut cmd = Cli::command();
|
|
generate(shell, &mut cmd, "relicario", &mut std::io::stdout());
|
|
Ok(())
|
|
}
|
|
Commands::Rate { passphrase } => cmd_rate(passphrase),
|
|
}
|
|
}
|
|
|
|
/// Collect all non-empty group names from the manifest and write them to the
|
|
/// plaintext `groups.cache` file so shell completion can enumerate `--group`
|
|
/// candidates without prompting for the vault passphrase.
|
|
///
|
|
/// Failures are silently swallowed — a missing cache is merely a UX degradation,
|
|
/// not a correctness problem.
|
|
fn refresh_groups_cache(vault_dir: &std::path::Path, manifest: &relicario_core::Manifest) {
|
|
let mut set = std::collections::BTreeSet::<String>::new();
|
|
for entry in manifest.items.values() {
|
|
if let Some(g) = entry.group.as_ref() {
|
|
if !g.is_empty() {
|
|
set.insert(g.clone());
|
|
}
|
|
}
|
|
}
|
|
let _ = helpers::write_groups_cache(vault_dir, &set);
|
|
}
|
|
|
|
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
|
|
/// for integration-test use (rpassword reads /dev/tty by default, which is
|
|
/// unavailable in assert_cmd-spawned children).
|
|
fn prompt_secret(label: &str) -> Result<String> {
|
|
if let Ok(s) = std::env::var("RELICARIO_TEST_ITEM_SECRET") {
|
|
return Ok(s);
|
|
}
|
|
rpassword::prompt_password(label).map_err(Into::into)
|
|
}
|
|
|
|
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 Ok(p) = std::env::var("RELICARIO_TEST_PASSPHRASE") {
|
|
Zeroizing::new(p)
|
|
} else {
|
|
Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?)
|
|
};
|
|
let confirm = if std::env::var_os("RELICARIO_TEST_PASSPHRASE").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(),
|
|
})?,
|
|
)?;
|
|
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, totp_qr } =>
|
|
build_login_item(title, username, url, password_prompt, password, group, tags, favorite, totp_qr)?,
|
|
AddKind::SecureNote { title, body_prompt, group, tags } =>
|
|
build_secure_note_item(title, body_prompt, group, tags)?,
|
|
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } =>
|
|
build_identity_item(title, full_name, email, phone, date_of_birth, group, tags)?,
|
|
AddKind::Card { title, holder, expiry, kind, group, tags } =>
|
|
build_card_item(title, holder, expiry, kind, group, tags)?,
|
|
AddKind::Key { title, label, algorithm, group, tags } =>
|
|
build_key_item(title, label, algorithm, group, tags)?,
|
|
AddKind::Document { title, file, group, tags } =>
|
|
build_document_item(&vault, title, file, group, tags)?,
|
|
AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } =>
|
|
build_totp_item(title, issuer, label, secret, period, digits, algorithm, group, tags)?,
|
|
};
|
|
|
|
vault.save_item(&item)?;
|
|
manifest.upsert(&item);
|
|
vault.save_manifest(&manifest)?;
|
|
refresh_groups_cache(vault.root(), &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(())
|
|
}
|
|
|
|
// --- Per-type item builders, one per AddKind variant. Each returns a
|
|
// fully-populated Item; cmd_add handles the common save/manifest/commit
|
|
// wrap-up. Document is the only builder that needs the unlocked vault
|
|
// (for attachment-cap settings + writing the encrypted blob alongside
|
|
// the item).
|
|
|
|
fn build_login_item(
|
|
title: Option<String>,
|
|
username: Option<String>,
|
|
url: Option<String>,
|
|
password_prompt: bool,
|
|
password: Option<String>,
|
|
group: Option<String>,
|
|
tags: Vec<String>,
|
|
favorite: bool,
|
|
totp_qr: Option<PathBuf>,
|
|
) -> Result<relicario_core::Item> {
|
|
use relicario_core::item_types::{LoginCore, TotpAlgorithm, TotpConfig, TotpKind};
|
|
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(prompt_secret("Password: ")?))
|
|
} else {
|
|
None
|
|
};
|
|
let totp = if let Some(path) = totp_qr {
|
|
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
|
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
|
Some(TotpConfig {
|
|
secret: Zeroizing::new(secret_bytes),
|
|
algorithm: TotpAlgorithm::Sha1,
|
|
digits: 6,
|
|
period_seconds: 30,
|
|
kind: TotpKind::Totp,
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
let mut item = Item::new(title, ItemCore::Login(LoginCore {
|
|
username, password, url: parsed_url, totp,
|
|
}));
|
|
item.group = group;
|
|
item.tags = tags;
|
|
item.favorite = favorite;
|
|
Ok(item)
|
|
}
|
|
|
|
fn build_secure_note_item(
|
|
title: Option<String>,
|
|
body_prompt: bool,
|
|
group: Option<String>,
|
|
tags: Vec<String>,
|
|
) -> Result<relicario_core::Item> {
|
|
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;
|
|
Ok(item)
|
|
}
|
|
|
|
fn build_identity_item(
|
|
title: Option<String>,
|
|
full_name: Option<String>,
|
|
email: Option<String>,
|
|
phone: Option<String>,
|
|
date_of_birth: Option<String>,
|
|
group: Option<String>,
|
|
tags: Vec<String>,
|
|
) -> Result<relicario_core::Item> {
|
|
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;
|
|
Ok(item)
|
|
}
|
|
|
|
fn build_card_item(
|
|
title: Option<String>,
|
|
holder: Option<String>,
|
|
expiry: Option<String>,
|
|
kind: String,
|
|
group: Option<String>,
|
|
tags: Vec<String>,
|
|
) -> Result<relicario_core::Item> {
|
|
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(prompt_secret("Card number: ")?);
|
|
let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?);
|
|
let cvv = if cvv.is_empty() { None } else { Some(cvv) };
|
|
let pin = Zeroizing::new(prompt_secret("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;
|
|
Ok(item)
|
|
}
|
|
|
|
fn build_key_item(
|
|
title: Option<String>,
|
|
label: Option<String>,
|
|
algorithm: Option<String>,
|
|
group: Option<String>,
|
|
tags: Vec<String>,
|
|
) -> Result<relicario_core::Item> {
|
|
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;
|
|
Ok(item)
|
|
}
|
|
|
|
fn build_document_item(
|
|
vault: &crate::session::UnlockedVault,
|
|
title: Option<String>,
|
|
file: PathBuf,
|
|
group: Option<String>,
|
|
tags: Vec<String>,
|
|
) -> Result<relicario_core::Item> {
|
|
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,
|
|
});
|
|
|
|
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)?;
|
|
Ok(item)
|
|
}
|
|
|
|
fn build_totp_item(
|
|
title: Option<String>,
|
|
issuer: Option<String>,
|
|
label: Option<String>,
|
|
secret: Option<String>,
|
|
period: u32,
|
|
digits: u8,
|
|
algorithm: String,
|
|
group: Option<String>,
|
|
tags: Vec<String>,
|
|
) -> Result<relicario_core::Item> {
|
|
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 => prompt_secret("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 mut item = Item::new(title, ItemCore::Totp(TotpCore {
|
|
config: TotpConfig {
|
|
secret: Zeroizing::new(secret_bytes),
|
|
algorithm: algo,
|
|
digits,
|
|
period_seconds: period,
|
|
kind: TotpKind::Totp,
|
|
},
|
|
issuer, label,
|
|
}));
|
|
item.group = group;
|
|
item.tags = tags;
|
|
Ok(item)
|
|
}
|
|
|
|
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()?;
|
|
refresh_groups_cache(vault.root(), &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(t) = &l.totp {
|
|
if show {
|
|
println!("TOTP: {}", data_encoding::BASE32.encode(&*t.secret));
|
|
} else {
|
|
println!("TOTP: **** (use --show to reveal)");
|
|
}
|
|
}
|
|
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()?;
|
|
refresh_groups_cache(vault.root(), &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, totp_qr: Option<PathBuf>) -> Result<()> {
|
|
use relicario_core::time::now_unix;
|
|
use relicario_core::ItemCore;
|
|
|
|
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());
|
|
|
|
if let Some(v) = prompt_keep("Title", &item.title)? { item.title = v; }
|
|
if let Some(v) = prompt_keep_opt("Group", item.group.as_deref())? { item.group = Some(v); }
|
|
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();
|
|
}
|
|
|
|
let history = &mut item.field_history;
|
|
match &mut item.core {
|
|
ItemCore::Login(l) => edit_login(l, history, totp_qr)?,
|
|
ItemCore::SecureNote(n) => edit_secure_note(n, history)?,
|
|
ItemCore::Identity(i) => edit_identity(i)?,
|
|
ItemCore::Card(c) => edit_card(c, history)?,
|
|
ItemCore::Key(k) => edit_key(k, history)?,
|
|
ItemCore::Document(_) => edit_document_message(),
|
|
ItemCore::Totp(t) => edit_totp(t, history)?,
|
|
}
|
|
|
|
item.modified = now_unix();
|
|
vault.save_item(&item)?;
|
|
manifest.upsert(&item);
|
|
vault.save_manifest(&manifest)?;
|
|
refresh_groups_cache(vault.root(), &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(())
|
|
}
|
|
|
|
// --- Per-type edit handlers. Each mutates its core slice in place; the ones
|
|
// that touch history-tracked fields take the item's field_history map so
|
|
// they can record the prior value alongside the change.
|
|
|
|
type FieldHistory = std::collections::HashMap<
|
|
relicario_core::FieldId,
|
|
Vec<relicario_core::item::FieldHistoryEntry>,
|
|
>;
|
|
|
|
fn edit_login(
|
|
l: &mut relicario_core::item_types::LoginCore,
|
|
history: &mut FieldHistory,
|
|
totp_qr: Option<PathBuf>,
|
|
) -> Result<()> {
|
|
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpKind};
|
|
use zeroize::Zeroizing;
|
|
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();
|
|
l.password = Some(Zeroizing::new(prompt_secret("New password: ")?));
|
|
if let Some(old_pw) = old {
|
|
push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string()));
|
|
}
|
|
}
|
|
if let Some(path) = totp_qr {
|
|
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
|
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
|
l.totp = Some(TotpConfig {
|
|
secret: Zeroizing::new(secret_bytes),
|
|
algorithm: TotpAlgorithm::Sha1,
|
|
digits: 6,
|
|
period_seconds: 30,
|
|
kind: TotpKind::Totp,
|
|
});
|
|
eprintln!("TOTP secret set from QR image.");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> {
|
|
use zeroize::Zeroizing;
|
|
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(history, "secure_note_body", Zeroizing::new(old.as_str().to_string()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()> {
|
|
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); }
|
|
Ok(())
|
|
}
|
|
|
|
fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> {
|
|
use zeroize::Zeroizing;
|
|
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(prompt_secret("New number: ")?));
|
|
if let Some(o) = old {
|
|
push_history(history, "card_number", Zeroizing::new(o.as_str().to_string()));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> {
|
|
use zeroize::Zeroizing;
|
|
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(history, "key_material", Zeroizing::new(old.as_str().to_string()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn edit_document_message() {
|
|
eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
|
|
}
|
|
|
|
fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> {
|
|
use zeroize::Zeroizing;
|
|
if let Some(v) = prompt_keep_opt("Issuer", t.issuer.as_deref())? { t.issuer = Some(v); }
|
|
if let Some(v) = prompt_keep_opt("Label", t.label.as_deref())? { t.label = Some(v); }
|
|
if prompt_yesno("Change TOTP secret?")? {
|
|
let old_b32 = data_encoding::BASE32.encode(&t.config.secret);
|
|
let new_b32 = prompt_secret("New TOTP secret (base32): ")?;
|
|
let new_bytes = base32_decode_lenient(&new_b32)?;
|
|
t.config.secret = Zeroizing::new(new_bytes);
|
|
push_history(history, "totp_secret", Zeroizing::new(old_b32));
|
|
}
|
|
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_history(query: String, show: bool, field: Option<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)?;
|
|
|
|
println!("History for {} ({})", item.title, item.id.as_str());
|
|
println!();
|
|
|
|
// Filter and sort the field-id keys so output is deterministic.
|
|
let mut keys: Vec<&relicario_core::FieldId> = item.field_history.keys().collect();
|
|
keys.sort_by(|a, b| a.0.cmp(&b.0));
|
|
|
|
let mut printed_any = false;
|
|
for fid in keys {
|
|
let display_name = fid.0.strip_prefix("core:").unwrap_or(&fid.0);
|
|
if let Some(filter) = &field {
|
|
if display_name != filter && fid.0 != *filter { continue; }
|
|
}
|
|
let entries = &item.field_history[fid];
|
|
if entries.is_empty() { continue; }
|
|
printed_any = true;
|
|
|
|
println!("{display_name} ({} {})",
|
|
entries.len(),
|
|
if entries.len() == 1 { "entry" } else { "entries" });
|
|
for (i, e) in entries.iter().enumerate() {
|
|
let ts = crate::helpers::iso8601(e.replaced_at);
|
|
if show {
|
|
println!(" [{i}] {ts} {}", e.value.as_str());
|
|
} else {
|
|
println!(" [{i}] {ts} ********");
|
|
}
|
|
}
|
|
println!();
|
|
}
|
|
|
|
if !printed_any {
|
|
if field.is_some() {
|
|
println!("no history for the requested field");
|
|
} else {
|
|
println!("no history captured for this item");
|
|
}
|
|
} else if !show {
|
|
println!("(use --show to reveal values)");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
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)?;
|
|
refresh_groups_cache(vault.root(), &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)?;
|
|
refresh_groups_cache(vault.root(), &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)?;
|
|
refresh_groups_cache(vault.root(), &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_backup(action: BackupAction) -> Result<()> {
|
|
match action {
|
|
BackupAction::Export { out, include_image, image, no_history } => {
|
|
cmd_backup_export(out, include_image, image, no_history)
|
|
}
|
|
BackupAction::Restore { input, target } => cmd_backup_restore(input, target),
|
|
}
|
|
}
|
|
|
|
fn cmd_backup_export(
|
|
out: PathBuf,
|
|
include_image: bool,
|
|
image: Option<PathBuf>,
|
|
no_history: bool,
|
|
) -> Result<()> {
|
|
use std::fs;
|
|
use relicario_core::{backup, validate_passphrase_strength};
|
|
use zeroize::Zeroizing;
|
|
|
|
let root = crate::helpers::vault_dir()?;
|
|
|
|
// Backup passphrase — prompt twice, gate on zxcvbn (audit H3).
|
|
let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE") {
|
|
Zeroizing::new(p)
|
|
} else {
|
|
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
|
|
};
|
|
let confirm = if std::env::var_os("RELICARIO_TEST_BACKUP_PASSPHRASE").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!("backup {}. Choose a longer or more entropic phrase.", e);
|
|
}
|
|
|
|
// Read everything from disk that the envelope needs.
|
|
let salt = fs::read(root.join(".relicario").join("salt"))
|
|
.with_context(|| "failed to read .relicario/salt")?;
|
|
let params_json = fs::read_to_string(root.join(".relicario").join("params.json"))
|
|
.with_context(|| "failed to read .relicario/params.json")?;
|
|
let devices_json = fs::read_to_string(root.join(".relicario").join("devices.json"))
|
|
.with_context(|| "failed to read .relicario/devices.json")?;
|
|
let manifest_enc = fs::read(root.join("manifest.enc"))
|
|
.with_context(|| "failed to read manifest.enc")?;
|
|
let settings_enc = fs::read(root.join("settings.enc"))
|
|
.with_context(|| "failed to read settings.enc")?;
|
|
|
|
// Items.
|
|
let mut item_files = Vec::new();
|
|
let items_dir = root.join("items");
|
|
if items_dir.is_dir() {
|
|
for entry in fs::read_dir(&items_dir)? {
|
|
let p = entry?.path();
|
|
if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; }
|
|
let id = p.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.ok_or_else(|| anyhow::anyhow!("bad item filename: {}", p.display()))?
|
|
.to_string();
|
|
let bytes = fs::read(&p)?;
|
|
item_files.push((id, bytes));
|
|
}
|
|
}
|
|
|
|
// Attachments. Layout: attachments/<item_id>/<aid>.enc
|
|
let mut attach_files = Vec::new();
|
|
let attach_dir = root.join("attachments");
|
|
if attach_dir.is_dir() {
|
|
for entry in fs::read_dir(&attach_dir)? {
|
|
let item_dir = entry?.path();
|
|
if !item_dir.is_dir() { continue; }
|
|
let item_id = item_dir.file_name()
|
|
.and_then(|s| s.to_str())
|
|
.ok_or_else(|| anyhow::anyhow!("bad attachment dir: {}", item_dir.display()))?
|
|
.to_string();
|
|
for sub in fs::read_dir(&item_dir)? {
|
|
let p = sub?.path();
|
|
if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; }
|
|
let aid = p.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.ok_or_else(|| anyhow::anyhow!("bad attachment filename: {}", p.display()))?
|
|
.to_string();
|
|
let bytes = fs::read(&p)?;
|
|
attach_files.push((item_id.clone(), aid, bytes));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Optional reference image.
|
|
let image_bytes = if include_image {
|
|
let path = match image {
|
|
Some(p) => p,
|
|
None => crate::session::get_image_path()?,
|
|
};
|
|
Some(fs::read(&path)
|
|
.with_context(|| format!("failed to read reference image {}", path.display()))?)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Optional .git/ tar.
|
|
let git_archive = if no_history { None } else { Some(tar_directory(&root.join(".git"))?) };
|
|
|
|
let items_refs: Vec<backup::BackupItem> = item_files.iter()
|
|
.map(|(id, bytes)| backup::BackupItem { id: id.clone(), ciphertext: bytes })
|
|
.collect();
|
|
let attach_refs: Vec<backup::BackupAttachment> = attach_files.iter()
|
|
.map(|(iid, aid, bytes)| backup::BackupAttachment {
|
|
item_id: iid.clone(),
|
|
attachment_id: aid.clone(),
|
|
ciphertext: bytes,
|
|
})
|
|
.collect();
|
|
|
|
let input = backup::BackupInput {
|
|
salt: &salt,
|
|
params_json: ¶ms_json,
|
|
devices_json: &devices_json,
|
|
manifest_enc: &manifest_enc,
|
|
settings_enc: &settings_enc,
|
|
items: items_refs,
|
|
attachments: attach_refs,
|
|
reference_jpg: image_bytes.as_deref(),
|
|
git_archive: git_archive.as_deref(),
|
|
};
|
|
|
|
let bytes = backup::pack_backup(input, &passphrase)?;
|
|
|
|
// atomic_write via the existing pattern: write `.tmp`, rename.
|
|
let tmp = {
|
|
let mut t = out.as_os_str().to_owned();
|
|
t.push(".tmp");
|
|
PathBuf::from(t)
|
|
};
|
|
fs::write(&tmp, &bytes)
|
|
.with_context(|| format!("failed to write {}", tmp.display()))?;
|
|
fs::rename(&tmp, &out)
|
|
.with_context(|| format!("failed to rename {}", out.display()))?;
|
|
|
|
// Marker file for `cmd_status`. Format: ISO-8601 UTC line.
|
|
let now_iso = crate::helpers::iso8601(relicario_core::now_unix());
|
|
fs::write(root.join(".relicario").join("last_backup"), format!("{now_iso}\n"))?;
|
|
|
|
let mib = (bytes.len() as f64) / (1024.0 * 1024.0);
|
|
eprintln!(
|
|
"Wrote {} ({:.2} MiB). Delete after restore is verified.",
|
|
out.display(), mib
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Tar a directory into an in-memory `Vec<u8>`. Used for `.git/` bundling.
|
|
fn tar_directory(dir: &std::path::Path) -> Result<Vec<u8>> {
|
|
let mut buf = Vec::new();
|
|
{
|
|
let mut builder = tar::Builder::new(&mut buf);
|
|
builder.append_dir_all(".", dir)
|
|
.with_context(|| format!("failed to tar {}", dir.display()))?;
|
|
builder.finish().with_context(|| "failed to finalize git tar")?;
|
|
}
|
|
Ok(buf)
|
|
}
|
|
|
|
fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
|
|
use std::fs;
|
|
use relicario_core::backup;
|
|
use zeroize::Zeroizing;
|
|
|
|
let target = if target.is_absolute() {
|
|
target
|
|
} else {
|
|
std::env::current_dir()?.join(&target)
|
|
};
|
|
|
|
if target.join(".relicario").exists() {
|
|
anyhow::bail!(
|
|
"target dir already contains a Relicario vault; restore refuses to overwrite — use an empty directory: {}",
|
|
target.display()
|
|
);
|
|
}
|
|
fs::create_dir_all(&target)
|
|
.with_context(|| format!("failed to create target {}", target.display()))?;
|
|
|
|
// Read input file.
|
|
let bytes = fs::read(&input)
|
|
.with_context(|| format!("failed to read backup file {}", input.display()))?;
|
|
|
|
// Backup passphrase prompt.
|
|
let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE") {
|
|
Zeroizing::new(p)
|
|
} else {
|
|
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
|
|
};
|
|
|
|
let unpacked = backup::unpack_backup(&bytes, &passphrase)
|
|
.map_err(|e| match e {
|
|
relicario_core::RelicarioError::Decrypt =>
|
|
anyhow::anyhow!("wrong backup passphrase, or the file is corrupt"),
|
|
other => anyhow::anyhow!(other),
|
|
})?;
|
|
|
|
// Write vault layout.
|
|
let relicario_dir = target.join(".relicario");
|
|
fs::create_dir_all(&relicario_dir)?;
|
|
fs::create_dir_all(target.join("items"))?;
|
|
fs::create_dir_all(target.join("attachments"))?;
|
|
|
|
fs::write(relicario_dir.join("salt"), unpacked.salt)?;
|
|
fs::write(relicario_dir.join("params.json"), &unpacked.params_json)?;
|
|
fs::write(relicario_dir.join("devices.json"), &unpacked.devices_json)?;
|
|
fs::write(target.join("manifest.enc"), &unpacked.manifest_enc)?;
|
|
fs::write(target.join("settings.enc"), &unpacked.settings_enc)?;
|
|
|
|
for item in &unpacked.items {
|
|
fs::write(target.join("items").join(format!("{}.enc", item.id)), &item.ciphertext)?;
|
|
}
|
|
for a in &unpacked.attachments {
|
|
let dir = target.join("attachments").join(&a.item_id);
|
|
fs::create_dir_all(&dir)?;
|
|
fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?;
|
|
}
|
|
|
|
// Reference image (if present).
|
|
if let Some(jpg) = &unpacked.reference_jpg {
|
|
let path = target.join("reference.jpg");
|
|
fs::write(&path, jpg)
|
|
.with_context(|| format!("failed to write reference image {}", path.display()))?;
|
|
}
|
|
|
|
// .git/ history.
|
|
if let Some(tar_bytes) = &unpacked.git_archive {
|
|
let mut archive = tar::Archive::new(tar_bytes.as_slice());
|
|
archive.unpack(target.join(".git"))
|
|
.with_context(|| "failed to untar .git/")?;
|
|
} else {
|
|
// No history bundled — start a fresh git repo.
|
|
let status = crate::helpers::git_command(&target, &["init"]).status()?;
|
|
if !status.success() { anyhow::bail!("git init failed"); }
|
|
|
|
// .gitignore — exclude reference image if present.
|
|
if target.join("reference.jpg").exists() {
|
|
fs::write(target.join(".gitignore"), "reference.jpg\n")?;
|
|
}
|
|
|
|
let _ = crate::helpers::git_command(&target, &["add", "."]).status()?;
|
|
let now_iso = crate::helpers::iso8601(relicario_core::now_unix());
|
|
let msg = format!("restore from backup {now_iso}");
|
|
let _ = crate::helpers::git_command(&target, &["commit", "-m", &msg]).status()?;
|
|
}
|
|
|
|
eprintln!(
|
|
"Restored vault to {}. Unlock with your passphrase + reference image.",
|
|
target.display()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_import(action: ImportAction) -> Result<()> {
|
|
match action {
|
|
ImportAction::Lastpass { csv } => cmd_import_lastpass(csv),
|
|
}
|
|
}
|
|
|
|
fn cmd_import_lastpass(csv_path: PathBuf) -> Result<()> {
|
|
use std::fs;
|
|
use relicario_core::import_lastpass::parse_lastpass_csv;
|
|
|
|
let csv_bytes = fs::read(&csv_path)
|
|
.with_context(|| format!("failed to read CSV {}", csv_path.display()))?;
|
|
|
|
let (items, warnings) = parse_lastpass_csv(&csv_bytes)?;
|
|
|
|
if items.is_empty() {
|
|
// Print all warnings so the user sees why nothing imported.
|
|
for w in &warnings {
|
|
print_warning(w);
|
|
}
|
|
bail!(
|
|
"imported 0 items from {} — see warnings above",
|
|
csv_path.display()
|
|
);
|
|
}
|
|
|
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
|
let mut manifest = vault.load_manifest()?;
|
|
|
|
let total = items.len();
|
|
let mut written_paths: Vec<String> = Vec::with_capacity(items.len() + 1);
|
|
|
|
for (idx, item) in items.iter().enumerate() {
|
|
vault.save_item(item)?;
|
|
manifest.upsert(item);
|
|
written_paths.push(format!("items/{}.enc", item.id.as_str()));
|
|
|
|
let n = idx + 1;
|
|
if n % 50 == 0 || n == total {
|
|
eprintln!("[{n}/{total}] importing...");
|
|
}
|
|
}
|
|
|
|
vault.save_manifest(&manifest)?;
|
|
written_paths.push("manifest.enc".into());
|
|
|
|
let path_refs: Vec<&str> = written_paths.iter().map(String::as_str).collect();
|
|
let csv_filename = csv_path
|
|
.file_name()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("lastpass.csv");
|
|
commit_paths(
|
|
&vault,
|
|
&format!("import: {} items from LastPass ({})", total, csv_filename),
|
|
&path_refs,
|
|
)?;
|
|
|
|
for w in &warnings {
|
|
print_warning(w);
|
|
}
|
|
// Counts only true skips, not partial imports. Coupled by convention to
|
|
// the parser's warning message strings: skip messages end in "— skipped",
|
|
// partial-import messages say "imported without TOTP" / "imported without URL".
|
|
// If a future warning uses the word "skipped" in any other sense, this filter
|
|
// will need to switch to an enum tag (see ImportWarning::message).
|
|
eprintln!(
|
|
"Imported {}, skipped {} (see warnings above)",
|
|
total,
|
|
warnings.iter().filter(|w| w.message.contains("skipped")).count()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn print_warning(w: &relicario_core::import_lastpass::ImportWarning) {
|
|
let prefix = match &w.title {
|
|
Some(t) => format!("row {} ({}):", w.row, t),
|
|
None => format!("row {}:", w.row),
|
|
};
|
|
eprintln!("warning: {prefix} {}", w.message);
|
|
}
|
|
|
|
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_detach(query: String, aid: String) -> Result<()> {
|
|
use std::fs;
|
|
use relicario_core::ItemCore;
|
|
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 pos = item.attachments.iter().position(|a| a.id.as_str() == aid)
|
|
.ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?;
|
|
|
|
// Document items keep their primary blob in the core; refuse to orphan it.
|
|
if let ItemCore::Document(d) = &item.core {
|
|
if d.primary_attachment.as_str() == aid {
|
|
anyhow::bail!(
|
|
"cannot detach the primary attachment of a Document item; \
|
|
use `purge {}` to delete the whole item",
|
|
item.title,
|
|
);
|
|
}
|
|
}
|
|
|
|
let removed = item.attachments.remove(pos);
|
|
let blob_path = vault.root().join("attachments").join(item.id.as_str())
|
|
.join(format!("{}.enc", removed.id.as_str()));
|
|
if blob_path.exists() {
|
|
fs::remove_file(&blob_path)
|
|
.with_context(|| format!("failed to delete {}", blob_path.display()))?;
|
|
}
|
|
|
|
item.modified = now_unix();
|
|
vault.save_item(&item)?;
|
|
manifest.upsert(&item);
|
|
vault.save_manifest(&manifest)?;
|
|
|
|
let item_path = format!("items/{}.enc", item.id.as_str());
|
|
let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str());
|
|
commit_paths(
|
|
&vault,
|
|
&format!("detach: {} from {} ({})", removed.filename, item.title, item.id.as_str()),
|
|
&[&item_path, "manifest.enc", &blob_relpath],
|
|
)?;
|
|
eprintln!("Detached {} (aid={}) from {}", removed.filename, aid, item.title);
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_generate(
|
|
length: Option<u32>,
|
|
bip39: bool,
|
|
words: Option<u32>,
|
|
symbols: Option<String>,
|
|
separator: Option<String>,
|
|
) -> Result<()> {
|
|
use relicario_core::{
|
|
generate_passphrase, generate_password, Capitalization, CharClasses,
|
|
GeneratorRequest, SymbolCharset,
|
|
};
|
|
|
|
// If we're inside a vault, unlock and pull `generator_defaults`. Outside
|
|
// a vault, this stays a fast standalone CSPRNG tool (no unlock prompt).
|
|
let vault_defaults: Option<GeneratorRequest> = if crate::helpers::vault_dir().is_ok() {
|
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
|
Some(vault.load_settings()?.generator_defaults)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// `--bip39` flag forces Bip39 mode; otherwise use whatever mode the
|
|
// vault default is in (Random when no vault).
|
|
let use_bip39 = bip39 || matches!(vault_defaults, Some(GeneratorRequest::Bip39 { .. }));
|
|
|
|
let output = if use_bip39 {
|
|
let (def_words, def_sep, def_cap) = match &vault_defaults {
|
|
Some(GeneratorRequest::Bip39 { word_count, separator, capitalization }) => {
|
|
(*word_count, separator.clone(), *capitalization)
|
|
}
|
|
_ => (5, " ".to_string(), Capitalization::Lower),
|
|
};
|
|
generate_passphrase(&GeneratorRequest::Bip39 {
|
|
word_count: words.unwrap_or(def_words),
|
|
separator: separator.unwrap_or(def_sep),
|
|
capitalization: def_cap,
|
|
})?
|
|
} else {
|
|
let (def_length, def_classes, def_charset) = match &vault_defaults {
|
|
Some(GeneratorRequest::Random { length, classes, symbol_charset }) => {
|
|
(*length, *classes, symbol_charset.clone())
|
|
}
|
|
_ => (
|
|
20,
|
|
CharClasses { lower: true, upper: true, digits: true, symbols: true },
|
|
SymbolCharset::SafeOnly,
|
|
),
|
|
};
|
|
let symbol_charset = match symbols.as_deref() {
|
|
None => def_charset,
|
|
Some("safe") => SymbolCharset::SafeOnly,
|
|
Some("extended") => SymbolCharset::Extended,
|
|
Some(other) => SymbolCharset::Custom(other.to_string()),
|
|
};
|
|
generate_password(&GeneratorRequest::Random {
|
|
length: length.unwrap_or(def_length),
|
|
classes: def_classes,
|
|
symbol_charset,
|
|
})?
|
|
};
|
|
|
|
println!("{}", output.as_str());
|
|
Ok(())
|
|
}
|
|
fn cmd_settings(action: SettingsAction) -> Result<()> {
|
|
use relicario_core::{
|
|
Capitalization, CharClasses, GeneratorRequest, HistoryRetention,
|
|
SymbolCharset, 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; }
|
|
}
|
|
SettingsAction::GeneratorDefaults {
|
|
random, bip39, length, words, symbols, separator,
|
|
} => {
|
|
// Decide target mode: explicit flag wins, else preserve current.
|
|
let target_bip39 = if random { false }
|
|
else if bip39 { true }
|
|
else { matches!(settings.generator_defaults, GeneratorRequest::Bip39 { .. }) };
|
|
|
|
// Pull existing fields where compatible, else seed with sensible
|
|
// defaults (kept in sync with `GeneratorRequest::default()`).
|
|
let (cur_length, cur_classes, cur_charset) = match &settings.generator_defaults {
|
|
GeneratorRequest::Random { length, classes, symbol_charset } => {
|
|
(*length, *classes, symbol_charset.clone())
|
|
}
|
|
_ => (
|
|
20,
|
|
CharClasses { lower: true, upper: true, digits: true, symbols: true },
|
|
SymbolCharset::SafeOnly,
|
|
),
|
|
};
|
|
let (cur_words, cur_sep, cur_cap) = match &settings.generator_defaults {
|
|
GeneratorRequest::Bip39 { word_count, separator, capitalization } => {
|
|
(*word_count, separator.clone(), *capitalization)
|
|
}
|
|
_ => (5, " ".to_string(), Capitalization::Lower),
|
|
};
|
|
|
|
settings.generator_defaults = if target_bip39 {
|
|
GeneratorRequest::Bip39 {
|
|
word_count: words.unwrap_or(cur_words),
|
|
separator: separator.unwrap_or(cur_sep),
|
|
capitalization: cur_cap,
|
|
}
|
|
} else {
|
|
let charset = match symbols.as_deref() {
|
|
None => cur_charset,
|
|
Some("safe") => SymbolCharset::SafeOnly,
|
|
Some("extended") => SymbolCharset::Extended,
|
|
Some(other) => SymbolCharset::Custom(other.to_string()),
|
|
};
|
|
GeneratorRequest::Random {
|
|
length: length.unwrap_or(cur_length),
|
|
classes: cur_classes,
|
|
symbol_charset: charset,
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
vault.save_settings(&settings)?;
|
|
commit_paths(&vault, "settings: update", &["settings.enc"])?;
|
|
eprintln!("Settings updated.");
|
|
Ok(())
|
|
}
|
|
fn cmd_sync() -> Result<()> {
|
|
let root = crate::helpers::vault_dir()?;
|
|
let pull = crate::helpers::git_command(&root, &["pull", "--rebase"]).status()?;
|
|
if !pull.success() { anyhow::bail!("git pull --rebase failed"); }
|
|
let push = crate::helpers::git_command(&root, &["push"]).status()?;
|
|
if !push.success() { anyhow::bail!("git push failed"); }
|
|
eprintln!("Sync complete.");
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_status() -> Result<()> {
|
|
use std::fs;
|
|
|
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
|
let root = vault.root().to_path_buf();
|
|
let manifest = vault.load_manifest()?;
|
|
|
|
let total_items = manifest.items.len();
|
|
let trashed_items = manifest.items.values().filter(|e| e.trashed_at.is_some()).count();
|
|
let active_items = total_items - trashed_items;
|
|
|
|
let (attachment_count, attachment_bytes) = manifest.items.values()
|
|
.flat_map(|e| e.attachment_summaries.iter())
|
|
.fold((0u64, 0u64), |(c, b), s| (c + 1, b + s.size));
|
|
|
|
// devices.json — count entries; missing/empty → 0.
|
|
let devices_path = root.join(".relicario").join("devices.json");
|
|
let device_count = match fs::read(&devices_path) {
|
|
Ok(bytes) => serde_json::from_slice::<serde_json::Value>(&bytes)
|
|
.ok()
|
|
.and_then(|v| v.as_array().map(|a| a.len()))
|
|
.unwrap_or(0),
|
|
Err(_) => 0,
|
|
};
|
|
|
|
let last_commit = crate::helpers::git_command(&root, &[
|
|
"log", "-1", "--pretty=format:%h %s",
|
|
]).output()
|
|
.ok()
|
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
|
.map(|s| s.trim().to_string())
|
|
.unwrap_or_else(|| "(no commits)".into());
|
|
|
|
// Last backup age (read from marker written by cmd_backup_export).
|
|
let last_backup_path = vault.root().join(".relicario").join("last_backup");
|
|
let last_backup_str = if last_backup_path.exists() {
|
|
let line = std::fs::read_to_string(&last_backup_path)
|
|
.unwrap_or_default()
|
|
.trim()
|
|
.to_string();
|
|
// Parse the ISO-8601 we wrote in cmd_backup_export.
|
|
match chrono::DateTime::parse_from_rfc3339(&line) {
|
|
Ok(then) => {
|
|
let now = relicario_core::now_unix();
|
|
let age = now - then.timestamp();
|
|
crate::helpers::humanize_age(age.max(0))
|
|
}
|
|
Err(_) => "unknown".to_string(),
|
|
}
|
|
} else {
|
|
"never".to_string()
|
|
};
|
|
|
|
println!("Vault: {}", root.display());
|
|
println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)");
|
|
println!("Attachments: {attachment_count} ({attachment_bytes} bytes)");
|
|
println!("Devices: {device_count}");
|
|
println!("Last commit: {last_commit}");
|
|
println!("Last export: {last_backup_str}");
|
|
Ok(())
|
|
}
|
|
fn cmd_device(action: DeviceAction) -> Result<()> {
|
|
use std::fs;
|
|
use ed25519_dalek::SigningKey;
|
|
use rand::rngs::OsRng;
|
|
|
|
let root = crate::helpers::vault_dir()?;
|
|
let devices_path = root.join(".relicario").join("devices.json");
|
|
|
|
#[derive(serde::Serialize, serde::Deserialize)]
|
|
struct DeviceEntry { name: String, public_key: String }
|
|
|
|
match action {
|
|
DeviceAction::Add { name } => {
|
|
let mut existing: Vec<DeviceEntry> =
|
|
serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default();
|
|
if existing.iter().any(|d| d.name == name) {
|
|
anyhow::bail!("device `{name}` already exists");
|
|
}
|
|
let signing = SigningKey::generate(&mut OsRng);
|
|
let verifying = signing.verifying_key();
|
|
let pubkey_hex = hex::encode(verifying.to_bytes());
|
|
|
|
existing.push(DeviceEntry { name: name.clone(), public_key: pubkey_hex.clone() });
|
|
fs::write(&devices_path, serde_json::to_string_pretty(&existing)?)?;
|
|
|
|
let cfg_dir = dirs::config_dir()
|
|
.ok_or_else(|| anyhow::anyhow!("no config dir"))?
|
|
.join("relicario").join("devices");
|
|
fs::create_dir_all(&cfg_dir)?;
|
|
let key_path = cfg_dir.join(format!("{name}.key"));
|
|
fs::write(&key_path, signing.to_bytes())?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?;
|
|
}
|
|
|
|
let status = crate::helpers::git_command(&root,
|
|
&["add", ".relicario/devices.json"]).status()?;
|
|
if !status.success() { anyhow::bail!("git add failed"); }
|
|
let status = crate::helpers::git_command(&root,
|
|
&["commit", "-m", &format!("device: add {name}")]).status()?;
|
|
if !status.success() { anyhow::bail!("git commit failed"); }
|
|
eprintln!("Added device `{name}` (pubkey: {pubkey_hex})");
|
|
}
|
|
DeviceAction::List => {
|
|
let existing: Vec<DeviceEntry> =
|
|
serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default();
|
|
if existing.is_empty() { eprintln!("(no devices)"); return Ok(()); }
|
|
for d in existing {
|
|
println!("{:<20} {}", d.name, d.public_key);
|
|
}
|
|
}
|
|
DeviceAction::Revoke { name } => {
|
|
let mut existing: Vec<DeviceEntry> =
|
|
serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default();
|
|
let before = existing.len();
|
|
existing.retain(|d| d.name != name);
|
|
if existing.len() == before { anyhow::bail!("device `{name}` not found"); }
|
|
fs::write(&devices_path, serde_json::to_string_pretty(&existing)?)?;
|
|
let status = crate::helpers::git_command(&root,
|
|
&["add", ".relicario/devices.json"]).status()?;
|
|
if !status.success() { anyhow::bail!("git add failed"); }
|
|
let status = crate::helpers::git_command(&root,
|
|
&["commit", "-m", &format!("device: revoke {name}")]).status()?;
|
|
if !status.success() { anyhow::bail!("git commit failed"); }
|
|
eprintln!("Revoked device `{name}`");
|
|
}
|
|
}
|
|
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,
|
|
}
|
|
|
|
fn cmd_rate(passphrase: String) -> Result<()> {
|
|
let pw: String = if passphrase == "-" {
|
|
use std::io::BufRead;
|
|
let stdin = std::io::stdin();
|
|
let mut line = String::new();
|
|
stdin.lock().read_line(&mut line)?;
|
|
line.trim_end_matches(&['\r', '\n'][..]).to_string()
|
|
} else {
|
|
passphrase
|
|
};
|
|
let est = relicario_core::generators::rate_passphrase(&pw);
|
|
let label = match est.score {
|
|
0 => "very weak",
|
|
1 => "weak",
|
|
2 => "fair",
|
|
3 => "good",
|
|
4 => "strong",
|
|
_ => "?",
|
|
};
|
|
println!("score: {}/4 ({})", est.score, label);
|
|
println!("guesses: ~10^{:.1}", est.guesses_log10);
|
|
println!("note: init requires score ≥ 3 (see `relicario init`)");
|
|
Ok(())
|
|
}
|