feat: add full CLI with all commands and device management

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-11 23:13:08 -04:00
parent 1e08055d8d
commit 87167e31a5
2 changed files with 720 additions and 2 deletions

View File

@@ -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"

View File

@@ -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<KdfParams> {
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<PathBuf> {
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, &params)
.context("failed to derive master key")?;
Ok(master_key)
}
fn read_manifest(key: &[u8; 32]) -> Result<Manifest> {
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<String> {
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<Option<String>> {
let value = prompt(message)?;
if value.is_empty() {
Ok(None)
} else {
Ok(Some(value))
}
}
fn prompt_with_default(field: &str, current: &str) -> Result<String> {
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, &params)
.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(&params)?,
)
.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::<usize>().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<Vec<DeviceEntry>> {
let path = idfoto_dir().join("devices.json");
let data = fs::read_to_string(&path).context("failed to read devices.json")?;
let devices: Vec<DeviceEntry> = 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),
},
}
}