From 87167e31a5e9863821d9d14074c682628b1f9161 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 23:13:08 -0400 Subject: [PATCH] feat: add full CLI with all commands and device management Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/idfoto-cli/Cargo.toml | 5 + crates/idfoto-cli/src/main.rs | 717 +++++++++++++++++++++++++++++++++- 2 files changed, 720 insertions(+), 2 deletions(-) diff --git a/crates/idfoto-cli/Cargo.toml b/crates/idfoto-cli/Cargo.toml index 2f2176d..7b91654 100644 --- a/crates/idfoto-cli/Cargo.toml +++ b/crates/idfoto-cli/Cargo.toml @@ -15,3 +15,8 @@ anyhow = "1" rpassword = "5" arboard = "3" dirs = "5" +hex = "0.4" +ed25519-dalek = { version = "2", features = ["rand_core"] } +rand = "0.8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/crates/idfoto-cli/src/main.rs b/crates/idfoto-cli/src/main.rs index 2c6e3ff..e31c623 100644 --- a/crates/idfoto-cli/src/main.rs +++ b/crates/idfoto-cli/src/main.rs @@ -1,3 +1,716 @@ -fn main() { - println!("idfoto v0.1.0"); +use anyhow::{bail, Context, Result}; +use clap::{Parser, Subcommand}; +use idfoto_core::{ + decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest, generate_entry_id, + Entry, KdfParams, Manifest, ManifestEntry, +}; +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 ────────────────────────────────────────────────────────── + +#[derive(Parser)] +#[command( + name = "idfoto", + version, + about = "Git-backed password manager with reference image authentication" +)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Initialize a new idfoto vault + Init { + #[arg(long)] + image: PathBuf, + #[arg(long, default_value = "reference.jpg")] + output: PathBuf, + }, + /// Add a new password entry + Add, + /// Get a password entry by name + Get { name: String }, + /// List all entries + List, + /// Edit an existing entry + Edit { name: String }, + /// Remove an entry + Rm { name: String }, + /// Sync vault with git remote + Sync, + /// Generate a random password + Generate { + #[arg(short, long, default_value = "20")] + length: usize, + }, + /// Manage devices + Device { + #[command(subcommand)] + action: DeviceCommands, + }, +} + +#[derive(Subcommand)] +enum DeviceCommands { + /// Add a new device + Add { + #[arg(long)] + name: String, + }, + /// List registered devices + List, + /// Revoke a device + Revoke { name: String }, +} + +// ─── Device entry ─────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct DeviceEntry { + name: String, + public_key: String, // hex-encoded +} + +// ─── Helper functions ─────────────────────────────────────────────────────── + +fn vault_dir() -> PathBuf { + std::env::current_dir().expect("failed to get current directory") +} + +fn idfoto_dir() -> PathBuf { + vault_dir().join(".idfoto") +} + +fn read_salt() -> Result<[u8; 32]> { + let data = fs::read(idfoto_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) +} + +fn read_params() -> Result { + let data = fs::read_to_string(idfoto_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) +} + +fn get_image_path() -> Result { + if let Ok(path) = std::env::var("IDFOTO_IMAGE") { + return Ok(PathBuf::from(path)); + } + let path = prompt("Reference image path")?; + Ok(PathBuf::from(path)) +} + +fn unlock(image_path: &PathBuf) -> Result<[u8; 32]> { + 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 = + idfoto_core::imgsecret::extract(&jpeg_data).context("failed to extract image secret")?; + + let salt = read_salt()?; + let params = read_params()?; + + let master_key = idfoto_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms) + .context("failed to derive master key")?; + + Ok(master_key) +} + +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) +} + +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(()) +} + +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(()) +} + +fn now_iso8601() -> String { + let duration = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + format!("{}", duration.as_secs()) +} + +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()) +} + +fn prompt_optional(message: &str) -> Result> { + let value = prompt(message)?; + if value.is_empty() { + Ok(None) + } else { + Ok(Some(value)) + } +} + +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()) + } +} + +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 ──────────────────────────────────────────────── + +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 = + idfoto_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 = idfoto_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms) + .context("failed to derive master key")?; + + // 8. Create directory structure + let idfoto = idfoto_dir(); + fs::create_dir_all(&idfoto).context("failed to create .idfoto directory")?; + fs::create_dir_all(vault_dir().join("entries")).context("failed to create entries directory")?; + + // 9. Write config files + fs::write(idfoto.join("salt"), &salt).context("failed to write salt")?; + fs::write( + idfoto.join("params.json"), + serde_json::to_string_pretty(¶ms)?, + ) + .context("failed to write params.json")?; + fs::write(idfoto.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 + 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 idfoto vault")?; + + // 13. Success + eprintln!("Vault initialized successfully."); + eprintln!("IMPORTANT: Keep your reference image safe — you need it to unlock the vault."); + + Ok(()) +} + +fn cmd_generate(length: usize) -> Result<()> { + println!("{}", generate_password(length)); + Ok(()) +} + +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, + 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, + updated_at: now, + }, + ); + write_manifest(&master_key, &manifest)?; + + git_commit(&format!("feat: add entry '{}'", name))?; + eprintln!("Entry '{}' added (id: {})", name, entry_id); + + Ok(()) +} + +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())) +} + +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 + 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(()) +} + +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(()) +} + +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, + 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, + updated_at: now, + }, + ); + write_manifest(&master_key, &manifest)?; + + git_commit(&format!("feat: edit entry '{}'", name))?; + eprintln!("Entry '{}' updated.", name); + + Ok(()) +} + +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(()) +} + +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 ────────────────────────────────────────────────────── + +fn read_devices() -> Result> { + let path = idfoto_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) +} + +fn write_devices(devices: &[DeviceEntry]) -> Result<()> { + let data = serde_json::to_string_pretty(devices)?; + fs::write(idfoto_dir().join("devices.json"), data).context("failed to write devices.json")?; + Ok(()) +} + +fn cmd_device_add(name: String) -> Result<()> { + use ed25519_dalek::SigningKey; + + let mut devices = read_devices()?; + + // Check for duplicate + if devices.iter().any(|d| d.name == name) { + bail!("device '{}' already exists", name); + } + + // Generate ed25519 keypair + 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 + let config_dir = dirs::config_dir() + .context("failed to find config directory")? + .join("idfoto"); + 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 to devices.json + 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(()) +} + +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(()) +} + +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 ─────────────────────────────────────────────────────────────────── + +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), + }, + } }