//! 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. 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 ────────────────────────────────────────────────────────── /// Top-level CLI argument parser. #[derive(Parser)] #[command( name = "relicario", version, about = "Git-backed password manager with reference image authentication" )] 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. Init { /// Path to the carrier JPEG image to embed the secret into. #[arg(long)] image: PathBuf, /// Output path for the reference image (with embedded secret). #[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, }, /// 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. Device { #[command(subcommand)] action: DeviceCommands, }, } /// 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. 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::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), }, } }