diff --git a/.gitignore b/.gitignore index 02e4099..57901de 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,9 @@ +target/ +.superpowers/ +.worktrees/ +extension/node_modules/ +extension/dist/ +extension/dist-firefox/ +extension/wasm/ +reference.jpg ref.jpg diff --git a/.relicario/devices.json b/.relicario/devices.json deleted file mode 100644 index 0637a08..0000000 --- a/.relicario/devices.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/.relicario/params.json b/.relicario/params.json deleted file mode 100644 index 8676580..0000000 --- a/.relicario/params.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "format_version": 2, - "kdf": { - "algorithm": "argon2id-v0x13", - "argon2_m": 65536, - "argon2_t": 3, - "argon2_p": 4 - }, - "aead": "xchacha20poly1305", - "salt_path": ".relicario/salt" -} \ No newline at end of file diff --git a/.relicario/salt b/.relicario/salt deleted file mode 100644 index 4d3c892..0000000 --- a/.relicario/salt +++ /dev/null @@ -1 +0,0 @@ -ο½48^Ζ՜\׏οΙl$$Η. ώφrΥΩΛ \ No newline at end of file diff --git a/docs/superpowers/plans/2026-04-19-relicario-typed-items-1b-cli-wasm.md b/docs/superpowers/plans/2026-04-19-relicario-typed-items-1b-cli-wasm.md new file mode 100644 index 0000000..b245a37 --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-relicario-typed-items-1b-cli-wasm.md @@ -0,0 +1,3773 @@ +# Relicario Typed-Item Phase 1B β€” CLI + WASM Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rewrite the `relicario-cli` binary against the new typed-item core API, add the `relicario-wasm` opaque session-handle bridge, implement all CLI/WASM-side audit fixes (H4, H5, H6, H7, M3, M6, M7, M11, L8), and ship integration tests exercising every CLI command. + +**Architecture:** + +- CLI remains a single `relicario` binary with clap subcommands. Each mutation that touches vault state follows a uniform flow: locate vault dir (refuse if missing), unlock via `derive_master_key`, do the op, re-serialize, write atomically, commit via a hardened `git_command` helper. +- WASM exposes the typed-item API via opaque `SessionHandle` values. The master key lives entirely in WASM linear memory inside a `Zeroizing<[u8; 32]>` stored in a `HashMap`. `lock(handle)` zeroes and drops the entry. The browser never receives key bytes. +- All randomness goes through `getrandom` (WASM uses the `js` feature). The CLI generator delegates to `relicario-core::generate_password` / `generate_passphrase`, so `rand::distributions::Uniform` + rejection sampling happen once in core (audit H6). + +**Tech Stack:** Rust 1.80+, `clap 4` (derive), `anyhow` for CLI error chains, `rpassword 7`, `arboard 3` (clipboard), `zeroize 1`, `chrono 0.4` (ISO-8601 timestamps), `wasm-bindgen 0.2`, `serde_wasm_bindgen 0.6`, `getrandom 0.2` (`js` feature), `assert_cmd 2` + `predicates 3` + `tempfile 3` for CLI integration tests. + +--- + +## Prerequisites (execution setup) + +Plan 1B begins with the feature branch `feature/typed-items-1a-rust-core` at tag `plan-1a-rust-core-complete`. That branch predates the `idfoto` β†’ `relicario` rename on `main` (`519a6f0`). Use `superpowers:using-git-worktrees` to create an isolated worktree off `plan-1a-rust-core-complete`. Task 1 then folds `main` into the worktree so the rest of the plan can reference the final `relicario-*` crate names. + +**Verification after setup:** + +```bash +git worktree list # new worktree present +git -C log --oneline -1 # points at 49b7820 (plan-1a tag) +ls crates/ # shows idfoto-core/, idfoto-cli/, idfoto-wasm/ +``` + +--- + +## Task 1: Reconcile the Plan 1A branch with the `idfoto` β†’ `relicario` rename + +**Why first:** every later task names crates, modules, error types, binary names, env vars, and the vault metadata dir with the post-rename values. Doing the reconciliation up front means no task has to dual-reference names. + +**Files:** +- Rename: `crates/idfoto-core/` β†’ `crates/relicario-core/` +- Rename: `crates/idfoto-cli/` β†’ `crates/relicario-cli/` +- Rename: `crates/idfoto-wasm/` β†’ `crates/relicario-wasm/` +- Modify: `Cargo.toml` (workspace members) +- Modify: all `Cargo.toml` in the three renamed crates (`name`, `dependencies.*`, `[[bin]]` `name`) +- Modify: every `.rs` file under the renamed crates (identifier + path sweep) +- Merge: `main` into the current branch + +- [ ] **Step 1: Worktree + branch** + +```bash +cd /home/alee/Sources/relicario +git worktree add .worktrees/typed-items-1b plan-1a-rust-core-complete +cd .worktrees/typed-items-1b +git checkout -b feature/typed-items-1b +``` + +- [ ] **Step 2: Rename crate directories** + +```bash +git mv crates/idfoto-core crates/relicario-core +git mv crates/idfoto-cli crates/relicario-cli +git mv crates/idfoto-wasm crates/relicario-wasm +``` + +- [ ] **Step 3: Text-sweep identifiers** + +Run each of the following sed invocations against every `.rs`, `.toml`, and `.md` file in `crates/` and the workspace root `Cargo.toml`: + +```bash +find crates Cargo.toml -type f \( -name '*.rs' -o -name '*.toml' -o -name '*.md' \) -print0 \ + | xargs -0 sed -i \ + -e 's/idfoto_core/relicario_core/g' \ + -e 's/idfoto-core/relicario-core/g' \ + -e 's/idfoto_cli/relicario_cli/g' \ + -e 's/idfoto-cli/relicario-cli/g' \ + -e 's/idfoto_wasm/relicario_wasm/g' \ + -e 's/idfoto-wasm/relicario-wasm/g' \ + -e 's/IdfotoError/RelicarioError/g' \ + -e 's/IDFOTO_IMAGE/RELICARIO_IMAGE/g' \ + -e 's|\.idfoto/|.relicario/|g' \ + -e 's/name = "idfoto"/name = "relicario"/g' \ + -e 's/"idfoto"/"relicario"/g' \ + -e 's/# idfoto/# relicario/g' +``` + +Manual inspection afterwards: `git diff --stat` and skim any `.md` docstring references. Leave intentional historical references in the `docs/superpowers/` folder untouched β€” those describe the old name as a historical fact. + +- [ ] **Step 4: Verify core builds + tests still pass** + +```bash +cargo build -p relicario-core --release +cargo test -p relicario-core +``` + +Expected: clean build and 110+ unit tests + 18 integration tests pass. If any test fails, the sed missed a reference β€” find it with `grep -r idfoto crates/`. + +- [ ] **Step 5: Merge main** + +```bash +git fetch origin +git merge origin/main -m "chore: merge rename commit into Plan 1B branch" +``` + +Expected: either a fast-forward (likely conflict-free because Step 3 aligned the renamed paths with main's naming) OR small conflicts in `Cargo.toml` workspace members. Resolve by keeping main's member list (it already uses `relicario-*`). + +After the merge: re-run `cargo test -p relicario-core` to confirm nothing regressed. + +- [ ] **Step 6: Commit reconciliation** + +```bash +git add -A +git commit -m "chore: reconcile Plan 1A branch with idfotoβ†’relicario rename + +Renames crate directories and sweeps identifiers so Plan 1B can reference +the post-rename names throughout. Merges main afterward to pick up any +additional rename-commit changes (docs, extension, etc.)." +``` + +- [ ] **Step 7: Re-run core test suite to confirm no regression** + +```bash +cargo test -p relicario-core 2>&1 | tail -5 +``` + +Expected: all tests pass. If anything fails, stop and investigate β€” a missed rename is the most likely cause. + +--- + +## Task 2: Core imgsecret dimension guard (audit M3) + +**Files:** +- Modify: `crates/relicario-core/src/imgsecret.rs` +- Test: `crates/relicario-core/src/imgsecret.rs` (inline `#[cfg(test)] mod tests`) + +**Why:** audit M3 β€” an attacker-supplied 32000Γ—32000 JPEG wedges the WASM service worker for tens of seconds during `extract_with_crop_recovery`. Cap the dimension and peek before full decode. + +- [ ] **Step 1: Write the failing test** + +Add to the existing inline tests block in `imgsecret.rs`: + +```rust +#[test] +fn rejects_oversized_image_without_full_decode() { + // Synthesize a JPEG header claiming 20000x20000 dimensions. + // The actual pixel data is irrelevant β€” the dimension peek should bail out + // before decoding any pixels. + let jpeg = build_oversized_jpeg_header(20_000, 20_000); + let result = extract(&jpeg); + assert!(matches!(result, Err(RelicarioError::ImgSecret(ref msg)) if msg.contains("dimension"))); +} + +fn build_oversized_jpeg_header(width: u16, height: u16) -> Vec { + // SOI + APP0 JFIF + SOF0 declaring width/height + SOS with minimal data + EOI + let mut v = vec![0xFF, 0xD8]; // SOI + v.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x10]); // APP0 + v.extend_from_slice(b"JFIF\0"); + v.extend_from_slice(&[0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00]); + v.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x11, 0x08]); // SOF0 + v.extend_from_slice(&height.to_be_bytes()); + v.extend_from_slice(&width.to_be_bytes()); + v.extend_from_slice(&[0x03, 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01]); + v.extend_from_slice(&[0xFF, 0xD9]); // EOI + v +} +``` + +- [ ] **Step 2: Run the test β€” expect FAIL** + +```bash +cargo test -p relicario-core imgsecret::tests::rejects_oversized_image_without_full_decode +``` + +Expected: fail, because extract currently tries to full-decode and either panics/slows. + +- [ ] **Step 3: Implement the guard** + +Near the top of `imgsecret.rs`: + +```rust +pub const MAX_DIMENSION: u32 = 10_000; + +fn peek_jpeg_dimensions(jpeg: &[u8]) -> Result<(u32, u32)> { + // Walk JPEG markers until we hit an SOF (start-of-frame) marker, which + // carries the image dimensions in bytes 5..=8 of its segment. + let mut i = 0; + while i + 1 < jpeg.len() { + if jpeg[i] != 0xFF { i += 1; continue; } + let marker = jpeg[i + 1]; + match marker { + 0xD8 | 0xD9 => { i += 2; continue; } // SOI / EOI + 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => { + // SOFn β€” height in [i+5..i+7], width in [i+7..i+9]. Last byte + // read is jpeg[i+8], so the guard is `i + 8 >= len`. + if i + 8 >= jpeg.len() { + return Err(RelicarioError::ImgSecret("truncated SOF marker".into())); + } + let height = u16::from_be_bytes([jpeg[i + 5], jpeg[i + 6]]) as u32; + let width = u16::from_be_bytes([jpeg[i + 7], jpeg[i + 8]]) as u32; + return Ok((width, height)); + } + _ => { + if i + 3 >= jpeg.len() { + return Err(RelicarioError::ImgSecret("truncated marker segment".into())); + } + let seg_len = u16::from_be_bytes([jpeg[i + 2], jpeg[i + 3]]) as usize; + i += 2 + seg_len; + } + } + } + Err(RelicarioError::ImgSecret("no SOF marker found in JPEG".into())) +} + +fn enforce_dimension_cap(jpeg: &[u8]) -> Result<()> { + let (w, h) = peek_jpeg_dimensions(jpeg)?; + if w > MAX_DIMENSION || h > MAX_DIMENSION { + return Err(RelicarioError::ImgSecret(format!( + "image dimensions {w}x{h} exceed {MAX_DIMENSION}x{MAX_DIMENSION} cap" + ))); + } + Ok(()) +} +``` + +Add `enforce_dimension_cap(jpeg)?;` as the first line of both `extract(…)` and `embed(…)` in `imgsecret.rs`. (Find them by their `pub fn` signatures.) + +- [ ] **Step 4: Run the test again β€” expect PASS** + +```bash +cargo test -p relicario-core imgsecret::tests::rejects_oversized_image_without_full_decode +``` + +Expected: pass. + +- [ ] **Step 5: Re-run all core tests to catch regressions** + +```bash +cargo test -p relicario-core +``` + +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +git add crates/relicario-core/src/imgsecret.rs +git commit -m "fix(core): cap imgsecret MAX_DIMENSION at 10000px (audit M3)" +``` + +--- + +## Task 3: CLI Cargo.toml + helpers (rpassword 7, zeroize, chrono; vault_dir/L8, git_command/H4, iso8601/M11) + +**Files:** +- Modify: `crates/relicario-cli/Cargo.toml` +- Create: `crates/relicario-cli/src/helpers.rs` + +**What this task lands:** +- H7: `rpassword = "7"` (drops `prompt_password_stderr`, uses `prompt_password` + eprintln). +- L8: `vault_dir()` walks up from CWD looking for `.relicario/`, refuses otherwise. +- H4: `git_command()` central helper with hardened env. +- M11: `iso8601(unix_seconds) -> String` using `chrono::DateTime::from_timestamp`. +- Adds `zeroize`, `chrono`, `tempfile` (dev), `assert_cmd` (dev), `predicates` (dev). + +- [ ] **Step 1: Update Cargo.toml** + +Rewrite `crates/relicario-cli/Cargo.toml`: + +```toml +[package] +name = "relicario-cli" +version = "0.1.0" +edition = "2021" +description = "CLI for relicario password manager" + +[[bin]] +name = "relicario" +path = "src/main.rs" + +[dependencies] +relicario-core = { path = "../relicario-core" } +clap = { version = "4", features = ["derive"] } +anyhow = "1" +rpassword = "7" +arboard = "3" +chrono = { version = "0.4", default-features = false, features = ["clock"] } +dirs = "5" +hex = "0.4" +ed25519-dalek = { version = "2", features = ["rand_core"] } +rand = "0.8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +zeroize = "1" + +[dev-dependencies] +assert_cmd = "2" +predicates = "3" +tempfile = "3" +``` + +- [ ] **Step 2: Write failing tests for helpers** + +Create `crates/relicario-cli/src/helpers.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn vault_dir_finds_marker_in_cwd() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir(tmp.path().join(".relicario")).unwrap(); + let found = find_vault_dir_from(tmp.path()).unwrap(); + assert_eq!(found, tmp.path()); + } + + #[test] + fn vault_dir_finds_marker_in_parent() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir(tmp.path().join(".relicario")).unwrap(); + let subdir = tmp.path().join("sub/nested"); + std::fs::create_dir_all(&subdir).unwrap(); + let found = find_vault_dir_from(&subdir).unwrap(); + assert_eq!(found, tmp.path()); + } + + #[test] + fn vault_dir_errors_when_missing() { + let tmp = TempDir::new().unwrap(); + let err = find_vault_dir_from(tmp.path()).unwrap_err(); + assert!(err.to_string().contains(".relicario")); + } + + #[test] + fn iso8601_formats_fixed_timestamp() { + // 2026-04-19T00:00:00Z = 1776556800 + assert_eq!(iso8601(1_776_556_800), "2026-04-19T00:00:00Z"); + } +} +``` + +- [ ] **Step 3: Run tests β€” expect FAIL (undefined)** + +```bash +cargo test -p relicario-cli --lib +``` + +Expected: compile error (`find_vault_dir_from`, `iso8601` not found). + +- [ ] **Step 4: Implement the helpers** + +Complete `crates/relicario-cli/src/helpers.rs`: + +```rust +//! CLI-side helpers: vault dir detection, hardened git shell-out, ISO-8601 +//! timestamp formatting. Kept in their own module so every command handler +//! stays terse. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{bail, Context, Result}; +use chrono::DateTime; + +/// Walk up from `start` looking for a directory containing `.relicario/`. +/// Returns the vault root (the directory that contains `.relicario/`). +/// Audit L8: refuses to operate outside an initialized vault. +pub fn find_vault_dir_from(start: &Path) -> Result { + let mut cur = start.to_path_buf(); + loop { + if cur.join(".relicario").is_dir() { + return Ok(cur); + } + if !cur.pop() { + bail!( + "no .relicario/ directory found in {} or any parent β€” \ + run `relicario init` first", + start.display() + ); + } + } +} + +/// Convenience wrapper that starts the search from `std::env::current_dir()`. +pub fn vault_dir() -> Result { + let cwd = std::env::current_dir().context("failed to get current directory")?; + find_vault_dir_from(&cwd) +} + +/// Path to the `.relicario/` configuration directory within the vault. +pub fn relicario_dir() -> Result { + Ok(vault_dir()?.join(".relicario")) +} + +/// Build a hardened `git` command β€” no hooks, no GPG signing, no editor. +/// Audit H4: prevents vault mutations from running hostile hooks, blocking on +/// GPG passphrase prompts (which would hold the master key alive), or entering +/// $EDITOR during rebase conflict markers. +pub fn git_command(repo: &Path, args: &[&str]) -> Command { + let mut cmd = Command::new("git"); + cmd.current_dir(repo); + cmd.args([ + "-c", "core.hooksPath=/dev/null", + "-c", "commit.gpgsign=false", + "-c", "core.editor=true", + ]); + cmd.args(args); + cmd +} + +/// Format a Unix-seconds timestamp as an ISO-8601 UTC string. +/// Audit M11: replaces the old `now_iso8601` helper that actually returned +/// a numeric string. +pub fn iso8601(unix_seconds: i64) -> String { + DateTime::from_timestamp(unix_seconds, 0) + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) + .unwrap_or_else(|| format!("invalid-timestamp:{unix_seconds}")) +} +``` + +Wire the module into the binary by adding to `crates/relicario-cli/src/main.rs` (near the top, after the `use` block): + +```rust +mod helpers; +``` + +- [ ] **Step 5: Run tests β€” expect PASS** + +```bash +cargo test -p relicario-cli --lib +``` + +Expected: 4 passing. + +- [ ] **Step 6: Commit** + +```bash +git add crates/relicario-cli/Cargo.toml crates/relicario-cli/src/helpers.rs crates/relicario-cli/src/main.rs +git commit -m "feat(cli): add helpers module (vault_dir/L8, git_command/H4, iso8601/M11) + +Bumps rpassword to 7.x (H7) and adds zeroize/chrono/assert_cmd dev-deps." +``` + +--- + +## Task 4: CLI unlock flow β€” `UnlockedVault` session struct + +**Files:** +- Create: `crates/relicario-cli/src/session.rs` +- Modify: `crates/relicario-cli/src/main.rs` (`mod session;`) + +**What this task lands:** a single `UnlockedVault` struct holding the derived master key in `Zeroizing<[u8; 32]>`, plus `unlock_interactive()` that prompts for passphrase, loads the reference image, extracts the image secret, derives the key, and returns the struct. Every command handler calls `UnlockedVault::unlock()` once and then goes through its helper methods (`load_manifest`, `save_manifest`, `load_item`, `save_item`, etc.). + +- [ ] **Step 1: Define the UnlockedVault skeleton** + +Create `crates/relicario-cli/src/session.rs`: + +```rust +//! Unlocked-vault session: the shape every vault-mutating command works with. +//! +//! Holds the derived master key in `Zeroizing<[u8; 32]>` for the lifetime of a +//! CLI invocation. Drops it (via Zeroize) when the struct goes out of scope. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use zeroize::Zeroizing; + +use relicario_core::{ + decrypt_item, decrypt_manifest, decrypt_settings, + derive_master_key, encrypt_item, encrypt_manifest, encrypt_settings, + imgsecret, Item, ItemId, KdfParams, Manifest, VaultSettings, +}; + +use crate::helpers::{relicario_dir, vault_dir}; + +/// A vault whose master key has been derived and is held in memory. +/// The key is wiped via `Zeroize` when this struct drops. +pub struct UnlockedVault { + root: PathBuf, + master_key: Zeroizing<[u8; 32]>, +} + +impl UnlockedVault { + pub fn root(&self) -> &Path { &self.root } + pub fn key(&self) -> &Zeroizing<[u8; 32]> { &self.master_key } + + /// Full interactive unlock flow: locate vault, prompt passphrase, locate + /// reference image, derive master key. + pub fn unlock_interactive() -> Result { + let root = vault_dir()?; + let salt = read_salt(&root)?; + let params = read_params(&root)?; + let image_path = get_image_path()?; + let image_bytes = fs::read(&image_path) + .with_context(|| format!("failed to read reference image {}", image_path.display()))?; + let image_secret = imgsecret::extract(&image_bytes)?; + + let passphrase = Zeroizing::new( + rpassword::prompt_password("Passphrase: ") + .context("failed to read passphrase")? + ); + + let master_key = derive_master_key( + passphrase.as_bytes(), + &image_secret, + &salt, + ¶ms, + )?; + + Ok(Self { root, master_key }) + } + + pub fn manifest_path(&self) -> PathBuf { self.root.join("manifest.enc") } + pub fn settings_path(&self) -> PathBuf { self.root.join("settings.enc") } + pub fn item_path(&self, id: &ItemId) -> PathBuf { + self.root.join("items").join(format!("{}.enc", id.as_str())) + } + + pub fn load_manifest(&self) -> Result { + let bytes = fs::read(self.manifest_path()).context("failed to read manifest.enc")?; + Ok(decrypt_manifest(&bytes, &self.master_key)?) + } + + pub fn save_manifest(&self, manifest: &Manifest) -> Result<()> { + let bytes = encrypt_manifest(manifest, &self.master_key)?; + atomic_write(&self.manifest_path(), &bytes) + } + + pub fn load_settings(&self) -> Result { + let bytes = fs::read(self.settings_path()).context("failed to read settings.enc")?; + Ok(decrypt_settings(&bytes, &self.master_key)?) + } + + pub fn save_settings(&self, settings: &VaultSettings) -> Result<()> { + let bytes = encrypt_settings(settings, &self.master_key)?; + atomic_write(&self.settings_path(), &bytes) + } + + pub fn load_item(&self, id: &ItemId) -> Result { + let bytes = fs::read(self.item_path(id)) + .with_context(|| format!("failed to read item {}", id.as_str()))?; + Ok(decrypt_item(&bytes, &self.master_key)?) + } + + pub fn save_item(&self, item: &Item) -> Result<()> { + let path = self.item_path(&item.id); + if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } + let bytes = encrypt_item(item, &self.master_key)?; + atomic_write(&path, &bytes) + } +} + +fn read_salt(root: &Path) -> Result<[u8; 32]> { + let data = fs::read(root.join(".relicario").join("salt")) + .context("failed to read .relicario/salt")?; + if data.len() != 32 { bail!("invalid salt length: {}", data.len()); } + let mut salt = [0u8; 32]; + salt.copy_from_slice(&data); + Ok(salt) +} + +fn read_params(root: &Path) -> Result { + let s = fs::read_to_string(root.join(".relicario").join("params.json")) + .context("failed to read .relicario/params.json")?; + let params: KdfParams = serde_json::from_str(&s).context("failed to parse params.json")?; + Ok(params) +} + +/// Locate the reference image path via `RELICARIO_IMAGE` env var or interactive prompt. +pub fn get_image_path() -> Result { + if let Ok(path) = std::env::var("RELICARIO_IMAGE") { + return Ok(PathBuf::from(path)); + } + // Also accept /reference.jpg as a convention. + if let Ok(root) = vault_dir() { + let default = root.join("reference.jpg"); + if default.exists() { return Ok(default); } + } + eprint!("Reference image path: "); + std::io::Write::flush(&mut std::io::stderr())?; + let mut line = String::new(); + std::io::stdin().read_line(&mut line)?; + let trimmed = line.trim(); + if trimmed.is_empty() { bail!("no reference image path provided"); } + Ok(PathBuf::from(trimmed)) +} + +/// Atomic write: write to .tmp, then rename over . Keeps the +/// vault file consistent if we crash mid-write. +fn atomic_write(path: &Path, data: &[u8]) -> Result<()> { + let tmp = path.with_extension("tmp"); + fs::write(&tmp, data).with_context(|| format!("failed to write {}", tmp.display()))?; + fs::rename(&tmp, path).with_context(|| format!("failed to rename {}", path.display()))?; + Ok(()) +} +``` + +Add `mod session;` under the existing `mod helpers;` in `crates/relicario-cli/src/main.rs`. + +- [ ] **Step 2: Verify it compiles** + +```bash +cargo build -p relicario-cli 2>&1 | head -30 +``` + +Expected: compile errors come ONLY from the stale command handlers in `main.rs` (which we'll rewrite in later tasks), not from `session.rs` itself. If `session.rs` itself errors, fix it before continuing. + +- [ ] **Step 3: Commit the partial state** + +`main.rs` will not fully compile yet β€” that's expected; the old `Entry`-based handlers still exist and reference the v1 API. Commit the new session module so the task boundary is clean. The build break is a known transient state that Task 5 starts repairing. + +```bash +git add crates/relicario-cli/src/session.rs crates/relicario-cli/src/main.rs +git commit -m "feat(cli): add UnlockedVault session wrapping master_key in Zeroizing + +Provides load/save helpers for Manifest/Settings/Item; atomic_write keeps +vault files consistent across crashes. main.rs is transiently broken +against the old Entry API β€” Task 5+ rewrites the command handlers." +``` + +--- + +## Task 5: Strip the old command handlers; introduce the typed-item CLI skeleton + +**Files:** +- Rewrite: `crates/relicario-cli/src/main.rs` + +**What lands:** the clap struct covering every Plan 1B subcommand (even ones whose bodies are stubs returning `bail!("not yet implemented")`). This gets the binary compiling again and gives later tasks a stable clap surface to fill in. + +- [ ] **Step 1: Write the new main.rs shell** + +Overwrite `crates/relicario-cli/src/main.rs`: + +```rust +//! relicario CLI β€” the platform layer for the relicario password manager. +//! +//! See module docs for the unlock flow and vault layout. + +mod helpers; +mod session; + +use std::path::PathBuf; + +use anyhow::{bail, Result}; +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command( + name = "relicario", + version, + about = "Git-backed password manager with reference-image two-factor unlock" +)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Initialize a new vault in the current directory. + Init { + /// Carrier JPEG to embed the secret into. + #[arg(long)] + image: PathBuf, + /// Output path for the reference image (gitignored). + #[arg(long, default_value = "reference.jpg")] + output: PathBuf, + }, + + /// Add a new item. Type-specific flags populate the core; missing fields + /// are prompted for interactively. + Add { + #[command(subcommand)] + kind: AddKind, + }, + + /// Print an item. Secrets are masked by default; pass --show to reveal. + Get { + /// Item id or case-insensitive title substring. + query: String, + /// Print secret field values in plaintext. + #[arg(long)] + show: bool, + /// Copy the primary secret (Login.password, Card.number, etc.) to clipboard. + #[arg(long)] + copy: bool, + }, + + /// List items. + List { + #[arg(long)] + r#type: Option, + #[arg(long)] + group: Option, + #[arg(long)] + tag: Option, + #[arg(long)] + trashed: bool, + }, + + /// Edit an item interactively. + Edit { query: String }, + + /// Soft-delete an item (moves to trash; reversible via `restore`). + Rm { query: String }, + + /// Restore a soft-deleted item. + Restore { query: String }, + + /// Permanently purge an item (and its attachments). + Purge { query: String }, + + /// Trash operations. + Trash { + #[command(subcommand)] + action: TrashAction, + }, + + /// Attach a file to an item. + Attach { query: String, file: PathBuf }, + + /// List attachments on an item. + Attachments { query: String }, + + /// Extract an attachment to disk. + Extract { + query: String, + aid: String, + #[arg(long)] + out: Option, + }, + + /// Generate a password or passphrase. + Generate { + #[arg(long, default_value_t = 20)] + length: u32, + #[arg(long)] + bip39: bool, + #[arg(long, default_value_t = 5)] + words: u32, + #[arg(long, default_value = "safe")] + symbols: String, + /// Separator for BIP39 words. + #[arg(long, default_value = " ")] + separator: String, + }, + + /// View or change vault settings. + Settings { + #[command(subcommand)] + action: SettingsAction, + }, + + /// Sync with the git remote (pull --rebase + push). + Sync, + + /// Device management. + Device { + #[command(subcommand)] + action: DeviceAction, + }, + + /// Lock the vault (no-op in CLI; present for UX parity with the extension). + Lock, +} + +#[derive(Subcommand)] +enum AddKind { + Login { + #[arg(long)] title: Option, + #[arg(long)] username: Option, + #[arg(long)] url: Option, + /// Prompt for password (vs reading from stdin or --password). + #[arg(long)] password_prompt: bool, + #[arg(long)] password: Option, + #[arg(long)] group: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + #[arg(long)] favorite: bool, + }, + SecureNote { + #[arg(long)] title: Option, + #[arg(long)] body_prompt: bool, + #[arg(long)] group: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, + Identity { + #[arg(long)] title: Option, + #[arg(long)] full_name: Option, + #[arg(long)] email: Option, + #[arg(long)] phone: Option, + #[arg(long)] date_of_birth: Option, + #[arg(long)] group: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, + Card { + #[arg(long)] title: Option, + #[arg(long)] holder: Option, + #[arg(long)] expiry: Option, // MM/YYYY + #[arg(long, default_value = "credit")] kind: String, + #[arg(long)] group: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, + Key { + #[arg(long)] title: Option, + #[arg(long)] label: Option, + #[arg(long)] algorithm: Option, + #[arg(long)] group: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, + Document { + #[arg(long)] title: Option, + #[arg(long)] file: PathBuf, + #[arg(long)] group: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, + Totp { + #[arg(long)] title: Option, + #[arg(long)] issuer: Option, + #[arg(long)] label: Option, + #[arg(long)] secret: Option, // base32 + #[arg(long, default_value = "30")] period: u32, + #[arg(long, default_value = "6")] digits: u8, + #[arg(long, default_value = "sha1")] algorithm: String, + #[arg(long)] group: Option, + #[arg(long, value_delimiter = ',')] tags: Vec, + }, +} + +#[derive(Subcommand)] +enum TrashAction { + /// List trashed items. + List, + /// Purge every trashed item past its retention window. + Empty, +} + +#[derive(Subcommand)] +enum SettingsAction { + /// Show current settings as JSON. + Show, + /// Set trash retention (e.g., --days 30 or --forever). + TrashRetention { + #[arg(long)] days: Option, + #[arg(long)] forever: bool, + }, + /// Set field history retention. + HistoryRetention { + #[arg(long)] last_n: Option, + #[arg(long)] days: Option, + #[arg(long)] forever: bool, + }, + /// Set per-attachment max size in bytes. + AttachmentCap { + #[arg(long)] per_attachment_max_bytes: Option, + #[arg(long)] per_item_max_count: Option, + #[arg(long)] per_vault_soft_cap_bytes: Option, + #[arg(long)] per_vault_hard_cap_bytes: Option, + }, +} + +#[derive(Subcommand)] +enum DeviceAction { + Add { #[arg(long)] name: String }, + List, + Revoke { name: String }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + match cli.command { + Commands::Init { image, output } => cmd_init(image, output), + Commands::Add { kind } => cmd_add(kind), + Commands::Get { query, show, copy } => cmd_get(query, show, copy), + Commands::List { r#type, group, tag, trashed } => cmd_list(r#type, group, tag, trashed), + Commands::Edit { query } => cmd_edit(query), + Commands::Rm { query } => cmd_rm(query), + Commands::Restore { query } => cmd_restore(query), + Commands::Purge { query } => cmd_purge(query), + Commands::Trash { action } => cmd_trash(action), + Commands::Attach { query, file } => cmd_attach(query, file), + Commands::Attachments { query } => cmd_attachments(query), + Commands::Extract { query, aid, out } => cmd_extract(query, aid, out), + Commands::Generate { length, bip39, words, symbols, separator } => { + cmd_generate(length, bip39, words, symbols, separator) + } + Commands::Settings { action } => cmd_settings(action), + Commands::Sync => cmd_sync(), + Commands::Device { action } => cmd_device(action), + Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) } + } +} + +fn cmd_init(_image: PathBuf, _output: PathBuf) -> Result<()> { bail!("not yet implemented"); } +fn cmd_add(_kind: AddKind) -> Result<()> { bail!("not yet implemented"); } +fn cmd_get(_query: String, _show: bool, _copy: bool) -> Result<()> { bail!("not yet implemented"); } +fn cmd_list(_t: Option, _g: Option, _tag: Option, _trashed: bool) -> Result<()> { bail!("not yet implemented"); } +fn cmd_edit(_query: String) -> Result<()> { bail!("not yet implemented"); } +fn cmd_rm(_query: String) -> Result<()> { bail!("not yet implemented"); } +fn cmd_restore(_query: String) -> Result<()> { bail!("not yet implemented"); } +fn cmd_purge(_query: String) -> Result<()> { bail!("not yet implemented"); } +fn cmd_trash(_action: TrashAction) -> Result<()> { bail!("not yet implemented"); } +fn cmd_attach(_q: String, _file: PathBuf) -> Result<()> { bail!("not yet implemented"); } +fn cmd_attachments(_q: String) -> Result<()> { bail!("not yet implemented"); } +fn cmd_extract(_q: String, _aid: String, _out: Option) -> Result<()> { bail!("not yet implemented"); } +fn cmd_generate(_l: u32, _b: bool, _w: u32, _s: String, _sep: String) -> Result<()> { bail!("not yet implemented"); } +fn cmd_settings(_a: SettingsAction) -> Result<()> { bail!("not yet implemented"); } +fn cmd_sync() -> Result<()> { bail!("not yet implemented"); } +fn cmd_device(_a: DeviceAction) -> Result<()> { bail!("not yet implemented"); } +``` + +- [ ] **Step 2: Build** + +```bash +cargo build -p relicario-cli +``` + +Expected: clean build. If not, fix. + +- [ ] **Step 3: Smoke-test the clap surface** + +```bash +cargo run -p relicario-cli -- --help +cargo run -p relicario-cli -- add login --help +cargo run -p relicario-cli -- settings trash-retention --help +``` + +Expected: usage help prints for each. No panic. + +- [ ] **Step 4: Commit** + +```bash +git add crates/relicario-cli/src/main.rs +git commit -m "feat(cli): scaffold clap surface for all typed-item commands + +Every subcommand from the Plan 1B CLI spec present; bodies return +'not yet implemented' so subsequent tasks land one command at a time." +``` + +--- + +## Task 6: `relicario init` β€” create a format-v2 vault + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` (`cmd_init`) + +**Spec match:** the created vault layout is: + +``` +/ + .relicario/ + salt 32 random bytes + params.json { format_version: 2, kdf: { argon2_m, argon2_t, argon2_p }, ... } + devices.json [] + items/ (empty) + attachments/ (empty) + manifest.enc empty Manifest (schema_version 2) + settings.enc VaultSettings::default() + reference.jpg the carrier with the embedded secret + .gitignore excludes reference.jpg +``` + +- [ ] **Step 1: Implement `cmd_init`** + +Replace the stub `cmd_init` in `main.rs`: + +```rust +fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { + use std::fs; + use rand::{rngs::OsRng, RngCore}; + use relicario_core::{ + derive_master_key, encrypt_manifest, encrypt_settings, imgsecret, + validate_passphrase_strength, KdfParams, Manifest, VaultSettings, + }; + use zeroize::Zeroizing; + + let root = std::env::current_dir()?; + let relicario_dir = root.join(".relicario"); + if relicario_dir.exists() { + anyhow::bail!(".relicario/ already exists in {}", root.display()); + } + + // Passphrase with strength gate (audit H3). + let passphrase = Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?); + let confirm = Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?); + if passphrase.as_str() != confirm.as_str() { + anyhow::bail!("passphrases do not match"); + } + if let Err(e) = validate_passphrase_strength(&passphrase) { + anyhow::bail!("{}. Choose a longer or more entropic phrase.", e); + } + + // Image secret: 32 random bytes, embedded in the carrier. + let mut image_secret = [0u8; 32]; + OsRng.fill_bytes(&mut image_secret); + let carrier = fs::read(&image) + .with_context(|| format!("failed to read carrier image {}", image.display()))?; + let stego = imgsecret::embed(&carrier, &image_secret)?; + fs::write(&output, &stego) + .with_context(|| format!("failed to write reference image {}", output.display()))?; + + // Vault salt + KDF params. + let mut salt = [0u8; 32]; + OsRng.fill_bytes(&mut salt); + let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }; + + // Derive master key, then persist an empty Manifest + default VaultSettings. + let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)?; + + fs::create_dir_all(&relicario_dir)?; + fs::create_dir_all(root.join("items"))?; + fs::create_dir_all(root.join("attachments"))?; + fs::write(relicario_dir.join("salt"), salt)?; + fs::write( + relicario_dir.join("params.json"), + serde_json::to_string_pretty(&ParamsFile { + format_version: 2, + kdf: ParamsKdf { + algorithm: "argon2id-v0x13".into(), + argon2_m: params.argon2_m, + argon2_t: params.argon2_t, + argon2_p: params.argon2_p, + }, + aead: "xchacha20poly1305".into(), + salt_path: ".relicario/salt".into(), + })?, + )?; + fs::write(relicario_dir.join("devices.json"), b"[]")?; + + let manifest = Manifest::new(); + fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?; + let settings = VaultSettings::default(); + fs::write(root.join("settings.enc"), encrypt_settings(&settings, &master_key)?)?; + + // .gitignore excludes the reference image. + let gitignore = format!("{}\n", output.file_name().unwrap().to_string_lossy()); + fs::write(root.join(".gitignore"), gitignore)?; + + // git init + initial commit via hardened wrapper. + let status = crate::helpers::git_command(&root, &["init"]).status()?; + if !status.success() { anyhow::bail!("git init failed"); } + let _ = crate::helpers::git_command(&root, &[ + "add", ".gitignore", ".relicario/params.json", ".relicario/devices.json", + "manifest.enc", "settings.enc", + ]).status()?; + let status = crate::helpers::git_command(&root, &[ + "commit", "-m", "init: new relicario vault (format v2)", + ]).status()?; + if !status.success() { anyhow::bail!("git commit failed"); } + + eprintln!("Vault initialized at {}", root.display()); + eprintln!("Reference image: {}", output.display()); + eprintln!(" β†’ back this file up somewhere safe; it is your second factor."); + Ok(()) +} + +#[derive(serde::Serialize)] +struct ParamsFile { + format_version: u32, + kdf: ParamsKdf, + aead: String, + salt_path: String, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "snake_case")] +struct ParamsKdf { + algorithm: String, + argon2_m: u32, + argon2_t: u32, + argon2_p: u32, +} +``` + +Also add `use anyhow::Context;` to the imports at the top of `main.rs`. + +- [ ] **Step 2: Build** + +```bash +cargo build -p relicario-cli +``` + +Expected: clean. + +- [ ] **Step 3: Manual smoke test** + +```bash +# In a throwaway dir: +mkdir -p /tmp/relicario-smoke && cd /tmp/relicario-smoke +# Create a small synthetic carrier (requires ImageMagick `magick`): +magick -size 800x600 gradient:black-red carrier.jpg +cargo run --manifest-path /home/alee/Sources/relicario/.worktrees/typed-items-1b/Cargo.toml \ + -p relicario-cli -- init --image carrier.jpg --output reference.jpg +# Expected: prompts twice for passphrase (use a strong one like "correct horse battery staple"), +# then creates .relicario/, items/, attachments/, manifest.enc, settings.enc, .gitignore, reference.jpg. +ls -la .relicario/ +cat .relicario/params.json +``` + +- [ ] **Step 4: Commit** + +```bash +git add crates/relicario-cli/src/main.rs +git commit -m "feat(cli): relicario init creates a format-v2 vault + +Prompts for a strong passphrase (zxcvbn gate via core), generates a +32-byte image secret, embeds it in the carrier JPEG, writes the +standard vault skeleton, and makes an initial git commit via the +hardened git_command helper." +``` + +--- + +## Task 7: `relicario add login` β€” dispatcher + Login variant (flag + interactive) + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` (`cmd_add` for `AddKind::Login`) + +**Pattern established here is reused by Task 8 for the other six variants.** + +- [ ] **Step 1: Implement `cmd_add` dispatcher + Login** + +Replace `cmd_add`: + +```rust +fn cmd_add(kind: AddKind) -> Result<()> { + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + + let item = match kind { + AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite } => { + use relicario_core::item_types::LoginCore; + use relicario_core::{Item, ItemCore}; + use zeroize::Zeroizing; + + let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; + let username = username.or_else(|| prompt_optional("Username").ok().flatten()); + let url = url.or_else(|| prompt_optional("URL").ok().flatten()); + let parsed_url = match url { + Some(s) => Some(url::Url::parse(&s) + .with_context(|| format!("invalid URL: {s}"))?), + None => None, + }; + let password = if let Some(p) = password { + Some(Zeroizing::new(p)) + } else if password_prompt { + Some(Zeroizing::new(rpassword::prompt_password("Password: ")?)) + } else { + None + }; + let core = ItemCore::Login(LoginCore { + username, + password, + url: parsed_url, + totp: None, + }); + let mut item = Item::new(title, core); + item.group = group; + item.tags = tags; + item.favorite = favorite; + item + } + // Task 8 fills in the other variants. + _ => anyhow::bail!("item kind not yet implemented"), + }; + + vault.save_item(&item)?; + manifest.upsert(&item); + vault.save_manifest(&manifest)?; + + commit_paths(&vault, &format!("add: {} ({})", item.title, item.id.as_str()), + &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; + + eprintln!("Added: {} (id={})", item.title, item.id.as_str()); + Ok(()) +} + +fn prompt(label: &str) -> Result { + eprint!("{label}: "); + std::io::Write::flush(&mut std::io::stderr())?; + let mut s = String::new(); + std::io::stdin().read_line(&mut s)?; + let trimmed = s.trim().to_string(); + if trimmed.is_empty() { anyhow::bail!("{label} required"); } + Ok(trimmed) +} + +fn prompt_optional(label: &str) -> Result> { + eprint!("{label} (leave blank to skip): "); + std::io::Write::flush(&mut std::io::stderr())?; + let mut s = String::new(); + std::io::stdin().read_line(&mut s)?; + let trimmed = s.trim().to_string(); + Ok(if trimmed.is_empty() { None } else { Some(trimmed) }) +} + +fn commit_paths(vault: &crate::session::UnlockedVault, message: &str, paths: &[&str]) -> Result<()> { + let mut args: Vec<&str> = vec!["add"]; + args.extend_from_slice(paths); + let status = crate::helpers::git_command(vault.root(), &args).status()?; + if !status.success() { anyhow::bail!("git add failed"); } + let status = crate::helpers::git_command(vault.root(), &["commit", "-m", message]).status()?; + if !status.success() { anyhow::bail!("git commit failed"); } + Ok(()) +} +``` + +- [ ] **Step 2: Build + manual smoke** + +```bash +cargo build -p relicario-cli +# In /tmp/relicario-smoke (vault from Task 6): +export RELICARIO_IMAGE=$PWD/reference.jpg +cargo run --manifest-path /Cargo.toml -p relicario-cli -- add login \ + --title "GitHub" --username alice --url https://github.com --password-prompt +# Enter passphrase, enter item password. +ls items/ +git log --oneline +``` + +Expected: one `items/.enc`, manifest updated, one git commit titled `add: GitHub (…)`. + +- [ ] **Step 3: Commit** + +```bash +git add crates/relicario-cli/src/main.rs +git commit -m "feat(cli): relicario add login with flag + interactive prompting + +Unlocks vault, builds LoginCore from flags (password via rpassword if +--password-prompt), saves item + manifest, commits via hardened git." +``` + +--- + +## Task 8: Remaining `add` variants (SecureNote, Identity, Card, Key, Document, Totp) + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` (extend the match in `cmd_add`) + +Follow the Login pattern: build the `*Core` from flags, fall back to interactive prompts for missing required fields, assemble the `Item`, save, commit. + +- [ ] **Step 1: Implement SecureNote** + +Add under the Login match arm: + +```rust +AddKind::SecureNote { title, body_prompt, group, tags } => { + use relicario_core::item_types::SecureNoteCore; + use relicario_core::{Item, ItemCore}; + use zeroize::Zeroizing; + + let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; + let body = if body_prompt { + eprintln!("Enter note body; end with Ctrl-D on a blank line:"); + let mut s = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?; + s + } else { + prompt("Body")? + }; + let mut item = Item::new(title, ItemCore::SecureNote(SecureNoteCore { + body: Zeroizing::new(body), + })); + item.group = group; + item.tags = tags; + item +} +``` + +- [ ] **Step 2: Implement Identity** + +```rust +AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } => { + use relicario_core::item_types::IdentityCore; + use relicario_core::{Item, ItemCore}; + + let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; + let dob = match date_of_birth { + Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d") + .with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?), + None => None, + }; + let mut item = Item::new(title, ItemCore::Identity(IdentityCore { + full_name, + address: None, + phone, + email, + date_of_birth: dob, + })); + item.group = group; + item.tags = tags; + item +} +``` + +- [ ] **Step 3: Implement Card** + +```rust +AddKind::Card { title, holder, expiry, kind, group, tags } => { + use relicario_core::item_types::{CardCore, CardKind}; + use relicario_core::{Item, ItemCore, MonthYear}; + use zeroize::Zeroizing; + + let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; + let number = Zeroizing::new(rpassword::prompt_password("Card number: ")?); + let cvv = Zeroizing::new(rpassword::prompt_password("CVV (blank to skip): ")?); + let cvv = if cvv.is_empty() { None } else { Some(cvv) }; + let pin = Zeroizing::new(rpassword::prompt_password("PIN (blank to skip): ")?); + let pin = if pin.is_empty() { None } else { Some(pin) }; + + let parsed_expiry = match expiry { + Some(s) => Some(parse_month_year(&s)?), + None => None, + }; + let parsed_kind = match kind.as_str() { + "credit" => CardKind::Credit, + "debit" => CardKind::Debit, + "gift" => CardKind::Gift, + "loyalty"=> CardKind::Loyalty, + "other" => CardKind::Other, + other => anyhow::bail!("unknown card kind: {other}"), + }; + + let mut item = Item::new(title, ItemCore::Card(CardCore { + number: Some(number), + holder, + expiry: parsed_expiry, + cvv, + pin, + kind: parsed_kind, + })); + item.group = group; + item.tags = tags; + item +} +``` + +Add the helper: + +```rust +fn parse_month_year(s: &str) -> Result { + // Accepts MM/YYYY or MM-YYYY or MM/YY. + let (m_str, y_str) = s.split_once(|c: char| c == '/' || c == '-') + .ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?; + let month: u8 = m_str.parse().context("invalid month")?; + let year: i32 = if y_str.len() == 2 { + 2000 + y_str.parse::().context("invalid 2-digit year")? + } else { + y_str.parse().context("invalid year")? + }; + Ok(relicario_core::MonthYear { month, year }) +} +``` + +- [ ] **Step 4: Implement Key** + +```rust +AddKind::Key { title, label, algorithm, group, tags } => { + use relicario_core::item_types::KeyCore; + use relicario_core::{Item, ItemCore}; + use zeroize::Zeroizing; + + let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; + eprintln!("Paste key material; end with Ctrl-D on a blank line:"); + let mut key_material = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?; + if key_material.trim().is_empty() { anyhow::bail!("key material required"); } + let public_key = prompt_optional("Public key (blank to skip)")?; + + let mut item = Item::new(title, ItemCore::Key(KeyCore { + key_material: Zeroizing::new(key_material), + label, + public_key, + algorithm, + })); + item.group = group; + item.tags = tags; + item +} +``` + +- [ ] **Step 5: Implement Document** + +```rust +AddKind::Document { title, file, group, tags } => { + use relicario_core::item_types::DocumentCore; + use relicario_core::{ + encrypt_attachment, AttachmentRef, Item, ItemCore, + }; + use std::fs; + + let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; + let bytes = fs::read(&file) + .with_context(|| format!("failed to read {}", file.display()))?; + let caps = vault.load_settings()?.attachment_caps; + let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?; + + let filename = file.file_name().unwrap().to_string_lossy().into_owned(); + let mime_type = guess_mime(&filename); + + let primary_attachment = enc.id.clone(); + let mut item = Item::new(title, ItemCore::Document(DocumentCore { + filename: filename.clone(), + mime_type: mime_type.clone(), + primary_attachment: primary_attachment.clone(), + })); + item.group = group; + item.tags = tags; + item.attachments.push(AttachmentRef { + id: primary_attachment.clone(), + filename, + mime_type, + size: bytes.len() as u64, + created: item.created, + }); + + // Persist the attachment blob before we return from the match arm so the + // later save_item + commit_paths step can reference it as a real file. + let att_dir = vault.root().join("attachments").join(item.id.as_str()); + fs::create_dir_all(&att_dir)?; + fs::write(att_dir.join(format!("{}.enc", primary_attachment.as_str())), &enc.bytes)?; + item +} + +fn guess_mime(filename: &str) -> String { + let lower = filename.to_ascii_lowercase(); + match lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or("") { + "pdf" => "application/pdf", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "txt" => "text/plain", + "json" => "application/json", + _ => "application/octet-stream", + }.to_string() +} +``` + +For Document, widen the commit so the attachment file is also staged: + +```rust +// Replace the default `commit_paths(...)` at the bottom of cmd_add with the +// attachments-aware version below (so both Document and non-Document paths +// stage everything they wrote): +let mut paths: Vec = vec![ + format!("items/{}.enc", item.id.as_str()), + "manifest.enc".into(), +]; +for att in &item.attachments { + paths.push(format!("attachments/{}/{}.enc", item.id.as_str(), att.id.as_str())); +} +let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect(); +commit_paths(&vault, &format!("add: {} ({})", item.title, item.id.as_str()), &path_refs)?; +``` + +- [ ] **Step 6: Implement Totp** + +```rust +AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } => { + use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpCore, TotpKind}; + use relicario_core::{Item, ItemCore}; + use zeroize::Zeroizing; + + let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; + let secret_b32 = match secret { + Some(s) => s, + None => rpassword::prompt_password("TOTP secret (base32): ")?, + }; + let secret_bytes = base32_decode_lenient(&secret_b32)?; + let algo = match algorithm.to_ascii_lowercase().as_str() { + "sha1" => TotpAlgorithm::Sha1, + "sha256" => TotpAlgorithm::Sha256, + "sha512" => TotpAlgorithm::Sha512, + other => anyhow::bail!("unknown algorithm: {other}"), + }; + + let core = TotpCore { + config: TotpConfig { + secret: Zeroizing::new(secret_bytes), + algorithm: algo, + digits, + period_seconds: period, + kind: TotpKind::Totp, + }, + issuer, + label, + }; + let mut item = Item::new(title, ItemCore::Totp(core)); + item.group = group; + item.tags = tags; + item +} + +fn base32_decode_lenient(s: &str) -> Result> { + let cleaned: String = s.chars() + .filter(|c| !c.is_whitespace()) + .collect::() + .to_ascii_uppercase() + .trim_end_matches('=') + .to_string(); + let padded = { + let rem = cleaned.len() % 8; + if rem == 0 { cleaned } else { format!("{}{}", cleaned, "=".repeat(8 - rem)) } + }; + data_encoding::BASE32.decode(padded.as_bytes()) + .map_err(|e| anyhow::anyhow!("invalid base32: {e}")) +} +``` + +Add `data-encoding = "2"` to `relicario-cli/Cargo.toml` dependencies. + +- [ ] **Step 7: Build + smoke each variant** + +```bash +cargo build -p relicario-cli +# Exercise each in /tmp/relicario-smoke (one at a time): +cargo run <…> -- add secure-note --title "diary" --body-prompt +cargo run <…> -- add identity --title "me" --full-name "Alice Example" --email a@example.com +cargo run <…> -- add card --title "Amex" --holder "Alice" --expiry 12/2030 --kind credit +cargo run <…> -- add key --title "ssh-prod" --label "prod@example" --algorithm ed25519 +echo "hello world" > /tmp/note.txt +cargo run <…> -- add document --title "Note" --file /tmp/note.txt +cargo run <…> -- add totp --title "GitHub 2FA" --secret JBSWY3DPEHPK3PXP --issuer github.com +``` + +- [ ] **Step 8: Commit** + +```bash +git add crates/relicario-cli/src/main.rs crates/relicario-cli/Cargo.toml +git commit -m "feat(cli): relicario add β€” remaining 6 item types + +SecureNote, Identity, Card, Key, Document (with inline attachment), +and Totp with base32 secret decoding. Document widens the commit +to include the attachment blob path." +``` + +--- + +## Task 9: `relicario get` β€” query, mask-by-default, clipboard with Zeroizing (audit M6, M7) + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` (`cmd_get`) + +- [ ] **Step 1: Implement `cmd_get`** + +```rust +fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> { + use relicario_core::ItemCore; + use zeroize::Zeroizing; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let manifest = vault.load_manifest()?; + let entry = resolve_query(&manifest, &query)?; + let item = vault.load_item(&entry.id)?; + + println!("ID: {}", item.id.as_str()); + println!("Title: {}", item.title); + println!("Type: {:?}", item.r#type); + if let Some(g) = &item.group { println!("Group: {g}"); } + if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); } + println!("Created: {}", crate::helpers::iso8601(item.created)); + println!("Modified: {}", crate::helpers::iso8601(item.modified)); + if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); } + println!(); + + let primary_secret: Option> = match &item.core { + ItemCore::Login(l) => { + if let Some(u) = &l.username { println!("Username: {u}"); } + if let Some(u) = &l.url { println!("URL: {u}"); } + if let Some(p) = &l.password { Some(p.clone()) } else { None } + } + ItemCore::SecureNote(n) => { + if show { println!("Body:\n{}", n.body.as_str()); } + else { println!("Body: ********"); } + None + } + ItemCore::Identity(i) => { + if let Some(v) = &i.full_name { println!("Name: {v}"); } + if let Some(v) = &i.email { println!("Email: {v}"); } + if let Some(v) = &i.phone { println!("Phone: {v}"); } + if let Some(v) = &i.date_of_birth { println!("DOB: {v}"); } + None + } + ItemCore::Card(c) => { + if let Some(h) = &c.holder { println!("Holder: {h}"); } + if let Some(e) = &c.expiry { println!("Expiry: {:02}/{}", e.month, e.year); } + println!("Kind: {:?}", c.kind); + c.number.clone() + } + ItemCore::Key(k) => { + if let Some(l) = &k.label { println!("Label: {l}"); } + if let Some(a) = &k.algorithm { println!("Algo: {a}"); } + if let Some(pk) = &k.public_key { println!("Pubkey: {pk}"); } + Some(k.key_material.clone()) + } + ItemCore::Document(d) => { + println!("Filename: {}", d.filename); + println!("MIME: {}", d.mime_type); + None + } + ItemCore::Totp(t) => { + if let Some(i) = &t.issuer { println!("Issuer: {i}"); } + if let Some(l) = &t.label { println!("Label: {l}"); } + println!("Period: {}s", t.config.period_seconds); + println!("Digits: {}", t.config.digits); + None + } + }; + + if let Some(secret) = primary_secret { + if show { + println!("Secret: {}", secret.as_str()); + } else { + println!("Secret: ******** (use --show to reveal, --copy to clipboard)"); + } + if copy { + copy_to_clipboard_then_clear(&secret)?; + eprintln!("Copied to clipboard (auto-clears in 30s)."); + } + } + + Ok(()) +} + +fn resolve_query<'a>( + manifest: &'a relicario_core::Manifest, + query: &str, +) -> Result<&'a relicario_core::ManifestEntry> { + if let Some(entry) = manifest.items.values().find(|e| e.id.as_str() == query) { + return Ok(entry); + } + let hits: Vec<_> = manifest.search(query); + match hits.len() { + 0 => anyhow::bail!("no item matches `{query}`"), + 1 => Ok(hits[0]), + _ => { + let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect(); + anyhow::bail!("ambiguous β€” {} matches: {}", hits.len(), titles.join(", ")) + } + } +} + +fn copy_to_clipboard_then_clear(secret: &zeroize::Zeroizing) -> Result<()> { + use arboard::Clipboard; + let mut cb = Clipboard::new().context("failed to access clipboard")?; + cb.set_text(secret.as_str().to_string()).context("failed to write clipboard")?; + let cleared_copy = zeroize::Zeroizing::new(secret.as_str().to_owned()); + // Unconditional clear (audit M6): spawn a detached thread that waits 30s + // and then rewrites the clipboard with empty string. Even if the user + // copies something else in the interim, we still overwrite once. + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(30)); + if let Ok(mut cb) = Clipboard::new() { + let _ = cb.set_text(String::new()); + drop(cleared_copy); // zeroize the detached copy + } + }); + Ok(()) +} +``` + +- [ ] **Step 2: Build + smoke** + +```bash +cargo build -p relicario-cli +cd /tmp/relicario-smoke +cargo run <…> -- get GitHub # expect masked +cargo run <…> -- get GitHub --show # expect secret printed +cargo run <…> -- get GitHub --copy # copies, auto-clears in 30s +``` + +- [ ] **Step 3: Commit** + +```bash +git add crates/relicario-cli/src/main.rs +git commit -m "feat(cli): relicario get with masking, --show, and zeroize-clipboard + +Secrets masked by default (audit M7). --show reveals plaintext. +--copy writes to clipboard and spawns a detached 30s auto-clear +thread holding a Zeroizing copy that wipes on drop (audit M6)." +``` + +--- + +## Task 10: `relicario list` with filters + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` (`cmd_list`) + +- [ ] **Step 1: Implement `cmd_list`** + +```rust +fn cmd_list( + type_filter: Option, + group_filter: Option, + tag_filter: Option, + trashed: bool, +) -> Result<()> { + use relicario_core::ItemType; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let manifest = vault.load_manifest()?; + + let parsed_type: Option = match type_filter.as_deref() { + None => None, + Some("login") => Some(ItemType::Login), + Some("secure_note") | Some("note") => Some(ItemType::SecureNote), + Some("identity") => Some(ItemType::Identity), + Some("card") => Some(ItemType::Card), + Some("key") => Some(ItemType::Key), + Some("document") => Some(ItemType::Document), + Some("totp") => Some(ItemType::Totp), + Some(other) => anyhow::bail!("unknown type filter: {other}"), + }; + + let mut entries: Vec<_> = manifest.items.values() + .filter(|e| { + if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() } + }) + .filter(|e| parsed_type.map_or(true, |t| e.r#type == t)) + .filter(|e| group_filter.as_ref().map_or(true, |g| e.group.as_deref() == Some(g.as_str()))) + .filter(|e| tag_filter.as_ref().map_or(true, |t| e.tags.iter().any(|x| x == t))) + .collect(); + entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())); + + if entries.is_empty() { + eprintln!("(no items match)"); + return Ok(()); + } + + println!("{:<16} {:<14} {:<6} {}", "ID", "TYPE", "FAV", "TITLE"); + for e in entries { + let fav = if e.favorite { " *" } else { "" }; + println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title); + } + Ok(()) +} +``` + +- [ ] **Step 2: Build + smoke** + +```bash +cargo build -p relicario-cli +cd /tmp/relicario-smoke +cargo run <…> -- list +cargo run <…> -- list --type login +cargo run <…> -- list --tag work +cargo run <…> -- list --trashed +``` + +- [ ] **Step 3: Commit** + +```bash +git add crates/relicario-cli/src/main.rs +git commit -m "feat(cli): relicario list with --type/--group/--tag/--trashed filters" +``` + +--- + +## Task 11: `relicario edit` β€” interactive field updates + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` (`cmd_edit`) + +**Scope:** interactive prompt-per-field; only asks about fields the user wants to change; leaves other fields untouched. History is captured automatically by `Item::set_field_value` for Password/Concealed/Totp kinds, but `cmd_edit` also supports editing the typed core's primary fields (Login.password, etc.) β€” for those, we manually push to `field_history` via a small helper because set_field_value only operates on custom fields. + +- [ ] **Step 1: Implement `cmd_edit`** + +```rust +fn cmd_edit(query: String) -> Result<()> { + use relicario_core::item::FieldHistoryEntry; + use relicario_core::time::now_unix; + use relicario_core::ItemCore; + use zeroize::Zeroizing; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + let entry = resolve_query(&manifest, &query)?; + let id = entry.id.clone(); + drop(entry); + let mut item = vault.load_item(&id)?; + + eprintln!("Editing: {} ({}) β€” leave a prompt blank to keep the current value.", + item.title, item.id.as_str()); + + // Title + if let Some(v) = prompt_keep("Title", &item.title)? { item.title = v; } + // Group + if let Some(v) = prompt_keep_opt("Group", item.group.as_deref())? { item.group = Some(v); } + // Tags (comma-separated) + if let Some(v) = prompt_keep_opt("Tags (comma-separated)", Some(&item.tags.join(",")))? { + item.tags = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(); + } + + // Core-specific fields. Only Login.password and Card.number/cvv/pin are + // history-tracked from the core path. + match &mut item.core { + ItemCore::Login(l) => { + if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); } + if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? { + l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?); + } + if prompt_yesno("Change password?")? { + let old = l.password.clone(); + let new_pw = Zeroizing::new(rpassword::prompt_password("New password: ")?); + l.password = Some(new_pw); + if let Some(old_pw) = old { + push_history(&mut item.field_history, /* synthetic field_id */ "login_password", + Zeroizing::new(old_pw.as_str().to_string())); + } + } + } + ItemCore::SecureNote(n) => { + if prompt_yesno("Edit body?")? { + let old = n.body.clone(); + eprintln!("Enter new body; end with Ctrl-D:"); + let mut s = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?; + n.body = Zeroizing::new(s); + push_history(&mut item.field_history, "secure_note_body", + Zeroizing::new(old.as_str().to_string())); + } + } + ItemCore::Identity(i) => { + if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); } + if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); } + if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); } + } + ItemCore::Card(c) => { + if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); } + if prompt_yesno("Change card number?")? { + let old = c.number.clone(); + c.number = Some(Zeroizing::new(rpassword::prompt_password("New number: ")?)); + if let Some(o) = old { + push_history(&mut item.field_history, "card_number", + Zeroizing::new(o.as_str().to_string())); + } + } + } + ItemCore::Key(k) => { + if prompt_yesno("Replace key material?")? { + eprintln!("Paste new key material; end with Ctrl-D:"); + let mut s = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?; + let old = k.key_material.clone(); + k.key_material = Zeroizing::new(s); + push_history(&mut item.field_history, "key_material", + Zeroizing::new(old.as_str().to_string())); + } + } + ItemCore::Document(_) => { + eprintln!("Document items: use `relicario attach` / `relicario extract` instead."); + } + ItemCore::Totp(_) => { + eprintln!("TOTP rotation not yet implemented β€” delete and re-add for now."); + } + } + + item.modified = now_unix(); + vault.save_item(&item)?; + manifest.upsert(&item); + vault.save_manifest(&manifest)?; + commit_paths(&vault, &format!("edit: {} ({})", item.title, item.id.as_str()), + &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; + eprintln!("Updated {}", item.id.as_str()); + Ok(()) +} + +fn prompt_keep(label: &str, current: &str) -> Result> { + eprint!("{label} [{current}]: "); + std::io::Write::flush(&mut std::io::stderr())?; + let mut s = String::new(); + std::io::stdin().read_line(&mut s)?; + let trimmed = s.trim().to_string(); + Ok(if trimmed.is_empty() { None } else { Some(trimmed) }) +} + +fn prompt_keep_opt(label: &str, current: Option<&str>) -> Result> { + let display = current.unwrap_or("(none)"); + eprint!("{label} [{display}]: "); + std::io::Write::flush(&mut std::io::stderr())?; + let mut s = String::new(); + std::io::stdin().read_line(&mut s)?; + let trimmed = s.trim().to_string(); + Ok(if trimmed.is_empty() { None } else { Some(trimmed) }) +} + +fn prompt_yesno(label: &str) -> Result { + eprint!("{label} [y/N] "); + std::io::Write::flush(&mut std::io::stderr())?; + let mut s = String::new(); + std::io::stdin().read_line(&mut s)?; + Ok(matches!(s.trim().to_ascii_lowercase().as_str(), "y" | "yes")) +} + +fn push_history( + history: &mut std::collections::HashMap>, + synthetic_key: &str, + old_value: zeroize::Zeroizing, +) { + use relicario_core::item::FieldHistoryEntry; + use relicario_core::time::now_unix; + // Synthetic FieldId for core-level fields β€” stable per-item (prefixed so + // custom-field UUIDs can't collide). + let fid = relicario_core::FieldId(format!("core:{synthetic_key}")); + history.entry(fid).or_default().push(FieldHistoryEntry { + value: old_value, + replaced_at: now_unix(), + }); +} +``` + +- [ ] **Step 2: Build + smoke** + +```bash +cargo build -p relicario-cli +cd /tmp/relicario-smoke +cargo run <…> -- edit GitHub +# Press Enter on title/group/url, answer "y" to change password, enter new password. +cargo run <…> -- get GitHub --show +# Confirm new password present; git log shows edit commit. +``` + +- [ ] **Step 3: Commit** + +```bash +git add crates/relicario-cli/src/main.rs +git commit -m "feat(cli): relicario edit β€” interactive field updates + history + +Title/group/tags always optional. Per-type prompts for core secret +fields (Login.password, Card.number, Key.material, SecureNote.body) +push the old value to field_history via a synthetic core: +FieldId so rotation is audit-traceable." +``` + +--- + +## Task 12: Trash ops β€” `rm`, `restore`, `purge`, `trash list`, `trash empty` + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` (`cmd_rm`, `cmd_restore`, `cmd_purge`, `cmd_trash`) + +- [ ] **Step 1: Implement the four** + +```rust +fn cmd_rm(query: String) -> Result<()> { + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + let entry = resolve_query(&manifest, &query)?; + let id = entry.id.clone(); + drop(entry); + let mut item = vault.load_item(&id)?; + item.soft_delete(); + vault.save_item(&item)?; + manifest.upsert(&item); + vault.save_manifest(&manifest)?; + commit_paths(&vault, &format!("trash: {} ({})", item.title, item.id.as_str()), + &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; + eprintln!("Moved to trash: {}", item.title); + Ok(()) +} + +fn cmd_restore(query: String) -> Result<()> { + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + let entry = resolve_query(&manifest, &query)?; + let id = entry.id.clone(); + drop(entry); + let mut item = vault.load_item(&id)?; + item.restore(); + vault.save_item(&item)?; + manifest.upsert(&item); + vault.save_manifest(&manifest)?; + commit_paths(&vault, &format!("restore: {} ({})", item.title, item.id.as_str()), + &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; + eprintln!("Restored: {}", item.title); + Ok(()) +} + +fn cmd_purge(query: String) -> Result<()> { + use std::fs; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + let entry = resolve_query(&manifest, &query)?; + let id = entry.id.clone(); + let title = entry.title.clone(); + drop(entry); + + // Remove the item file, its attachments directory, and drop the manifest entry. + let item_path = vault.item_path(&id); + if item_path.exists() { fs::remove_file(&item_path)?; } + let att_dir = vault.root().join("attachments").join(id.as_str()); + if att_dir.exists() { fs::remove_dir_all(&att_dir)?; } + manifest.remove(&id); + vault.save_manifest(&manifest)?; + + let mut paths = vec![format!("items/{}.enc", id.as_str()), "manifest.enc".into()]; + paths.push(format!("attachments/{}", id.as_str())); + let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect(); + // `git add -- attachments/` after rmdir stages the deletion. + let _ = crate::helpers::git_command(vault.root(), &["rm", "-rf", "--ignore-unmatch", + &format!("items/{}.enc", id.as_str()), + &format!("attachments/{}", id.as_str()), + ]).status()?; + let status = crate::helpers::git_command(vault.root(), &["add", "manifest.enc"]).status()?; + if !status.success() { anyhow::bail!("git add manifest.enc failed"); } + let status = crate::helpers::git_command(vault.root(), + &["commit", "-m", &format!("purge: {} ({})", title, id.as_str())]).status()?; + if !status.success() { anyhow::bail!("git commit failed"); } + eprintln!("Purged: {title}"); + Ok(()) +} + +fn cmd_trash(action: TrashAction) -> Result<()> { + match action { + TrashAction::List => cmd_list(None, None, None, true), + TrashAction::Empty => cmd_trash_empty(), + } +} + +fn cmd_trash_empty() -> Result<()> { + use relicario_core::time::now_unix; + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let manifest = vault.load_manifest()?; + let settings = vault.load_settings()?; + let now = now_unix(); + + let purgeable: Vec<_> = manifest.items.values() + .filter(|e| match e.trashed_at { + Some(t) => settings.trash_retention.should_purge(t, now), + None => false, + }) + .map(|e| (e.id.clone(), e.title.clone())) + .collect(); + + if purgeable.is_empty() { + eprintln!("nothing past retention window"); + return Ok(()); + } + + for (id, title) in purgeable { + cmd_purge(id.as_str().to_string())?; + eprintln!(" purged {title}"); + } + Ok(()) +} +``` + +- [ ] **Step 2: Build + smoke** + +```bash +cargo build -p relicario-cli +cd /tmp/relicario-smoke +cargo run <…> -- rm GitHub +cargo run <…> -- list --trashed # expect GitHub listed +cargo run <…> -- restore GitHub +cargo run <…> -- purge GitHub +git log --oneline | head -6 +``` + +- [ ] **Step 3: Commit** + +```bash +git add crates/relicario-cli/src/main.rs +git commit -m "feat(cli): trash ops β€” rm / restore / purge / trash empty + +Soft-delete sets trashed_at via Item::soft_delete; restore clears it. +Purge deletes item + attachment dir and removes manifest entry. +Trash empty scans for items past settings.trash_retention." +``` + +--- + +## Task 13: Attachment ops β€” `attach`, `attachments`, `extract` + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` (`cmd_attach`, `cmd_attachments`, `cmd_extract`) + +- [ ] **Step 1: Implement all three** + +```rust +fn cmd_attach(query: String, file: PathBuf) -> Result<()> { + use std::fs; + use relicario_core::{encrypt_attachment, AttachmentRef}; + use relicario_core::time::now_unix; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut manifest = vault.load_manifest()?; + let entry = resolve_query(&manifest, &query)?; + let id = entry.id.clone(); + drop(entry); + let mut item = vault.load_item(&id)?; + let settings = vault.load_settings()?; + let caps = settings.attachment_caps; + + if item.attachments.len() as u32 >= caps.per_item_max_count { + anyhow::bail!("item already has {} attachments (max {})", + item.attachments.len(), caps.per_item_max_count); + } + + let bytes = fs::read(&file) + .with_context(|| format!("failed to read {}", file.display()))?; + let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?; + + let filename = file.file_name().unwrap().to_string_lossy().into_owned(); + let mime_type = guess_mime(&filename); + let aref = AttachmentRef { + id: enc.id.clone(), + filename, + mime_type, + size: bytes.len() as u64, + created: now_unix(), + }; + + let att_dir = vault.root().join("attachments").join(item.id.as_str()); + fs::create_dir_all(&att_dir)?; + fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?; + + item.attachments.push(aref); + item.modified = now_unix(); + vault.save_item(&item)?; + manifest.upsert(&item); + vault.save_manifest(&manifest)?; + + let paths = [ + format!("items/{}.enc", item.id.as_str()), + "manifest.enc".into(), + format!("attachments/{}/{}.enc", item.id.as_str(), enc.id.as_str()), + ]; + let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect(); + commit_paths(&vault, &format!("attach: {} β†’ {} ({})", + file.display(), item.title, item.id.as_str()), &path_refs)?; + eprintln!("Attached {} to {} (aid={})", file.display(), item.title, enc.id.as_str()); + Ok(()) +} + +fn cmd_attachments(query: String) -> Result<()> { + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let manifest = vault.load_manifest()?; + let entry = resolve_query(&manifest, &query)?; + let item = vault.load_item(&entry.id)?; + if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); } + println!("{:<17} {:>12} {:<22} {}", "AID", "SIZE", "MIME", "FILENAME"); + for a in &item.attachments { + println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename); + } + Ok(()) +} + +fn cmd_extract(query: String, aid: String, out: Option) -> Result<()> { + use std::fs; + use relicario_core::decrypt_attachment; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let manifest = vault.load_manifest()?; + let entry = resolve_query(&manifest, &query)?; + let item = vault.load_item(&entry.id)?; + + let aref = item.attachments.iter().find(|a| a.id.as_str() == aid) + .ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?; + let path = vault.root().join("attachments").join(item.id.as_str()) + .join(format!("{}.enc", aid)); + let bytes = fs::read(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + let plaintext = decrypt_attachment(&bytes, vault.key())?; + let out_path = out.unwrap_or_else(|| PathBuf::from(&aref.filename)); + fs::write(&out_path, plaintext.as_slice()) + .with_context(|| format!("failed to write {}", out_path.display()))?; + eprintln!("Wrote {} bytes to {}", plaintext.len(), out_path.display()); + Ok(()) +} +``` + +- [ ] **Step 2: Build + smoke** + +```bash +cargo build -p relicario-cli +echo "attached data" > /tmp/attachdata.txt +cargo run <…> -- add login --title "Example" --username alice --password foo +cargo run <…> -- attach Example /tmp/attachdata.txt +cargo run <…> -- attachments Example +cargo run <…> -- extract Example --out /tmp/extracted.txt +diff /tmp/attachdata.txt /tmp/extracted.txt +``` + +- [ ] **Step 3: Commit** + +```bash +git add crates/relicario-cli/src/main.rs +git commit -m "feat(cli): attachment ops β€” attach / attachments / extract + +Respects AttachmentCaps from settings.enc; content-addressed aid +comes from core::encrypt_attachment." +``` + +--- + +## Task 14: `relicario generate` β€” delegate to core (audit H6) + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` (`cmd_generate`) + +**Why this task also closes audit H6:** it removes the CLI's ad-hoc `next_u32() % CHARSET.len()` generator (from the pre-1A code) and instead calls `relicario_core::generate_password` / `generate_passphrase`, which use `rand::distributions::Uniform` internally. + +- [ ] **Step 1: Implement `cmd_generate`** + +```rust +fn cmd_generate(length: u32, bip39: bool, words: u32, symbols: String, separator: String) -> Result<()> { + use relicario_core::{ + generate_passphrase, generate_password, Capitalization, CharClasses, + GeneratorRequest, SymbolCharset, + }; + + let output = if bip39 { + generate_passphrase(&GeneratorRequest::Bip39 { + word_count: words, + separator, + capitalization: Capitalization::Lower, + })? + } else { + let symbol_charset = match symbols.as_str() { + "safe" => SymbolCharset::SafeOnly, + "extended" => SymbolCharset::Extended, + other => SymbolCharset::Custom(other.to_string()), + }; + generate_password(&GeneratorRequest::Random { + length, + classes: CharClasses { lower: true, upper: true, digits: true, symbols: true }, + symbol_charset, + })? + }; + + // Plain println! is fine here β€” the user explicitly requested generation + // and this is not a stored secret being revealed. + println!("{}", output.as_str()); + Ok(()) +} +``` + +- [ ] **Step 2: Build + smoke** + +```bash +cargo build -p relicario-cli +cargo run <…> -- generate --length 32 +cargo run <…> -- generate --bip39 --words 5 +cargo run <…> -- generate --bip39 --words 4 --separator - +cargo run <…> -- generate --length 24 --symbols extended +``` + +- [ ] **Step 3: Commit** + +```bash +git add crates/relicario-cli/src/main.rs +git commit -m "feat(cli): relicario generate delegates to core (audit H6) + +CLI no longer has its own charset-sampling path β€” uses the CSPRNG +generate_password / generate_passphrase in relicario-core, which use +rand::distributions::Uniform internally." +``` + +--- + +## Task 15: `relicario settings` subcommands + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` (`cmd_settings`) + +- [ ] **Step 1: Implement** + +```rust +fn cmd_settings(action: SettingsAction) -> Result<()> { + use relicario_core::{HistoryRetention, TrashRetention}; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let mut settings = vault.load_settings()?; + + match action { + SettingsAction::Show => { + println!("{}", serde_json::to_string_pretty(&settings)?); + return Ok(()); + } + SettingsAction::TrashRetention { days, forever } => { + settings.trash_retention = match (days, forever) { + (Some(d), false) => TrashRetention::Days(d), + (None, true) => TrashRetention::Forever, + _ => anyhow::bail!("specify exactly one of --days or --forever"), + }; + } + SettingsAction::HistoryRetention { last_n, days, forever } => { + settings.field_history_retention = match (last_n, days, forever) { + (Some(n), None, false) => HistoryRetention::LastN(n), + (None, Some(d), false) => HistoryRetention::Days(d), + (None, None, true) => HistoryRetention::Forever, + _ => anyhow::bail!("specify exactly one of --last-n / --days / --forever"), + }; + } + SettingsAction::AttachmentCap { + per_attachment_max_bytes, per_item_max_count, + per_vault_soft_cap_bytes, per_vault_hard_cap_bytes, + } => { + if let Some(v) = per_attachment_max_bytes { settings.attachment_caps.per_attachment_max_bytes = v; } + if let Some(v) = per_item_max_count { settings.attachment_caps.per_item_max_count = v; } + if let Some(v) = per_vault_soft_cap_bytes { settings.attachment_caps.per_vault_soft_cap_bytes = v; } + if let Some(v) = per_vault_hard_cap_bytes { settings.attachment_caps.per_vault_hard_cap_bytes = v; } + } + } + + vault.save_settings(&settings)?; + commit_paths(&vault, "settings: update", &["settings.enc"])?; + eprintln!("Settings updated."); + Ok(()) +} +``` + +- [ ] **Step 2: Build + smoke** + +```bash +cargo build -p relicario-cli +cargo run <…> -- settings show +cargo run <…> -- settings trash-retention --days 60 +cargo run <…> -- settings history-retention --last-n 10 +cargo run <…> -- settings attachment-cap --per-attachment-max-bytes 5242880 +cargo run <…> -- settings show +``` + +- [ ] **Step 3: Commit** + +```bash +git add crates/relicario-cli/src/main.rs +git commit -m "feat(cli): relicario settings show / trash-retention / history-retention / attachment-cap" +``` + +--- + +## Task 16: `relicario sync` β€” hardened rebase + push (audit H4) + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` (`cmd_sync`) + +- [ ] **Step 1: Implement** + +```rust +fn cmd_sync() -> Result<()> { + let root = crate::helpers::vault_dir()?; + let pull = crate::helpers::git_command(&root, &["pull", "--rebase"]).status()?; + if !pull.success() { anyhow::bail!("git pull --rebase failed"); } + let push = crate::helpers::git_command(&root, &["push"]).status()?; + if !push.success() { anyhow::bail!("git push failed"); } + eprintln!("Sync complete."); + Ok(()) +} +``` + +- [ ] **Step 2: Build + smoke** + +```bash +cargo build -p relicario-cli +# In a vault with a remote set: +cargo run <…> -- sync +``` + +- [ ] **Step 3: Commit** + +```bash +git add crates/relicario-cli/src/main.rs +git commit -m "feat(cli): relicario sync β€” pull --rebase then push via hardened git" +``` + +--- + +## Task 17: `relicario device` β€” add / list / revoke + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` (`cmd_device`) + +Device management is orthogonal to the typed-item rewrite but still needs to move to the new commit flow. Keep the existing ed25519 generation + 0600 file permissions logic. + +- [ ] **Step 1: Implement** + +```rust +fn cmd_device(action: DeviceAction) -> Result<()> { + use std::fs; + use ed25519_dalek::SigningKey; + use rand::rngs::OsRng; + + let root = crate::helpers::vault_dir()?; + let devices_path = root.join(".relicario").join("devices.json"); + + #[derive(serde::Serialize, serde::Deserialize)] + struct DeviceEntry { name: String, public_key: String } + + match action { + DeviceAction::Add { name } => { + let mut existing: Vec = + serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default(); + if existing.iter().any(|d| d.name == name) { + anyhow::bail!("device `{name}` already exists"); + } + let signing = SigningKey::generate(&mut OsRng); + let verifying = signing.verifying_key(); + let pubkey_hex = hex::encode(verifying.to_bytes()); + + existing.push(DeviceEntry { name: name.clone(), public_key: pubkey_hex.clone() }); + fs::write(&devices_path, serde_json::to_string_pretty(&existing)?)?; + + // Save the private key under ~/.config/relicario/devices/.key (0600). + let cfg_dir = dirs::config_dir() + .ok_or_else(|| anyhow::anyhow!("no config dir"))? + .join("relicario").join("devices"); + fs::create_dir_all(&cfg_dir)?; + let key_path = cfg_dir.join(format!("{name}.key")); + fs::write(&key_path, signing.to_bytes())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?; + } + + let status = crate::helpers::git_command(&root, + &["add", ".relicario/devices.json"]).status()?; + if !status.success() { anyhow::bail!("git add failed"); } + let status = crate::helpers::git_command(&root, + &["commit", "-m", &format!("device: add {name}")]).status()?; + if !status.success() { anyhow::bail!("git commit failed"); } + eprintln!("Added device `{name}` (pubkey: {pubkey_hex})"); + } + DeviceAction::List => { + let existing: Vec = + serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default(); + if existing.is_empty() { eprintln!("(no devices)"); return Ok(()); } + for d in existing { + println!("{:<20} {}", d.name, d.public_key); + } + } + DeviceAction::Revoke { name } => { + let mut existing: Vec = + serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default(); + let before = existing.len(); + existing.retain(|d| d.name != name); + if existing.len() == before { anyhow::bail!("device `{name}` not found"); } + fs::write(&devices_path, serde_json::to_string_pretty(&existing)?)?; + let status = crate::helpers::git_command(&root, + &["add", ".relicario/devices.json"]).status()?; + if !status.success() { anyhow::bail!("git add failed"); } + let status = crate::helpers::git_command(&root, + &["commit", "-m", &format!("device: revoke {name}")]).status()?; + if !status.success() { anyhow::bail!("git commit failed"); } + eprintln!("Revoked device `{name}`"); + } + } + Ok(()) +} +``` + +- [ ] **Step 2: Build + smoke** + +```bash +cargo build -p relicario-cli +cargo run <…> -- device add --name laptop +cargo run <…> -- device list +cargo run <…> -- device revoke laptop +``` + +- [ ] **Step 3: Commit** + +```bash +git add crates/relicario-cli/src/main.rs +git commit -m "feat(cli): device add / list / revoke rewired to hardened git" +``` + +--- + +## Task 18: WASM β€” Cargo.toml, session module, unlock/lock (audit H2, H5) + +**Files:** +- Modify: `crates/relicario-wasm/Cargo.toml` +- Rewrite: `crates/relicario-wasm/src/lib.rs` +- Create: `crates/relicario-wasm/src/session.rs` + +**What lands:** the opaque `SessionHandle` bridge. Master key lives in `Zeroizing<[u8; 32]>` inside a `HashMap` kept in a `thread_local!` (service-worker is single-threaded per-agent). JS never sees key bytes. + +- [ ] **Step 1: Cargo.toml** + +Overwrite `crates/relicario-wasm/Cargo.toml`: + +```toml +[package] +name = "relicario-wasm" +version = "0.1.0" +edition = "2021" +description = "WASM bindings for relicario password manager" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +relicario-core = { path = "../relicario-core" } +wasm-bindgen = "0.2" +serde-wasm-bindgen = "0.6" +serde_json = "1" +serde = { version = "1", features = ["derive"] } +zeroize = "1" +getrandom = { version = "0.2", features = ["js"] } + +[dev-dependencies] +wasm-bindgen-test = "0.3" +image = { version = "0.25", default-features = false, features = ["jpeg"] } +``` + +Removed: `js-sys`, `hmac`, `sha1`, `data-encoding`. TOTP now lives in `relicario-core::item_types::totp` and is computed there; the WASM layer only exposes a thin wrapper. + +- [ ] **Step 2: session.rs** + +Create `crates/relicario-wasm/src/session.rs`: + +```rust +//! Opaque session-handle bridge. The master key never leaves WASM linear +//! memory; JS receives only a u32 handle that it passes back on every +//! subsequent call. + +use std::cell::RefCell; +use std::collections::HashMap; +use zeroize::Zeroizing; + +thread_local! { + static SESSIONS: RefCell>> = RefCell::new(HashMap::new()); + static NEXT_HANDLE: RefCell = const { RefCell::new(1) }; +} + +pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 { + let handle = NEXT_HANDLE.with(|n| { + let mut n = n.borrow_mut(); + let h = *n; + *n = n.wrapping_add(1); + if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle + h + }); + SESSIONS.with(|s| { s.borrow_mut().insert(handle, key); }); + handle +} + +pub fn with(handle: u32, f: F) -> Option +where + F: FnOnce(&Zeroizing<[u8; 32]>) -> R, +{ + SESSIONS.with(|s| s.borrow().get(&handle).map(f)) +} + +pub fn remove(handle: u32) -> bool { + SESSIONS.with(|s| s.borrow_mut().remove(&handle).is_some()) +} + +/// For tests only β€” empty the table and wipe all sessions. +#[cfg(test)] +pub fn clear() { + SESSIONS.with(|s| s.borrow_mut().clear()); +} +``` + +- [ ] **Step 3: lib.rs β€” unlock/lock shell** + +Rewrite `crates/relicario-wasm/src/lib.rs` (start β€” the rest lands in Tasks 19-21): + +```rust +//! WASM bindings for relicario. +//! +//! The bridge exposes an opaque `SessionHandle` API: the master key is held +//! entirely in WASM linear memory, wrapped in `Zeroizing<[u8; 32]>`, and +//! looked up per call via a u32 handle. JS cannot read key bytes. + +mod session; + +use wasm_bindgen::prelude::*; +use zeroize::Zeroizing; + +use relicario_core::{derive_master_key, imgsecret, KdfParams}; + +/// Handle type returned from `unlock`. Backed by a `u32`; opaque to JS +/// (it's just a number there, but using a wrapper struct makes the +/// intent clear in TS bindings). +#[wasm_bindgen] +pub struct SessionHandle(u32); + +#[wasm_bindgen] +impl SessionHandle { + #[wasm_bindgen(getter)] + pub fn value(&self) -> u32 { self.0 } +} + +#[wasm_bindgen] +pub fn unlock( + passphrase: &str, + image_bytes: &[u8], + salt: &[u8], + params_json: &str, +) -> Result { + let params: KdfParams = serde_json::from_str(params_json) + .map_err(|e| JsError::new(&format!("params: {e}")))?; + let image_secret = imgsecret::extract(image_bytes) + .map_err(|e| JsError::new(&e.to_string()))?; + let salt_arr: &[u8; 32] = salt.try_into() + .map_err(|_| JsError::new("salt must be exactly 32 bytes"))?; + let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, ¶ms) + .map_err(|e| JsError::new(&e.to_string()))?; + let handle = session::insert(master_key); + Ok(SessionHandle(handle)) +} + +#[wasm_bindgen] +pub fn lock(handle: &SessionHandle) -> bool { + session::remove(handle.0) +} + +// Subsequent wasm_bindgen fns added in Tasks 19-21. +``` + +- [ ] **Step 4: Build** + +```bash +cargo build -p relicario-wasm --target wasm32-unknown-unknown +``` + +(If `wasm32-unknown-unknown` target isn't installed: `rustup target add wasm32-unknown-unknown`. Also fine to build native β€” `cargo build -p relicario-wasm` β€” as a sanity check; `js-sys`-free lib builds on host too.) + +- [ ] **Step 5: Unit test (native) for session table** + +Add to the bottom of `lib.rs`: + +```rust +#[cfg(test)] +mod session_tests { + use super::*; + use zeroize::Zeroizing; + + #[test] + fn insert_then_remove_clears_entry() { + session::clear(); + let h = session::insert(Zeroizing::new([0x11u8; 32])); + assert_ne!(h, 0); + assert!(session::remove(h)); + assert!(!session::remove(h)); // second remove false + } + + #[test] + fn with_yields_key_only_while_session_lives() { + session::clear(); + let h = session::insert(Zeroizing::new([0x22u8; 32])); + let byte = session::with(h, |k| k[0]); + assert_eq!(byte, Some(0x22)); + session::remove(h); + let byte = session::with(h, |k| k[0]); + assert_eq!(byte, None); + } +} +``` + +Run: `cargo test -p relicario-wasm --lib session_tests` β†’ expect 2 passing. + +- [ ] **Step 6: Commit** + +```bash +git add crates/relicario-wasm/Cargo.toml crates/relicario-wasm/src/session.rs crates/relicario-wasm/src/lib.rs +git commit -m "feat(wasm): opaque SessionHandle bridge with unlock/lock + +Master key never leaves WASM linear memory. Held in Zeroizing<[u8;32]> +inside a thread_local HashMap keyed by u32. lock() removes + zeroizes." +``` + +--- + +## Task 19: WASM β€” manifest / item / settings bridges + +**Files:** +- Modify: `crates/relicario-wasm/src/lib.rs` + +Add six `#[wasm_bindgen]` functions. Each takes a `SessionHandle` by reference, looks up the master key via `session::with`, and does encrypt/decrypt. Decrypt returns `JsValue` via `serde_wasm_bindgen`; encrypt takes JSON strings from JS to avoid serializing the whole type hierarchy into TS. + +- [ ] **Step 1: Add the six functions** + +Append to `lib.rs`: + +```rust +use serde_wasm_bindgen::to_value; +use relicario_core::{ + decrypt_item, decrypt_manifest, decrypt_settings, + encrypt_item, encrypt_manifest, encrypt_settings, + Item, Manifest, VaultSettings, +}; + +fn need_key(handle: &SessionHandle) -> Result<(), JsError> { + if session::with(handle.0, |_| ()).is_some() { Ok(()) } + else { Err(JsError::new("invalid or locked session handle")) } +} + +#[wasm_bindgen] +pub fn manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result { + need_key(handle)?; + let out = session::with(handle.0, |k| decrypt_manifest(encrypted, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + to_value(&out).map_err(|e| JsError::new(&e.to_string())) +} + +#[wasm_bindgen] +pub fn manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Result, JsError> { + need_key(handle)?; + let m: Manifest = serde_json::from_str(manifest_json) + .map_err(|e| JsError::new(&format!("manifest json: {e}")))?; + session::with(handle.0, |k| encrypt_manifest(&m, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string())) +} + +#[wasm_bindgen] +pub fn item_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result { + need_key(handle)?; + let out = session::with(handle.0, |k| decrypt_item(encrypted, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + to_value(&out).map_err(|e| JsError::new(&e.to_string())) +} + +#[wasm_bindgen] +pub fn item_encrypt(handle: &SessionHandle, item_json: &str) -> Result, JsError> { + need_key(handle)?; + let item: Item = serde_json::from_str(item_json) + .map_err(|e| JsError::new(&format!("item json: {e}")))?; + session::with(handle.0, |k| encrypt_item(&item, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string())) +} + +#[wasm_bindgen] +pub fn settings_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result { + need_key(handle)?; + let out = session::with(handle.0, |k| decrypt_settings(encrypted, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + to_value(&out).map_err(|e| JsError::new(&e.to_string())) +} + +#[wasm_bindgen] +pub fn settings_encrypt(handle: &SessionHandle, settings_json: &str) -> Result, JsError> { + need_key(handle)?; + let s: VaultSettings = serde_json::from_str(settings_json) + .map_err(|e| JsError::new(&format!("settings json: {e}")))?; + session::with(handle.0, |k| encrypt_settings(&s, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string())) +} +``` + +- [ ] **Step 2: Native unit test for round-trip** + +Add to the `session_tests` module: + +```rust +#[test] +fn manifest_round_trip_via_handle() { + use relicario_core::Manifest; + session::clear(); + let h = session::insert(Zeroizing::new([0x55u8; 32])); + let handle = SessionHandle(h); + let empty = Manifest::new(); + let bytes = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap(); + let js_out = manifest_decrypt(&handle, &bytes).unwrap(); + let parsed: Manifest = serde_wasm_bindgen::from_value(js_out).unwrap(); + assert_eq!(parsed.items.len(), 0); +} +``` + +Run: `cargo test -p relicario-wasm --lib manifest_round_trip_via_handle` β†’ expect passing. + +- [ ] **Step 3: Commit** + +```bash +git add crates/relicario-wasm/src/lib.rs +git commit -m "feat(wasm): manifest / item / settings encrypt+decrypt via SessionHandle" +``` + +--- + +## Task 20: WASM β€” attachments, generators, TOTP, imgsecret, IDs + +**Files:** +- Modify: `crates/relicario-wasm/src/lib.rs` + +- [ ] **Step 1: Add attachment and ID bridges** + +Append to `lib.rs`: + +```rust +use relicario_core::{decrypt_attachment, encrypt_attachment, FieldId, ItemId}; + +#[wasm_bindgen] +pub struct EncryptedAttachment { + aid: String, + bytes: Vec, +} + +#[wasm_bindgen] +impl EncryptedAttachment { + #[wasm_bindgen(getter)] pub fn aid(&self) -> String { self.aid.clone() } + #[wasm_bindgen(getter)] pub fn bytes(&self) -> Vec { self.bytes.clone() } +} + +#[wasm_bindgen] +pub fn attachment_encrypt( + handle: &SessionHandle, + plaintext: &[u8], + max_bytes: u64, +) -> Result { + need_key(handle)?; + let enc = session::with(handle.0, |k| encrypt_attachment(plaintext, k, max_bytes)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(EncryptedAttachment { aid: enc.id.as_str().to_owned(), bytes: enc.bytes }) +} + +#[wasm_bindgen] +pub fn attachment_decrypt( + handle: &SessionHandle, + encrypted: &[u8], +) -> Result, JsError> { + need_key(handle)?; + let plain = session::with(handle.0, |k| decrypt_attachment(encrypted, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(plain.to_vec()) +} + +#[wasm_bindgen] pub fn new_item_id() -> String { ItemId::new().as_str().to_owned() } +#[wasm_bindgen] pub fn new_field_id() -> String { FieldId::new().as_str().to_owned() } +``` + +- [ ] **Step 2: Add generators** + +```rust +use relicario_core::{ + generate_passphrase as core_generate_passphrase, + generate_password as core_generate_password, + rate_passphrase as core_rate_passphrase, + GeneratorRequest, +}; + +#[wasm_bindgen] +pub fn generate_password(request_json: &str) -> Result { + let req: GeneratorRequest = serde_json::from_str(request_json) + .map_err(|e| JsError::new(&format!("generator request: {e}")))?; + let out = core_generate_password(&req).map_err(|e| JsError::new(&e.to_string()))?; + Ok(out.as_str().to_owned()) +} + +#[wasm_bindgen] +pub fn generate_passphrase(request_json: &str) -> Result { + let req: GeneratorRequest = serde_json::from_str(request_json) + .map_err(|e| JsError::new(&format!("generator request: {e}")))?; + let out = core_generate_passphrase(&req).map_err(|e| JsError::new(&e.to_string()))?; + Ok(out.as_str().to_owned()) +} + +#[wasm_bindgen] +pub fn rate_passphrase(p: &str) -> Result { + let est = core_rate_passphrase(p); + to_value(&serde_json::json!({ + "score": est.score, + "guesses_log10": est.guesses_log10, + })).map_err(|e| JsError::new(&e.to_string())) +} +``` + +- [ ] **Step 3: Imgsecret wrappers** + +```rust +#[wasm_bindgen] +pub fn extract_image_secret(image_bytes: &[u8]) -> Result, JsError> { + let s = imgsecret::extract(image_bytes).map_err(|e| JsError::new(&e.to_string()))?; + Ok(s.to_vec()) +} + +#[wasm_bindgen] +pub fn embed_image_secret(carrier: &[u8], secret: &[u8]) -> Result, JsError> { + let s: &[u8; 32] = secret.try_into() + .map_err(|_| JsError::new("secret must be exactly 32 bytes"))?; + imgsecret::embed(carrier, s).map_err(|e| JsError::new(&e.to_string())) +} +``` + +- [ ] **Step 4: TOTP wrapper** + +`relicario-core::item_types::totp` exposes `compute_totp_code(&TotpConfig, now_unix_seconds: u64) -> Result`. (If that helper doesn't exist on the tag, this task has to add it to core too β€” see Step 4a below.) + +```rust +use relicario_core::item_types::{TotpConfig, compute_totp_code}; + +#[wasm_bindgen] +pub struct TotpCode { + code: String, + expires_at: u64, +} + +#[wasm_bindgen] +impl TotpCode { + #[wasm_bindgen(getter)] pub fn code(&self) -> String { self.code.clone() } + #[wasm_bindgen(getter)] pub fn expires_at(&self) -> u64 { self.expires_at } +} + +#[wasm_bindgen] +pub fn totp_compute( + config_json: &str, + now_unix_seconds: u64, +) -> Result { + let cfg: TotpConfig = serde_json::from_str(config_json) + .map_err(|e| JsError::new(&format!("totp config: {e}")))?; + let code = compute_totp_code(&cfg, now_unix_seconds) + .map_err(|e| JsError::new(&e.to_string()))?; + let period = cfg.period_seconds as u64; + let expires_at = ((now_unix_seconds / period) + 1) * period; + Ok(TotpCode { code, expires_at }) +} +``` + +- [ ] **Step 4a: Verify core has `compute_totp_code`** + +```bash +grep -rn "compute_totp_code" crates/relicario-core/src/ +``` + +If missing, add to `crates/relicario-core/src/item_types/totp.rs`: + +```rust +use hmac::{Hmac, Mac}; +use sha1::Sha1; +use sha2::{Sha256, Sha512}; + +use crate::error::{RelicarioError, Result}; + +pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result { + use TotpAlgorithm::*; + let counter = match config.kind { + TotpKind::Totp => now_unix_seconds / config.period_seconds as u64, + TotpKind::Hotp(counter) => counter, + TotpKind::Steam => now_unix_seconds / config.period_seconds as u64, + }; + let counter_bytes = counter.to_be_bytes(); + let hmac_out: Vec = match config.algorithm { + Sha1 => { + let mut mac = Hmac::::new_from_slice(&config.secret) + .map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?; + mac.update(&counter_bytes); + mac.finalize().into_bytes().to_vec() + } + Sha256 => { + let mut mac = Hmac::::new_from_slice(&config.secret) + .map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?; + mac.update(&counter_bytes); + mac.finalize().into_bytes().to_vec() + } + Sha512 => { + let mut mac = Hmac::::new_from_slice(&config.secret) + .map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?; + mac.update(&counter_bytes); + mac.finalize().into_bytes().to_vec() + } + }; + let offset = (hmac_out[hmac_out.len() - 1] & 0x0F) as usize; + let truncated = ((hmac_out[offset] as u32 & 0x7F) << 24) + | ((hmac_out[offset + 1] as u32) << 16) + | ((hmac_out[offset + 2] as u32) << 8) + | (hmac_out[offset + 3] as u32); + let modulus = 10u32.pow(config.digits as u32); + Ok(format!("{:0width$}", truncated % modulus, width = config.digits as usize)) +} + +#[cfg(test)] +mod compute_tests { + use super::*; + use zeroize::Zeroizing; + + #[test] + fn rfc6238_sha1_vector_59() { + let cfg = TotpConfig { + secret: Zeroizing::new(b"12345678901234567890".to_vec()), + algorithm: TotpAlgorithm::Sha1, + digits: 8, + period_seconds: 30, + kind: TotpKind::Totp, + }; + assert_eq!(compute_totp_code(&cfg, 59).unwrap(), "94287082"); + } +} +``` + +Also add `hmac = "0.12"`, `sha1 = "0.10"`, `sha2 = "0.10"` to `relicario-core/Cargo.toml` if not already present. + +- [ ] **Step 5: Build** + +```bash +cargo build -p relicario-core +cargo build -p relicario-wasm --target wasm32-unknown-unknown +``` + +- [ ] **Step 6: Run native WASM tests** + +```bash +cargo test -p relicario-wasm --lib +cargo test -p relicario-core item_types::totp +``` + +Expected: all pass. + +- [ ] **Step 7: Commit** + +```bash +git add crates/relicario-wasm/src/lib.rs crates/relicario-core +git commit -m "feat(wasm): attachment / generator / totp / imgsecret / id bridges + +Also ports TOTP RFC 6238 compute to relicario-core::item_types::totp +so native + CLI + WASM share one implementation (audit H5: CSPRNG +via core's Uniform-sampling generator)." +``` + +--- + +## Task 21: WASM β€” serde_wasm_bindgen configuration + TypeScript type declarations + +**Files:** +- Modify: `crates/relicario-wasm/src/lib.rs` (configure serializer) +- Create: `extension/src/wasm.d.ts` (TS declarations β€” consumed by Plan 1C) + +The default `serde_wasm_bindgen::to_value` produces JS `Map`s for Rust `HashMap`. The extension wants plain objects. Configure the serializer accordingly. + +- [ ] **Step 1: Plain-object serializer helper** + +In `lib.rs`, replace the `to_value` import with a local helper: + +```rust +use serde_wasm_bindgen::Serializer; + +fn js_value_for(v: &T) -> Result { + let ser = Serializer::new().serialize_maps_as_objects(true); + v.serialize(&ser).map_err(|e| JsError::new(&e.to_string())) +} +``` + +Replace every `to_value(&out)` call with `js_value_for(&out)`, and every `to_value(&serde_json::json!(...))` with `js_value_for(&serde_json::json!(...))`. + +- [ ] **Step 2: TS declarations** + +Create `extension/src/wasm.d.ts`: + +```ts +// Thin TypeScript declarations for the relicario-wasm bindings. +// These are hand-written to mirror the #[wasm_bindgen] signatures in +// crates/relicario-wasm/src/lib.rs; keep them in sync manually. + +export class SessionHandle { + readonly value: number; + free(): void; +} + +export class EncryptedAttachment { + readonly aid: string; + readonly bytes: Uint8Array; + free(): void; +} + +export class TotpCode { + readonly code: string; + readonly expires_at: number; + free(): void; +} + +export function unlock( + passphrase: string, + image_bytes: Uint8Array, + salt: Uint8Array, + params_json: string, +): SessionHandle; + +export function lock(handle: SessionHandle): boolean; + +export function manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown; +export function manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array; +export function item_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown; +export function item_encrypt(handle: SessionHandle, item_json: string): Uint8Array; +export function settings_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown; +export function settings_encrypt(handle: SessionHandle, settings_json: string): Uint8Array; + +export function attachment_encrypt( + handle: SessionHandle, + plaintext: Uint8Array, + max_bytes: bigint, +): EncryptedAttachment; +export function attachment_decrypt(handle: SessionHandle, encrypted: Uint8Array): Uint8Array; + +export function new_item_id(): string; +export function new_field_id(): string; + +export function generate_password(request_json: string): string; +export function generate_passphrase(request_json: string): string; +export function rate_passphrase(p: string): { score: number; guesses_log10: number }; + +export function extract_image_secret(image_bytes: Uint8Array): Uint8Array; +export function embed_image_secret(carrier: Uint8Array, secret: Uint8Array): Uint8Array; + +export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode; + +// Initializer (wasm-bindgen's default init function). +export default function init(module_or_path?: unknown): Promise; +``` + +- [ ] **Step 3: Build both** + +```bash +cargo build -p relicario-wasm --target wasm32-unknown-unknown +cd extension && bunx tsc --noEmit 2>&1 | head -30 && cd .. +``` + +Expected: both clean. If the extension has unrelated TS errors (from earlier phases), ignore β€” Plan 1C fixes them. + +- [ ] **Step 4: Commit** + +```bash +git add crates/relicario-wasm/src/lib.rs extension/src/wasm.d.ts +git commit -m "feat(wasm): configure serde_wasm_bindgen for plain-object HashMap + +Maps serialize as JS objects, not Maps β€” what the extension popup +expects. Also ships hand-written TS declarations for the bridge +(consumed by Plan 1C)." +``` + +--- + +## Task 22: CLI integration test harness β€” basic flows + +**Files:** +- Create: `crates/relicario-cli/tests/common/mod.rs` +- Create: `crates/relicario-cli/tests/basic_flows.rs` + +**Framework:** `assert_cmd` (spawn the compiled `relicario` binary), `predicates` (assert stdout/stderr patterns), `tempfile::TempDir` (each test gets its own vault dir), `serde_json` to verify written state. + +- [ ] **Step 1: Common test harness** + +Create `crates/relicario-cli/tests/common/mod.rs`: + +```rust +//! Shared helpers for CLI integration tests. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use assert_cmd::cargo::CommandCargoExt; +use tempfile::TempDir; + +pub struct TestVault { + pub dir: TempDir, + pub reference_image: PathBuf, + pub passphrase: String, +} + +impl TestVault { + /// Create a vault in a fresh tempdir and initialize it. Uses a strong + /// enough passphrase to pass the zxcvbn gate. + pub fn init() -> Self { + let dir = TempDir::new().expect("tempdir"); + // Synthesize a 400x300 JPEG carrier (small, passes imgsecret minimums). + let carrier = make_test_jpeg(400, 300); + let carrier_path = dir.path().join("carrier.jpg"); + std::fs::write(&carrier_path, &carrier).unwrap(); + + let passphrase = "correct horse battery staple 2026".to_string(); + let ref_path = dir.path().join("reference.jpg"); + + let mut cmd = Command::cargo_bin("relicario").unwrap(); + cmd.current_dir(dir.path()) + .args(["init", "--image", carrier_path.to_str().unwrap(), + "--output", ref_path.to_str().unwrap()]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = cmd.spawn().unwrap(); + // init asks for passphrase twice. + let stdin = child.stdin.as_mut().unwrap(); + writeln!(stdin, "{passphrase}").unwrap(); + writeln!(stdin, "{passphrase}").unwrap(); + let out = child.wait_with_output().unwrap(); + assert!(out.status.success(), "init failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr)); + + Self { dir, reference_image: ref_path, passphrase } + } + + pub fn path(&self) -> &Path { self.dir.path() } + + /// Run a relicario subcommand against this vault, piping the passphrase + /// into stdin for unlock. + pub fn run(&self, args: &[&str]) -> std::process::Output { + let mut cmd = Command::cargo_bin("relicario").unwrap(); + cmd.current_dir(self.dir.path()) + .env("RELICARIO_IMAGE", &self.reference_image) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = cmd.spawn().unwrap(); + writeln!(child.stdin.as_mut().unwrap(), "{}", self.passphrase).unwrap(); + child.wait_with_output().unwrap() + } + + /// Same as `run` but also pipes additional stdin lines after the passphrase. + pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output { + let mut cmd = Command::cargo_bin("relicario").unwrap(); + cmd.current_dir(self.dir.path()) + .env("RELICARIO_IMAGE", &self.reference_image) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = cmd.spawn().unwrap(); + { + let stdin = child.stdin.as_mut().unwrap(); + writeln!(stdin, "{}", self.passphrase).unwrap(); + for line in extra { + writeln!(stdin, "{line}").unwrap(); + } + } + child.wait_with_output().unwrap() + } +} + +/// Synthesize a JPEG whose dimensions + Q92 encoding pass the imgsecret +/// minimum-size check. Copy of the make_test_jpeg helper from core tests. +pub fn make_test_jpeg(w: u32, h: u32) -> Vec { + use image::codecs::jpeg::JpegEncoder; + use image::{ExtendedColorType, ImageBuffer, ImageEncoder, Rgb}; + + let img = ImageBuffer::from_fn(w, h, |x, y| { + Rgb([ + ((x * 7 + y * 13) % 256) as u8, + ((x * 11 + y * 3) % 256) as u8, + ((x * 5 + y * 17) % 256) as u8, + ]) + }); + let mut out = Vec::new(); + JpegEncoder::new_with_quality(&mut out, 92) + .write_image(img.as_raw(), w, h, ExtendedColorType::Rgb8) + .unwrap(); + out +} +``` + +Also add `image = { version = "0.25", default-features = false, features = ["jpeg"] }` to `relicario-cli/Cargo.toml`'s `[dev-dependencies]`. + +- [ ] **Step 2: Write basic-flow tests** + +Create `crates/relicario-cli/tests/basic_flows.rs`: + +```rust +mod common; + +use common::TestVault; + +#[test] +fn init_creates_expected_layout() { + let v = TestVault::init(); + assert!(v.path().join(".relicario/salt").exists()); + assert!(v.path().join(".relicario/params.json").exists()); + assert!(v.path().join(".relicario/devices.json").exists()); + assert!(v.path().join("manifest.enc").exists()); + assert!(v.path().join("settings.enc").exists()); + assert!(v.path().join("reference.jpg").exists()); + assert!(v.path().join(".gitignore").exists()); + assert!(v.path().join(".git").is_dir()); +} + +#[test] +fn init_params_json_is_format_v2() { + let v = TestVault::init(); + let s = std::fs::read_to_string(v.path().join(".relicario/params.json")).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&s).unwrap(); + assert_eq!(parsed["format_version"], 2); + assert_eq!(parsed["kdf"]["algorithm"], "argon2id-v0x13"); + assert_eq!(parsed["aead"], "xchacha20poly1305"); +} + +#[test] +fn add_login_then_list_shows_it() { + let v = TestVault::init(); + let out = v.run(&[ + "add", "login", "--title", "GitHub", + "--username", "alice", "--url", "https://github.com", + "--password", "hunter2", + ]); + assert!(out.status.success(), "add failed: {:?}", out); + let out = v.run(&["list"]); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!(stdout.contains("GitHub"), "list missing GitHub: {stdout}"); +} + +#[test] +fn get_masks_by_default_shows_with_flag() { + let v = TestVault::init(); + v.run(&["add", "login", "--title", "gmail", + "--username", "u", "--password", "super-secret"]); + + let masked = v.run(&["get", "gmail"]); + let stdout = String::from_utf8(masked.stdout).unwrap(); + assert!(stdout.contains("********"), "expected masked: {stdout}"); + assert!(!stdout.contains("super-secret"), "leaked plaintext: {stdout}"); + + let shown = v.run(&["get", "gmail", "--show"]); + let stdout = String::from_utf8(shown.stdout).unwrap(); + assert!(stdout.contains("super-secret"), "expected plaintext: {stdout}"); +} + +#[test] +fn rm_restore_purge_cycle() { + let v = TestVault::init(); + v.run(&["add", "login", "--title", "target", + "--username", "u", "--password", "p"]); + + let rm = v.run(&["rm", "target"]); + assert!(rm.status.success()); + + // List default excludes trashed + let out = v.run(&["list"]); + assert!(!String::from_utf8(out.stdout).unwrap().contains("target")); + + // --trashed shows it + let out = v.run(&["list", "--trashed"]); + assert!(String::from_utf8(out.stdout).unwrap().contains("target")); + + let restore = v.run(&["restore", "target"]); + assert!(restore.status.success()); + let out = v.run(&["list"]); + assert!(String::from_utf8(out.stdout).unwrap().contains("target")); + + let purge = v.run(&["purge", "target"]); + assert!(purge.status.success()); + let out = v.run(&["list"]); + assert!(!String::from_utf8(out.stdout).unwrap().contains("target")); +} + +#[test] +fn generate_random_and_bip39() { + // generate does not require unlock. + let dir = tempfile::TempDir::new().unwrap(); + let mut cmd = std::process::Command::cargo_bin("relicario").unwrap(); + use assert_cmd::cargo::CommandCargoExt as _; + let out = cmd.current_dir(dir.path()) + .args(["generate", "--length", "32"]).output().unwrap(); + assert!(out.status.success()); + assert_eq!(String::from_utf8(out.stdout).unwrap().trim().len(), 32); + + let mut cmd = std::process::Command::cargo_bin("relicario").unwrap(); + let out = cmd.current_dir(dir.path()) + .args(["generate", "--bip39", "--words", "5"]).output().unwrap(); + assert!(out.status.success()); + let phrase = String::from_utf8(out.stdout).unwrap(); + assert_eq!(phrase.trim().split(' ').count(), 5); +} +``` + +- [ ] **Step 3: Run tests** + +```bash +cargo build -p relicario-cli # build so assert_cmd finds the binary +cargo test -p relicario-cli --test basic_flows +``` + +Expected: all passing. Slow β€” Argon2id runs per-test β€” so expect 10–30s total. + +- [ ] **Step 4: Commit** + +```bash +git add crates/relicario-cli/Cargo.toml crates/relicario-cli/tests/ +git commit -m "test(cli): integration harness + basic flow tests + +Uses assert_cmd + tempfile to spin up a fresh vault per test. +Covers init layout, add/list/get mask semantics, rm/restore/purge cycle, +and generate smoke." +``` + +--- + +## Task 23: CLI integration tests β€” edit + history, attachments, settings + +**Files:** +- Create: `crates/relicario-cli/tests/edit_and_history.rs` +- Create: `crates/relicario-cli/tests/attachments.rs` +- Create: `crates/relicario-cli/tests/settings.rs` + +- [ ] **Step 1: Edit + history test** + +Create `crates/relicario-cli/tests/edit_and_history.rs`: + +```rust +mod common; + +use common::TestVault; + +#[test] +fn edit_password_captures_history() { + let v = TestVault::init(); + v.run(&["add", "login", "--title", "bank", + "--username", "u", "--password", "first-pw"]); + + // edit: accept defaults on title/group/tags/username/url, then change pw. + let out = v.run_with_input( + &["edit", "bank"], + &["", "", "", "", "", "y", "second-pw"], + ); + assert!(out.status.success(), "edit failed: {:?}", out); + + // Round-trip: read the item file directly and confirm field_history has + // one entry on the synthetic core:login_password key. + let items_dir = v.path().join("items"); + let entries: Vec<_> = std::fs::read_dir(&items_dir).unwrap() + .map(|e| e.unwrap().path()).collect(); + assert_eq!(entries.len(), 1); + // We can't decrypt without the key in this test, so instead observe that: + // - git log has an "edit: bank" commit. + let log = std::process::Command::new("git") + .current_dir(v.path()).args(["log", "--oneline"]) + .output().unwrap(); + let log = String::from_utf8(log.stdout).unwrap(); + assert!(log.contains("edit: bank"), "missing edit commit: {log}"); +} +``` + +- [ ] **Step 2: Attachments test** + +Create `crates/relicario-cli/tests/attachments.rs`: + +```rust +mod common; + +use common::TestVault; + +#[test] +fn attach_list_extract_round_trip() { + let v = TestVault::init(); + v.run(&["add", "login", "--title", "thing", + "--username", "u", "--password", "p"]); + + let payload_path = v.path().join("payload.txt"); + std::fs::write(&payload_path, b"attached-bytes").unwrap(); + + let attach = v.run(&["attach", "thing", payload_path.to_str().unwrap()]); + assert!(attach.status.success(), "attach failed: {:?}", attach); + + let list = v.run(&["attachments", "thing"]); + let stdout = String::from_utf8(list.stdout).unwrap(); + assert!(stdout.contains("payload.txt"), "missing payload: {stdout}"); + + // Parse aid from stdout β€” first token on the data row after the header. + let aid = stdout.lines() + .find(|l| l.contains("payload.txt")) + .and_then(|l| l.split_whitespace().next()) + .expect("aid token"); + + let out_path = v.path().join("extracted.txt"); + let ex = v.run(&["extract", "thing", aid, "--out", out_path.to_str().unwrap()]); + assert!(ex.status.success(), "extract failed: {:?}", ex); + assert_eq!(std::fs::read(out_path).unwrap(), b"attached-bytes"); +} + +#[test] +fn attach_rejects_over_cap() { + let v = TestVault::init(); + v.run(&["add", "login", "--title", "thing", + "--username", "u", "--password", "p"]); + + // Drop the per-attachment cap to 10 bytes, then try to attach 100. + v.run(&["settings", "attachment-cap", "--per-attachment-max-bytes", "10"]); + + let big = v.path().join("big.bin"); + std::fs::write(&big, vec![0u8; 100]).unwrap(); + let out = v.run(&["attach", "thing", big.to_str().unwrap()]); + assert!(!out.status.success(), "expected failure; got {:?}", out); + assert!(String::from_utf8(out.stderr).unwrap().to_lowercase().contains("attachment")); +} +``` + +- [ ] **Step 3: Settings test** + +Create `crates/relicario-cli/tests/settings.rs`: + +```rust +mod common; + +use common::TestVault; + +#[test] +fn settings_roundtrip_trash_retention() { + let v = TestVault::init(); + let out = v.run(&["settings", "show"]); + assert!(String::from_utf8(out.stdout).unwrap().contains("trash_retention")); + + let out = v.run(&["settings", "trash-retention", "--days", "60"]); + assert!(out.status.success(), "set failed: {:?}", out); + let out = v.run(&["settings", "show"]); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!(stdout.contains("\"value\": 60"), "expected 60: {stdout}"); +} + +#[test] +fn settings_rejects_conflicting_retention_flags() { + let v = TestVault::init(); + let out = v.run(&["settings", "trash-retention", "--days", "30", "--forever"]); + assert!(!out.status.success()); +} +``` + +- [ ] **Step 4: Run tests** + +```bash +cargo test -p relicario-cli --test edit_and_history +cargo test -p relicario-cli --test attachments +cargo test -p relicario-cli --test settings +``` + +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-cli/tests/ +git commit -m "test(cli): integration tests for edit/history, attachments, settings" +``` + +--- + +## Task 24: CLI integration tests β€” vault_dir detection + v1 vault rejection + +**Files:** +- Create: `crates/relicario-cli/tests/vault_detection.rs` + +Covers audit L8 (refuse outside vault) and confirms the v1 format is rejected as documented. + +- [ ] **Step 1: Test refuses to run outside a vault** + +Create `crates/relicario-cli/tests/vault_detection.rs`: + +```rust +mod common; + +use assert_cmd::cargo::CommandCargoExt; +use std::process::Command; +use tempfile::TempDir; + +#[test] +fn list_refuses_without_vault_marker() { + let dir = TempDir::new().unwrap(); + // No .relicario/ in dir β€” list should bail with a friendly error. + let mut cmd = Command::cargo_bin("relicario").unwrap(); + let out = cmd.current_dir(dir.path()).arg("list").output().unwrap(); + assert!(!out.status.success()); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!(stderr.contains(".relicario"), "expected marker hint: {stderr}"); +} + +#[test] +fn get_finds_vault_in_parent_dir() { + let v = common::TestVault::init(); + v.run(&["add", "login", "--title", "parent-test", + "--username", "u", "--password", "p"]); + + // Create a nested subdir and run `list` from inside it. + let nested = v.path().join("a/b/c"); + std::fs::create_dir_all(&nested).unwrap(); + + let mut cmd = Command::cargo_bin("relicario").unwrap(); + use std::io::Write; + use std::process::Stdio; + cmd.current_dir(&nested) + .env("RELICARIO_IMAGE", &v.reference_image) + .arg("list") + .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = cmd.spawn().unwrap(); + writeln!(child.stdin.as_mut().unwrap(), "{}", v.passphrase).unwrap(); + let out = child.wait_with_output().unwrap(); + assert!(out.status.success(), "list from nested dir failed: {:?}", out); + assert!(String::from_utf8(out.stdout).unwrap().contains("parent-test")); +} + +#[test] +fn v1_vault_is_rejected_with_clear_error() { + // Synthesize an on-disk v1 vault: .idfoto/ dir with old params.json. + // Since vault_dir detection uses .relicario/, the pre-rename dir name is + // naturally rejected without any compat shim. Confirm that. + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".idfoto")).unwrap(); + + let mut cmd = Command::cargo_bin("relicario").unwrap(); + let out = cmd.current_dir(dir.path()).arg("list").output().unwrap(); + assert!(!out.status.success()); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!(stderr.contains(".relicario"), "expected relicario marker demand: {stderr}"); +} +``` + +- [ ] **Step 2: Run** + +```bash +cargo test -p relicario-cli --test vault_detection +``` + +Expected: all pass. + +- [ ] **Step 3: Commit** + +```bash +git add crates/relicario-cli/tests/vault_detection.rs +git commit -m "test(cli): vault_dir detection (L8) + v1 vault rejection" +``` + +--- + +## Task 25: Update CLAUDE.md + final verification + +**Files:** +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Rewrite the "Project structure" diagram** + +Replace the existing diagram in `CLAUDE.md` with a reflection of the typed-item module layout: + +````markdown +## Project structure + +``` +crates/ +β”œβ”€β”€ relicario-core/ # Platform-agnostic library (no filesystem, no git, no network) +β”‚ β”œβ”€β”€ src/ +β”‚ β”‚ β”œβ”€β”€ lib.rs # Re-exports public API +β”‚ β”‚ β”œβ”€β”€ error.rs # RelicarioError enum (thiserror) +β”‚ β”‚ β”œβ”€β”€ crypto.rs # Argon2id KDF (length-prefixed, Zeroizing) + XChaCha20-Poly1305 +β”‚ β”‚ β”œβ”€β”€ ids.rs # ItemId, FieldId, content-addressed AttachmentId +β”‚ β”‚ β”œβ”€β”€ time.rs # now_unix, MonthYear +β”‚ β”‚ β”œβ”€β”€ item_types/ # per-type cores + ItemType/ItemCore enums +β”‚ β”‚ β”œβ”€β”€ item.rs # Item envelope, Field, FieldKind, FieldValue, Section +β”‚ β”‚ β”œβ”€β”€ attachment.rs # AttachmentRef, EncryptedAttachment, encrypt/decrypt helpers +β”‚ β”‚ β”œβ”€β”€ manifest.rs # Browse-without-decrypt index (schema_version 2) +β”‚ β”‚ β”œβ”€β”€ settings.rs # VaultSettings: retention, generator defaults, caps +β”‚ β”‚ β”œβ”€β”€ generators.rs # CSPRNG password + BIP39 + zxcvbn gate +β”‚ β”‚ β”œβ”€β”€ vault.rs # JSON ↔ AEAD wrappers for Item/Manifest/VaultSettings +β”‚ β”‚ └── imgsecret.rs # DCT steganography (MAX_DIMENSION cap) +β”‚ └── tests/ # integration.rs, attachments.rs, generators.rs, format_v2.rs, field_history.rs +β”œβ”€β”€ relicario-cli/ # `relicario` binary +β”‚ β”œβ”€β”€ src/main.rs # clap surface + command handlers +β”‚ β”œβ”€β”€ src/helpers.rs # vault_dir, git_command, iso8601 +β”‚ β”œβ”€β”€ src/session.rs # UnlockedVault (master key in Zeroizing) +β”‚ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection +└── relicario-wasm/ # WASM bindings for the extension + β”œβ”€β”€ src/lib.rs # #[wasm_bindgen] surface + └── src/session.rs # opaque SessionHandle β†’ Zeroizing<[u8;32]> +``` +```` + +Also update the "Build and test" section example commands to use `relicario-cli` crate targets, and add a section summarizing the Plan 1B audit fixes. + +- [ ] **Step 2: Run the full test suite end-to-end** + +```bash +cargo test +cargo build -p relicario-wasm --target wasm32-unknown-unknown +``` + +Expected: all core tests, all WASM native tests, all CLI integration tests pass. WASM target builds clean. + +- [ ] **Step 3: Run `cargo clippy`** + +```bash +cargo clippy --workspace --all-targets -- -D warnings +``` + +Expected: no warnings. Fix anything that surfaces; rebuild. + +- [ ] **Step 4: Commit docs + tag** + +```bash +git add CLAUDE.md +git commit -m "docs: update CLAUDE.md for the typed-item module layout" +git tag plan-1b-cli-wasm-complete +``` + +- [ ] **Step 5: Report completion** + +Print the commit + tag summary so the next session knows where to pick up: + +```bash +git log --oneline plan-1a-rust-core-complete..plan-1b-cli-wasm-complete +``` + +Expected: ~25 commits covering this plan. Confirm tag exists: `git tag | grep 1b`. + +--- + +## Hand-off to Plan 1C + +Plan 1C (extension rewrite) starts from `plan-1b-cli-wasm-complete` and: + +- Rewrites `extension/src/service-worker/` around the split message router (popup-only vs content-callable), sender checks, origin-bound autofill. +- Replaces all page-DOM capture/icon rendering with closed Shadow DOM + textContent. +- Updates popup UI for the 7 typed-item forms. +- Removes `setup.html` + WASM artifacts from `web_accessible_resources`. +- Consumes the `extension/src/wasm.d.ts` declarations shipped in Task 21. +- Lands audit items C1–C4, H8, L11 that depend on the extension surface. + +--- + +## Self-Review Checklist + +Before handing this plan off for execution: + +### Spec coverage + +- [x] `relicario init` β€” Task 6 +- [x] `relicario add` (all 7 types) β€” Tasks 7–8 +- [x] `relicario get` with mask + --show + clipboard β€” Task 9 +- [x] `relicario list` with filters β€” Task 10 +- [x] `relicario edit` with history β€” Task 11 +- [x] `relicario rm/restore/purge/trash empty` β€” Task 12 +- [x] `relicario attach/attachments/extract` β€” Task 13 +- [x] `relicario generate` delegating to core β€” Task 14 +- [x] `relicario settings show/trash-retention/history-retention/attachment-cap` β€” Task 15 +- [x] `relicario sync` hardened β€” Task 16 +- [x] `relicario device add/list/revoke` β€” Task 17 +- [x] WASM `unlock/lock` (SessionHandle) β€” Task 18 +- [x] WASM manifest/item/settings β€” Task 19 +- [x] WASM attachment/generator/totp/imgsecret/ID β€” Task 20 +- [x] WASM TS declarations β€” Task 21 + +### Audit fixes + +- [x] H4 (hardened git shell-out): Task 3 helper, Tasks 6, 7, 12, 15, 16, 17 +- [x] H5 (CSPRNG in WASM): Task 18 uses `getrandom` + core's Uniform generator via Task 20's generator bridge +- [x] H6 (CLI Uniform sampling): Task 14 delegates to core, eliminating CLI modulo bias +- [x] H7 (rpassword 7.x): Task 3 Cargo.toml bump, used throughout +- [x] M3 (imgsecret MAX_DIMENSION): Task 2 +- [x] M6 (clipboard Zeroize + always-clear): Task 9 +- [x] M7 (mask by default, --show to reveal): Task 9 +- [x] M11 (ISO-8601): Task 3 helper, used in Task 9 `get` output +- [x] L8 (vault dir detection): Task 3 helper, used by every command; Task 24 test + +### Test coverage + +- [x] init layout + format_version 2 β€” Task 22 +- [x] add/list/get mask semantics β€” Task 22 +- [x] rm/restore/purge cycle β€” Task 22 +- [x] generate (random + BIP39) β€” Task 22 +- [x] edit history capture β€” Task 23 +- [x] attach/list/extract + cap rejection β€” Task 23 +- [x] settings retention roundtrip β€” Task 23 +- [x] vault_dir detection (L8) + v1 vault rejection β€” Task 24 + +### Placeholder scan + +- No "TBD", "implement later", or "fill in details". +- Every task either contains the full code block it produces OR defers to an earlier task where the pattern is established (e.g., Task 8 references Task 7's Login dispatcher pattern for Identity/Card/Key/etc. with fresh per-type code inline). +- Every test has its assertion block fully written. + +### Type-name consistency + +- `relicario_core::…` (crate ident, underscores) everywhere in Rust imports. +- `relicario-core` (crate name, dashes) in `Cargo.toml` dependency lines. +- `RelicarioError` for the error enum. +- `RELICARIO_IMAGE` for the env var. +- `.relicario/` for the vault metadata dir. +- `SessionHandle` in WASM; `UnlockedVault` in CLI β€” intentionally different per layer. +- `ItemId`/`FieldId`/`AttachmentId` match `relicario-core` API. diff --git a/manifest.enc b/manifest.enc deleted file mode 100644 index b75b333..0000000 Binary files a/manifest.enc and /dev/null differ diff --git a/settings.enc b/settings.enc deleted file mode 100644 index 8198360..0000000 Binary files a/settings.enc and /dev/null differ