From 15e6ed9c75c80ae1dca5578a09052673ee39a6e6 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 22:00:14 -0400 Subject: [PATCH] feat(cli): scaffold clap surface for all typed-item commands Every subcommand from the Plan 1B CLI spec present; bodies return 'not yet implemented' so subsequent tasks land one command at a time. Co-Authored-By: Claude Sonnet 4.6 --- crates/relicario-cli/src/main.rs | 1081 +++++++----------------------- 1 file changed, 230 insertions(+), 851 deletions(-) diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index a1d056b..33d20a6 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -1,895 +1,274 @@ -//! relicario CLI -- the platform layer for the relicario password manager. +//! relicario CLI — the platform layer for the relicario password manager. //! -//! This binary provides the filesystem, git, and terminal I/O that -//! [`relicario_core`] intentionally excludes. It is the "glue" between the -//! platform-agnostic core library and the user's local environment. -//! -//! ## Vault layout on disk -//! -//! ```text -//! / -//! .relicario/ -//! salt # 32-byte random salt for Argon2id KDF -//! params.json # KDF tuning parameters (m, t, p) -//! devices.json # registered device public keys -//! entries/ -//! .enc # individual encrypted entries -//! manifest.enc # encrypted entry index (name, url, username per entry) -//! .gitignore # excludes reference.jpg from version control -//! reference.jpg # the reference image with embedded secret (gitignored) -//! ``` -//! -//! ## Unlock flow -//! -//! Every command that accesses vault data follows this sequence: -//! -//! 1. Locate the reference image (via `RELICARIO_IMAGE` env var or interactive prompt). -//! 2. Prompt for the passphrase (read from stderr, not echoed). -//! 3. Extract the 32-byte image secret from the reference JPEG via DCT steganography. -//! 4. Read the vault salt and KDF params from `.relicario/`. -//! 5. Derive the master key: `Argon2id(passphrase || image_secret, salt, params)`. -//! 6. Use the master key to decrypt the manifest and/or individual entries. -//! -//! ## Git integration -//! -//! The CLI shells out to the `git` binary for all version control operations. -//! This avoids pulling in libgit2 or gitoxide as dependencies, keeping the -//! binary small and the build simple. Every mutation (add, edit, rm, device add/revoke) -//! creates a git commit, preserving an audit log of all vault changes. +//! See module docs for the unlock flow and vault layout. mod helpers; mod session; -use anyhow::{bail, Context, Result}; -use clap::{Parser, Subcommand}; -use relicario_core::{ - decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest, generate_entry_id, - Entry, KdfParams, Manifest, ManifestEntry, -}; -use zeroize::Zeroizing; -use rand::rngs::OsRng; -use rand::RngCore; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::io::{self, BufRead, Write}; use std::path::PathBuf; -use std::process::Command; -// ─── CLI structure ────────────────────────────────────────────────────────── +use anyhow::{bail, Result}; +use clap::{Parser, Subcommand}; -/// Top-level CLI argument parser. #[derive(Parser)] #[command( name = "relicario", version, - about = "Git-backed password manager with reference image authentication" + about = "Git-backed password manager with reference-image two-factor unlock" )] struct Cli { #[command(subcommand)] command: Commands, } -/// All available CLI subcommands. #[derive(Subcommand)] enum Commands { - /// Initialize a new relicario vault in the current directory. - /// Creates the directory structure, generates a random image secret, - /// embeds it in the carrier image, and sets up git. + /// Initialize a new vault in the current directory. Init { - /// Path to the carrier JPEG image to embed the secret into. + /// Carrier JPEG to embed the secret into. #[arg(long)] image: PathBuf, - /// Output path for the reference image (with embedded secret). + /// Output path for the reference image (gitignored). #[arg(long, default_value = "reference.jpg")] output: PathBuf, }, - /// Add a new password entry to the vault. - /// Prompts interactively for name, URL, username, password, notes, and TOTP. - Add, - /// Get a password entry by name (fuzzy search). - /// Decrypts and displays the full entry, and copies the password to clipboard - /// with a 30-second auto-clear. - Get { name: String }, - /// List all entries in the vault (names, URLs, usernames only -- no passwords). - List, - /// Edit an existing entry by name (fuzzy search). - /// Shows current values and lets you selectively update fields. - Edit { name: String }, - /// Remove an entry from the vault by name (fuzzy search). - /// Prompts for confirmation before deleting. - Rm { name: String }, - /// Sync the vault with the git remote (pull --rebase, then push). - Sync, - /// Generate a random password and print it to stdout. - Generate { - /// Length of the generated password in characters. - #[arg(short, long, default_value = "20")] - length: usize, + + /// Add a new item. Type-specific flags populate the core; missing fields + /// are prompted for interactively. + Add { + #[command(subcommand)] + kind: AddKind, }, - /// Manage device keys (add, list, revoke). - /// Device ed25519 keys are independent of the vault KDF -- revoking a device - /// does not require changing the passphrase or reference image. + + /// 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, + #[arg(long)] + group: Option, + #[arg(long)] + tag: Option, + #[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, + }, + + /// 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: DeviceCommands, + 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, + #[arg(long)] username: Option, + #[arg(long)] url: Option, + /// Prompt for password (vs reading from stdin or --password). + #[arg(long)] password_prompt: bool, + #[arg(long)] password: Option, + #[arg(long)] group: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + #[arg(long)] favorite: bool, + }, + SecureNote { + #[arg(long)] title: Option, + #[arg(long)] body_prompt: bool, + #[arg(long)] group: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, + Identity { + #[arg(long)] title: Option, + #[arg(long)] full_name: Option, + #[arg(long)] email: Option, + #[arg(long)] phone: Option, + #[arg(long)] date_of_birth: Option, + #[arg(long)] group: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, + Card { + #[arg(long)] title: Option, + #[arg(long)] holder: Option, + #[arg(long)] expiry: Option, // MM/YYYY + #[arg(long, default_value = "credit")] kind: String, + #[arg(long)] group: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, + Key { + #[arg(long)] title: Option, + #[arg(long)] label: Option, + #[arg(long)] algorithm: Option, + #[arg(long)] group: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, + Document { + #[arg(long)] title: Option, + #[arg(long)] file: PathBuf, + #[arg(long)] group: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, + Totp { + #[arg(long)] title: Option, + #[arg(long)] issuer: Option, + #[arg(long)] label: Option, + #[arg(long)] secret: Option, // 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, + #[arg(long, value_delimiter = ',')] tags: Vec, }, } -/// Subcommands for device key management. #[derive(Subcommand)] -enum DeviceCommands { - /// Register a new device by generating an ed25519 keypair. - /// The private key is saved to the user's config directory; - /// the public key is added to the vault's devices.json. - Add { - /// Human-readable name for this device (e.g., "macbook", "phone"). - #[arg(long)] - name: String, - }, - /// List all registered devices and their public keys. +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, + #[arg(long)] forever: bool, + }, + /// Set field history retention. + HistoryRetention { + #[arg(long)] last_n: Option, + #[arg(long)] days: Option, + #[arg(long)] forever: bool, + }, + /// Set per-attachment max size in bytes. + AttachmentCap { + #[arg(long)] per_attachment_max_bytes: Option, + #[arg(long)] per_item_max_count: Option, + #[arg(long)] per_vault_soft_cap_bytes: Option, + #[arg(long)] per_vault_hard_cap_bytes: Option, + }, +} + +#[derive(Subcommand)] +enum DeviceAction { + Add { #[arg(long)] name: String }, List, - /// Revoke a device by removing its public key from devices.json. - /// This does NOT rotate the vault key -- the device can no longer - /// authenticate, but the vault encryption is unchanged. Revoke { name: String }, } -// ─── Device entry ─────────────────────────────────────────────────────────── - -/// A registered device, stored in `.relicario/devices.json`. -/// -/// Each device has an ed25519 keypair. The private key lives on the device -/// itself (in the user's config directory); only the public key is stored -/// in the vault. This separation means revoking a device is a metadata-only -/// operation that does not affect the vault's encryption key. -#[derive(Debug, Clone, Serialize, Deserialize)] -struct DeviceEntry { - /// Human-readable device name (e.g., "macbook-pro", "pixel-7"). - name: String, - /// Hex-encoded ed25519 public key (64 hex chars = 32 bytes). - public_key: String, // hex-encoded -} - -// ─── Helper functions ─────────────────────────────────────────────────────── - -/// Returns the vault root directory (the current working directory). -/// The vault is always rooted at the directory where `relicario` is invoked. -fn vault_dir() -> PathBuf { - std::env::current_dir().expect("failed to get current directory") -} - -/// Returns the path to the `.relicario/` configuration directory within the vault. -fn relicario_dir() -> PathBuf { - vault_dir().join(".relicario") -} - -/// Read the 32-byte vault salt from `.relicario/salt`. -/// -/// The salt is generated once during `init` and is unique per vault. It is -/// not secret (stored in plaintext) -- its purpose is to prevent precomputed -/// rainbow table attacks against the Argon2id KDF. -fn read_salt() -> Result<[u8; 32]> { - let data = fs::read(relicario_dir().join("salt")).context("failed to read salt")?; - let mut salt = [0u8; 32]; - if data.len() != 32 { - bail!("invalid salt file: expected 32 bytes, got {}", data.len()); - } - salt.copy_from_slice(&data); - Ok(salt) -} - -/// Read the KDF parameters from `.relicario/params.json`. -fn read_params() -> Result { - let data = fs::read_to_string(relicario_dir().join("params.json")) - .context("failed to read params.json")?; - let params: KdfParams = serde_json::from_str(&data).context("failed to parse params.json")?; - Ok(params) -} - -/// Locate the reference image path. -/// -/// First checks the `RELICARIO_IMAGE` environment variable (useful for scripting -/// and testing). If not set, prompts the user interactively. -fn get_image_path() -> Result { - if let Ok(path) = std::env::var("RELICARIO_IMAGE") { - return Ok(PathBuf::from(path)); - } - let path = prompt("Reference image path")?; - Ok(PathBuf::from(path)) -} - -/// Perform the two-factor unlock sequence and return the derived master key. -/// -/// This is the core authentication flow used by every vault-access command: -/// 1. Prompt for the passphrase (via rpassword, not echoed to terminal). -/// 2. Read and decode the reference JPEG, extracting the steganographic secret. -/// 3. Load the vault salt and KDF params. -/// 4. Derive the master key via Argon2id(passphrase || image_secret, salt). -fn unlock(image_path: &PathBuf) -> Result> { - let passphrase = rpassword::prompt_password_stderr("Passphrase: ").context("failed to read passphrase")?; - - let jpeg_data = fs::read(image_path).context("failed to read reference image")?; - let image_secret = - relicario_core::imgsecret::extract(&jpeg_data).context("failed to extract image secret")?; - - let salt = read_salt()?; - let params = read_params()?; - - let master_key = relicario_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms) - .context("failed to derive master key")?; - - Ok(master_key) -} - -/// Decrypt and return the vault manifest. -fn read_manifest(key: &[u8; 32]) -> Result { - let data = fs::read(vault_dir().join("manifest.enc")).context("failed to read manifest.enc")?; - let manifest = decrypt_manifest(key, &data).context("failed to decrypt manifest")?; - Ok(manifest) -} - -/// Encrypt and write the vault manifest to disk. -fn write_manifest(key: &[u8; 32], manifest: &Manifest) -> Result<()> { - let data = encrypt_manifest(key, manifest).context("failed to encrypt manifest")?; - fs::write(vault_dir().join("manifest.enc"), data).context("failed to write manifest.enc")?; - Ok(()) -} - -/// Stage all changes and create a git commit with the given message. -/// -/// Every vault mutation is committed to preserve a full audit log in git history. -/// The CLI shells out to the `git` binary rather than using a Rust git library -/// to keep dependencies minimal. -fn git_commit(message: &str) -> Result<()> { - let status = Command::new("git") - .args(["add", "-A"]) - .status() - .context("failed to run git add")?; - if !status.success() { - bail!("git add failed"); - } - - let status = Command::new("git") - .args(["commit", "-m", message]) - .status() - .context("failed to run git commit")?; - if !status.success() { - bail!("git commit failed"); - } - - Ok(()) -} - -/// Return the current time as a Unix timestamp string. -/// -/// Uses seconds since epoch rather than a formatted ISO 8601 string to avoid -/// pulling in chrono or time crate dependencies. -fn now_iso8601() -> String { - let duration = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default(); - format!("{}", duration.as_secs()) -} - -/// Prompt the user for input via stderr (so stdout remains clean for piping). -fn prompt(message: &str) -> Result { - eprint!("{}: ", message); - io::stderr().flush()?; - let mut line = String::new(); - io::stdin().lock().read_line(&mut line)?; - Ok(line.trim().to_string()) -} - -/// Prompt for an optional field. Returns `None` if the user enters an empty string. -fn prompt_optional(message: &str) -> Result> { - let value = prompt(message)?; - if value.is_empty() { - Ok(None) - } else { - Ok(Some(value)) - } -} - -/// Prompt for a field with a default value shown in brackets. -/// If the user presses Enter without typing, the current value is kept. -fn prompt_with_default(field: &str, current: &str) -> Result { - eprint!("{} [{}]: ", field, current); - io::stderr().flush()?; - let mut line = String::new(); - io::stdin().lock().read_line(&mut line)?; - let trimmed = line.trim(); - if trimmed.is_empty() { - Ok(current.to_string()) - } else { - Ok(trimmed.to_string()) - } -} - -/// Generate a random password of the given length using a mixed character set. -/// -/// The charset includes lowercase, uppercase, digits, and common symbols. -/// Each character is selected uniformly at random via the OS CSPRNG. -fn generate_password(length: usize) -> String { - const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+"; - let mut rng = OsRng; - (0..length) - .map(|_| { - let idx = (rng.next_u32() as usize) % CHARSET.len(); - CHARSET[idx] as char - }) - .collect() -} - -// ─── Command implementations ──────────────────────────────────────────────── - -/// Initialize a new relicario vault in the current directory. -/// -/// Full sequence: -/// 1. Read the carrier JPEG provided by the user. -/// 2. Generate a random 32-byte image secret. -/// 3. Embed the secret into the carrier via DCT steganography. -/// 4. Save the resulting reference JPEG (this is the user's second factor). -/// 5. Prompt for a passphrase (minimum 8 characters, with confirmation). -/// 6. Generate a random 32-byte salt. -/// 7. Derive the master key from passphrase + image_secret + salt. -/// 8. Create the vault directory structure (.relicario/, entries/). -/// 9. Write salt, KDF params, empty devices list, and encrypted empty manifest. -/// 10. Initialize git and create the first commit. -fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { - // 1. Read carrier JPEG - let carrier = fs::read(&image).context("failed to read carrier image")?; - - // 2. Generate random image_secret - let mut image_secret = [0u8; 32]; - OsRng.fill_bytes(&mut image_secret); - - // 3. Embed secret into carrier - let reference_jpeg = - relicario_core::imgsecret::embed(&carrier, &image_secret).context("failed to embed secret")?; - - // 4. Save reference JPEG - fs::write(&output, &reference_jpeg).context("failed to write reference image")?; - eprintln!("Reference image saved to {}", output.display()); - - // 5. Prompt for passphrase - let passphrase = loop { - let p1 = rpassword::prompt_password_stderr("Passphrase (min 8 chars): ") - .context("failed to read passphrase")?; - if p1.len() < 8 { - eprintln!("Passphrase must be at least 8 characters."); - continue; - } - let p2 = rpassword::prompt_password_stderr("Confirm passphrase: ") - .context("failed to read passphrase confirmation")?; - if p1 != p2 { - eprintln!("Passphrases do not match."); - continue; - } - break p1; - }; - - // 6. Generate random salt - let mut salt = [0u8; 32]; - OsRng.fill_bytes(&mut salt); - - // 7. Derive master key - let params = KdfParams::default(); - let master_key = relicario_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms) - .context("failed to derive master key")?; - - // 8. Create directory structure - let relicario = relicario_dir(); - fs::create_dir_all(&relicario).context("failed to create .relicario directory")?; - fs::create_dir_all(vault_dir().join("entries")).context("failed to create entries directory")?; - - // 9. Write config files - fs::write(relicario.join("salt"), &salt).context("failed to write salt")?; - fs::write( - relicario.join("params.json"), - serde_json::to_string_pretty(¶ms)?, - ) - .context("failed to write params.json")?; - fs::write(relicario.join("devices.json"), "[]").context("failed to write devices.json")?; - - // 10. Encrypt empty manifest - let manifest = Manifest::new(); - let manifest_enc = encrypt_manifest(&*master_key, &manifest).context("failed to encrypt manifest")?; - fs::write(vault_dir().join("manifest.enc"), manifest_enc) - .context("failed to write manifest.enc")?; - - // 11. Create .gitignore (exclude reference image from version control -- - // it contains the steganographic secret and must be kept offline) - fs::write(vault_dir().join(".gitignore"), "reference.jpg\n") - .context("failed to write .gitignore")?; - - // 12. Git init and commit - let status = Command::new("git").arg("init").status()?; - if !status.success() { - bail!("git init failed"); - } - git_commit("feat: initialize relicario vault")?; - - // 13. Success - eprintln!("Vault initialized successfully."); - eprintln!("IMPORTANT: Keep your reference image safe — you need it to unlock the vault."); - - Ok(()) -} - -/// Generate a random password and print it to stdout. -fn cmd_generate(length: usize) -> Result<()> { - println!("{}", generate_password(length)); - Ok(()) -} - -/// Add a new entry to the vault. -/// -/// Prompts for all fields, encrypts the entry, writes it to `entries/.enc`, -/// updates the manifest, and commits the change to git. -fn cmd_add() -> Result<()> { - let image_path = get_image_path()?; - let master_key = unlock(&image_path)?; - - let name = prompt("Name")?; - if name.is_empty() { - bail!("Name cannot be empty"); - } - - let url = prompt_optional("URL (optional)")?; - let username = prompt_optional("Username (optional)")?; - - let password = { - let p = prompt_optional("Password (Enter to auto-generate)")?; - match p { - Some(pw) if !pw.is_empty() => pw, - _ => { - let gen = generate_password(20); - eprintln!("Generated password: {}", gen); - gen - } - } - }; - - let notes = prompt_optional("Notes (optional)")?; - let totp_secret = prompt_optional("TOTP secret (optional)")?; - - let now = now_iso8601(); - let entry = Entry { - name: name.clone(), - url: url.clone(), - username: username.clone(), - password, - notes, - totp_secret, - group: None, - created_at: now.clone(), - updated_at: now.clone(), - }; - - let entry_id = generate_entry_id(); - let encrypted = encrypt_entry(&*master_key, &entry).context("failed to encrypt entry")?; - fs::write( - vault_dir().join("entries").join(format!("{}.enc", entry_id)), - encrypted, - ) - .context("failed to write entry file")?; - - let mut manifest = read_manifest(&*master_key)?; - manifest.add_entry( - entry_id.clone(), - ManifestEntry { - name: name.clone(), - url, - username, - group: None, - updated_at: now, - }, - ); - write_manifest(&*master_key, &manifest)?; - - git_commit(&format!("feat: add entry '{}'", name))?; - eprintln!("Entry '{}' added (id: {})", name, entry_id); - - Ok(()) -} - -/// Search the manifest for entries matching a query and let the user select one. -/// -/// If exactly one entry matches, it is returned immediately. If multiple match, -/// the user is shown a numbered list and prompted to choose. -fn search_and_select(manifest: &Manifest, query: &str) -> Result<(String, ManifestEntry)> { - let results = manifest.search(query); - if results.is_empty() { - bail!("no entries matching '{}'", query); - } - - if results.len() == 1 { - let (id, entry) = results[0]; - return Ok((id.clone(), entry.clone())); - } - - eprintln!("Multiple matches:"); - for (i, (id, entry)) in results.iter().enumerate() { - eprintln!( - " {}) {} (id: {}, url: {})", - i + 1, - entry.name, - id, - entry.url.as_deref().unwrap_or("-") - ); - } - - let choice = prompt("Choose entry number")?; - let idx: usize = choice.parse::().context("invalid number")? - 1; - if idx >= results.len() { - bail!("invalid selection"); - } - - let (id, entry) = results[idx]; - Ok((id.clone(), entry.clone())) -} - -/// Retrieve and display a vault entry, and copy its password to the clipboard. -/// -/// The password is auto-cleared from the clipboard after 30 seconds to limit -/// exposure. The clipboard clear is best-effort (a background thread checks -/// whether the clipboard still contains the password before clearing). -fn cmd_get(query: String) -> Result<()> { - let image_path = get_image_path()?; - let master_key = unlock(&image_path)?; - - let manifest = read_manifest(&*master_key)?; - let (entry_id, _) = search_and_select(&manifest, &query)?; - - let data = fs::read(vault_dir().join("entries").join(format!("{}.enc", entry_id))) - .context("failed to read entry file")?; - let entry = decrypt_entry(&*master_key, &data).context("failed to decrypt entry")?; - - println!("Name: {}", entry.name); - println!( - "URL: {}", - entry.url.as_deref().unwrap_or("-") - ); - println!( - "Username: {}", - entry.username.as_deref().unwrap_or("-") - ); - println!("Password: {}", entry.password); - if let Some(notes) = &entry.notes { - println!("Notes: {}", notes); - } - if let Some(totp) = &entry.totp_secret { - println!("TOTP: {}", totp); - } - - // Copy password to clipboard with 30s TTL. - // Uses arboard for cross-platform clipboard access. - // The clear is done in a background thread: after 30 seconds, if the - // clipboard still contains this password, it is replaced with an empty string. - match arboard::Clipboard::new() { - Ok(mut clipboard) => { - if clipboard.set_text(&entry.password).is_ok() { - eprintln!("Password copied to clipboard (clearing in 30s)"); - let pw = entry.password.clone(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(30)); - if let Ok(mut cb) = arboard::Clipboard::new() { - if let Ok(current) = cb.get_text() { - if current == pw { - let _ = cb.set_text(""); - } - } - } - }); - } - } - Err(_) => { - eprintln!("(clipboard unavailable)"); - } - } - - Ok(()) -} - -/// List all vault entries in alphabetical order. -/// -/// Only shows non-sensitive metadata (name, URL, username) from the manifest. -/// Individual entry files are not decrypted. -fn cmd_list() -> Result<()> { - let image_path = get_image_path()?; - let master_key = unlock(&image_path)?; - - let manifest = read_manifest(&*master_key)?; - - let mut entries: Vec<_> = manifest.entries.iter().collect(); - entries.sort_by(|a, b| a.1.name.to_lowercase().cmp(&b.1.name.to_lowercase())); - - if entries.is_empty() { - eprintln!("No entries in vault."); - return Ok(()); - } - - println!("{:<10} {:<30} {:<30} {}", "ID", "Name", "URL", "Username"); - println!("{}", "-".repeat(80)); - for (id, entry) in entries { - println!( - "{:<10} {:<30} {:<30} {}", - id, - entry.name, - entry.url.as_deref().unwrap_or("-"), - entry.username.as_deref().unwrap_or("-") - ); - } - - Ok(()) -} - -/// Edit an existing entry by searching for it, showing current values, and -/// prompting for new values. Unchanged fields keep their current value. -fn cmd_edit(query: String) -> Result<()> { - let image_path = get_image_path()?; - let master_key = unlock(&image_path)?; - - let manifest = read_manifest(&*master_key)?; - let (entry_id, _) = search_and_select(&manifest, &query)?; - - let data = fs::read(vault_dir().join("entries").join(format!("{}.enc", entry_id))) - .context("failed to read entry file")?; - let entry = decrypt_entry(&*master_key, &data).context("failed to decrypt entry")?; - - eprintln!("Editing '{}' (Enter to keep current value)", entry.name); - - let name = prompt_with_default("Name", &entry.name)?; - let url = prompt_with_default("URL", entry.url.as_deref().unwrap_or(""))?; - let url = if url.is_empty() { None } else { Some(url) }; - let username = prompt_with_default("Username", entry.username.as_deref().unwrap_or(""))?; - let username = if username.is_empty() { - None - } else { - Some(username) - }; - let password = prompt_with_default("Password", &entry.password)?; - let notes = prompt_with_default("Notes", entry.notes.as_deref().unwrap_or(""))?; - let notes = if notes.is_empty() { None } else { Some(notes) }; - let totp_secret = prompt_with_default("TOTP secret", entry.totp_secret.as_deref().unwrap_or(""))?; - let totp_secret = if totp_secret.is_empty() { - None - } else { - Some(totp_secret) - }; - - let now = now_iso8601(); - let updated_entry = Entry { - name: name.clone(), - url: url.clone(), - username: username.clone(), - password, - notes, - totp_secret, - group: entry.group, - created_at: entry.created_at, - updated_at: now.clone(), - }; - - let encrypted = encrypt_entry(&*master_key, &updated_entry).context("failed to encrypt entry")?; - fs::write( - vault_dir().join("entries").join(format!("{}.enc", entry_id)), - encrypted, - ) - .context("failed to write entry file")?; - - let mut manifest = read_manifest(&*master_key)?; - manifest.add_entry( - entry_id, - ManifestEntry { - name: name.clone(), - url, - username, - group: updated_entry.group, - updated_at: now, - }, - ); - write_manifest(&*master_key, &manifest)?; - - git_commit(&format!("feat: edit entry '{}'", name))?; - eprintln!("Entry '{}' updated.", name); - - Ok(()) -} - -/// Remove an entry from the vault after confirmation. -/// -/// Deletes the encrypted entry file, removes the entry from the manifest, -/// and commits the change to git. -fn cmd_rm(query: String) -> Result<()> { - let image_path = get_image_path()?; - let master_key = unlock(&image_path)?; - - let manifest = read_manifest(&*master_key)?; - let (entry_id, entry) = search_and_select(&manifest, &query)?; - - let confirm = prompt(&format!("Delete '{}' (id: {})? [y/N]", entry.name, entry_id))?; - if confirm.to_lowercase() != "y" { - eprintln!("Cancelled."); - return Ok(()); - } - - let entry_path = vault_dir() - .join("entries") - .join(format!("{}.enc", entry_id)); - if entry_path.exists() { - fs::remove_file(&entry_path).context("failed to remove entry file")?; - } - - let mut manifest = read_manifest(&*master_key)?; - manifest.remove_entry(&entry_id); - write_manifest(&*master_key, &manifest)?; - - git_commit(&format!("feat: remove entry '{}'", entry.name))?; - eprintln!("Entry '{}' removed.", entry.name); - - Ok(()) -} - -/// Sync the vault with the git remote. -/// -/// Performs `git pull --rebase` followed by `git push`. Rebase is used instead -/// of merge to keep the commit history linear, which is important for the -/// audit log use case. -fn cmd_sync() -> Result<()> { - eprintln!("Pulling..."); - let status = Command::new("git") - .args(["pull", "--rebase"]) - .status() - .context("failed to run git pull")?; - if !status.success() { - bail!("git pull --rebase failed"); - } - - eprintln!("Pushing..."); - let status = Command::new("git") - .arg("push") - .status() - .context("failed to run git push")?; - if !status.success() { - bail!("git push failed"); - } - - eprintln!("Sync complete."); - Ok(()) -} - -// ─── Device management ────────────────────────────────────────────────────── - -/// Read the device registry from `.relicario/devices.json`. -fn read_devices() -> Result> { - let path = relicario_dir().join("devices.json"); - let data = fs::read_to_string(&path).context("failed to read devices.json")?; - let devices: Vec = serde_json::from_str(&data).context("failed to parse devices.json")?; - Ok(devices) -} - -/// Write the device registry to `.relicario/devices.json`. -fn write_devices(devices: &[DeviceEntry]) -> Result<()> { - let data = serde_json::to_string_pretty(devices)?; - fs::write(relicario_dir().join("devices.json"), data).context("failed to write devices.json")?; - Ok(()) -} - -/// Register a new device by generating an ed25519 keypair. -/// -/// The private key is saved to `~/.config/relicario/.key` with -/// restrictive permissions (0600 on Unix). The public key is added to -/// the vault's devices.json and committed to git. -/// -/// Device keys are independent of the vault encryption key -- revoking a -/// device does not require rotating the passphrase or reference image. -fn cmd_device_add(name: String) -> Result<()> { - use ed25519_dalek::SigningKey; - - let mut devices = read_devices()?; - - // Check for duplicate device names - if devices.iter().any(|d| d.name == name) { - bail!("device '{}' already exists", name); - } - - // Generate ed25519 keypair using the OS CSPRNG - let signing_key = SigningKey::generate(&mut OsRng); - let verifying_key = signing_key.verifying_key(); - - let private_key_hex = hex::encode(signing_key.to_bytes()); - let public_key_hex = hex::encode(verifying_key.to_bytes()); - - // Save private key to the user's config directory (NOT in the vault) - let config_dir = dirs::config_dir() - .context("failed to find config directory")? - .join("relicario"); - fs::create_dir_all(&config_dir).context("failed to create config directory")?; - let key_path = config_dir.join(format!("{}.key", name)); - fs::write(&key_path, &private_key_hex).context("failed to write private key")?; - - // Set restrictive permissions on the key file (Unix only) - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?; - } - - // Add public key to the vault's device registry - devices.push(DeviceEntry { - name: name.clone(), - public_key: public_key_hex, - }); - write_devices(&devices)?; - - git_commit(&format!("feat: add device '{}'", name))?; - eprintln!("Device '{}' added.", name); - eprintln!("Private key saved to {}", key_path.display()); - - Ok(()) -} - -/// List all registered devices with their public keys. -fn cmd_device_list() -> Result<()> { - let devices = read_devices()?; - - if devices.is_empty() { - eprintln!("No devices registered."); - return Ok(()); - } - - println!("{:<20} {}", "Name", "Public Key"); - println!("{}", "-".repeat(60)); - for device in &devices { - println!("{:<20} {}", device.name, device.public_key); - } - - Ok(()) -} - -/// Revoke a device by removing it from the device registry. -/// -/// This is a metadata-only operation: the device's public key is removed from -/// devices.json, but the vault encryption key is NOT rotated. The revoked -/// device can no longer authenticate via its ed25519 key, but if it had -/// previously derived the master key (via passphrase + image), that key -/// remains valid until the user changes their passphrase or reference image. -fn cmd_device_revoke(name: String) -> Result<()> { - let mut devices = read_devices()?; - let initial_len = devices.len(); - devices.retain(|d| d.name != name); - - if devices.len() == initial_len { - bail!("device '{}' not found", name); - } - - write_devices(&devices)?; - git_commit(&format!("feat: revoke device '{}'", name))?; - eprintln!("Device '{}' revoked.", name); - - Ok(()) -} - -// ─── Main ─────────────────────────────────────────────────────────────────── - -/// Entry point: parse CLI arguments and dispatch to the appropriate command handler. fn main() -> Result<()> { let cli = Cli::parse(); - match cli.command { Commands::Init { image, output } => cmd_init(image, output), - Commands::Add => cmd_add(), - Commands::Get { name } => cmd_get(name), - Commands::List => cmd_list(), - Commands::Edit { name } => cmd_edit(name), - Commands::Rm { name } => cmd_rm(name), + 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::Generate { length } => cmd_generate(length), - Commands::Device { action } => match action { - DeviceCommands::Add { name } => cmd_device_add(name), - DeviceCommands::List => cmd_device_list(), - DeviceCommands::Revoke { name } => cmd_device_revoke(name), - }, + Commands::Device { action } => cmd_device(action), + Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) } } } + +fn cmd_init(_image: PathBuf, _output: PathBuf) -> Result<()> { bail!("not yet implemented"); } +fn cmd_add(_kind: AddKind) -> Result<()> { bail!("not yet implemented"); } +fn cmd_get(_query: String, _show: bool, _copy: bool) -> Result<()> { bail!("not yet implemented"); } +fn cmd_list(_t: Option, _g: Option, _tag: Option, _trashed: bool) -> Result<()> { bail!("not yet implemented"); } +fn cmd_edit(_query: String) -> Result<()> { bail!("not yet implemented"); } +fn cmd_rm(_query: String) -> Result<()> { bail!("not yet implemented"); } +fn cmd_restore(_query: String) -> Result<()> { bail!("not yet implemented"); } +fn cmd_purge(_query: String) -> Result<()> { bail!("not yet implemented"); } +fn cmd_trash(_action: TrashAction) -> Result<()> { bail!("not yet implemented"); } +fn cmd_attach(_q: String, _file: PathBuf) -> Result<()> { bail!("not yet implemented"); } +fn cmd_attachments(_q: String) -> Result<()> { bail!("not yet implemented"); } +fn cmd_extract(_q: String, _aid: String, _out: Option) -> Result<()> { bail!("not yet implemented"); } +fn cmd_generate(_l: u32, _b: bool, _w: u32, _s: String, _sep: String) -> Result<()> { bail!("not yet implemented"); } +fn cmd_settings(_a: SettingsAction) -> Result<()> { bail!("not yet implemented"); } +fn cmd_sync() -> Result<()> { bail!("not yet implemented"); } +fn cmd_device(_a: DeviceAction) -> Result<()> { bail!("not yet implemented"); }