From 76f34bfcf5cc41d5307997c1a8936046d5ae90e2 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 18:50:37 -0400 Subject: [PATCH] chore: remove stray vault files from Plan 1B + add plan doc A Task 6 implementer subagent ran `relicario init` inside the worktree root during manual testing and committed the resulting vault skeleton (.relicario/, manifest.enc, settings.enc) plus overwrote .gitignore. None of these should be in the source repo. Restores the original .gitignore (adds reference.jpg and ref.jpg to it) and checks in the Plan 1B design doc that describes the work just merged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 8 + .relicario/devices.json | 1 - .relicario/params.json | 11 - .relicario/salt | 1 - ...04-19-relicario-typed-items-1b-cli-wasm.md | 3773 +++++++++++++++++ manifest.enc | Bin 72 -> 0 bytes settings.enc | Bin 468 -> 0 bytes 7 files changed, 3781 insertions(+), 13 deletions(-) delete mode 100644 .relicario/devices.json delete mode 100644 .relicario/params.json delete mode 100644 .relicario/salt create mode 100644 docs/superpowers/plans/2026-04-19-relicario-typed-items-1b-cli-wasm.md delete mode 100644 manifest.enc delete mode 100644 settings.enc 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 b75b3330d6cbc0450c1ebf4dbcf80c392bb0ada5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 72 zcmV-O0Jr}FgfaGH2s8c2dA)Db-8JDZNg}7xuGIM@YOZQSv@@+92mobX4?IkuT+|CHwG$COC diff --git a/settings.enc b/settings.enc deleted file mode 100644 index 81983601a2d6ca4c7c44574972ee82174768e213..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 468 zcmV;_0W1Cj4`%p;j)44zmYq0tF3PyOb3#6ZNhxd!eq-gc)&Df5|FFclJ{F#C|L+ydxN*0!?oJ`0SpBj{xmhEVj-Cq0uWC8dP=#}8;exB zAB0XBiP8!era>Uwdr_t9|m#c$L!qlSWvy zps~H2EMY+suX#H9liyWM!`cAE8&EREpubg`JLa)$dRGa(UhypQzc?qA_&hD2GXN~r zeH?_KF|LNY8GT0fKYKMZFFX>8^Z{LSBPHJ^TNql4vaAo<1^7p05ML>^lMa)GqN&dUAwBs9QYI!LbfSsf8S}#WibzGYj z5Ng@7+9D-AaWf8G{))sPpCrp+*nZI7T@^+@sT8_YqDNz6~GpL zXN`?reG)-~OLwDI|D6frB<@=<#GtGOxjRcypkS?