Resolves conflicts from merging origin/main (idfoto→relicario rename): - Kept Plan 1A's typed-item vault.rs, lib.rs, integration.rs over main's old entry-based versions - Took main's relicario_dir() fix in CLI main.rs (sed had missed idfoto_dir) - Kept Plan 1A's UnsupportedFormatVersion error variant in crypto.rs - Kept Plan 1A's opaque Decrypt message (audit M4) in error.rs - Deleted entry.rs (replaced by item.rs + typed modules in Plan 1A) - Resolved Cargo.toml description to main's "relicario password manager" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
893 lines
32 KiB
Rust
893 lines
32 KiB
Rust
//! 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
|
|
//! <vault_dir>/
|
|
//! .relicario/
|
|
//! salt # 32-byte random salt for Argon2id KDF
|
|
//! params.json # KDF tuning parameters (m, t, p)
|
|
//! devices.json # registered device public keys
|
|
//! entries/
|
|
//! <id>.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<KdfParams> {
|
|
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<PathBuf> {
|
|
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<Zeroizing<[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 =
|
|
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<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)
|
|
}
|
|
|
|
/// 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<String> {
|
|
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<Option<String>> {
|
|
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<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())
|
|
}
|
|
}
|
|
|
|
/// 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/<id>.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::<usize>().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<Vec<DeviceEntry>> {
|
|
let path = relicario_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)
|
|
}
|
|
|
|
/// 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/<name>.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),
|
|
},
|
|
}
|
|
}
|