diff --git a/crates/relicario-cli/ARCHITECTURE.md b/crates/relicario-cli/ARCHITECTURE.md new file mode 100644 index 0000000..4d4ac29 --- /dev/null +++ b/crates/relicario-cli/ARCHITECTURE.md @@ -0,0 +1,539 @@ +# Architecture: relicario-cli + +## What this crate is for + +The `relicario` binary is the platform layer for `relicario-core`: it adds +filesystem layout, a hardened `git` shell-out, interactive `rpassword` prompts, +clipboard handoff, and a clap-based command surface. The crate has two design +roles. First, it is the developer / power-user surface that exposes everything +the core can do (every `ItemCore` variant, every `VaultSettings` knob, history +inspection, device key management). Second, it is the only working interface +during disaster recovery — the extension may be uninstalled, the device may be +new — so it intentionally maintains feature parity with the extension's vault +tab. It deliberately shells out to `git` rather than depending on libgit2 / +gitoxide; this keeps the dep tree slim, lets the user override `git` config +locally, and lets recovery debugging happen with familiar tooling. + +## Module map + +The crate is three files of source and a `tests/` directory. Each source file +has one job. + +- **`src/main.rs`** (`main.rs:1-1719`) — clap surface plus every command + handler. Internal structure: a top-level `Cli` / `Commands` enum + (`main.rs:13-275`), a flat dispatcher `match` in `main()` + (`main.rs:277-303`), per-command handlers named `cmd_`, and a layer of + per-type item helpers (`build__item` for `cmd_add`, `edit_` for + `cmd_edit`). The per-type split is recent: commit `3f0f5b1` extracted + ~217-line `match` arms in `cmd_add` and `cmd_edit` into focused functions, + one per `ItemCore` variant, so each builder/editor reads top-to-bottom and + can be tested through the same integration paths. Owns all clap argument + parsing, all interactive prompts (`prompt`, `prompt_optional`, `prompt_keep`, + `prompt_keep_opt`, `prompt_yesno`, `prompt_secret`), and the shared + `commit_paths` helper that is the single chokepoint for git commits during + vault mutations. + +- **`src/session.rs`** (`session.rs:1-152`) — `UnlockedVault` lifecycle. Holds + the derived master key in `Zeroizing<[u8; 32]>` for one CLI invocation; the + key wipes via `Zeroize` on scope exit (`session.rs:22-25`). Owns the + `unlock_interactive` flow (vault root walk → salt read → params read → + reference image extract → passphrase prompt → KDF) at `session.rs:33-59`, + the typed `load_*` / `save_*` accessors for `Item` / `Manifest` / + `VaultSettings`, the `read_salt` / `read_params` helpers, the + `RELICARIO_IMAGE` lookup, and `atomic_write` (`session.rs:144-151`) which + every disk write to a vault file goes through. Owns the env-var escape + hatches `RELICARIO_TEST_PASSPHRASE` (`session.rs:42`) and `RELICARIO_IMAGE` + (`session.rs:125`) that integration tests use to bypass the TTY. + +- **`src/helpers.rs`** (`helpers.rs:1-101`) — pure, no-state plumbing: + `find_vault_dir_from` (`helpers.rs:14-28`) walks up parent directories + looking for a `.relicario/` marker; `vault_dir` and `relicario_dir` wrap it + for `cwd`-rooted callers; `git_command` (`helpers.rs:45-55`) is the + hardened-`git` factory that every git invocation in the crate (production + code, not tests) goes through; `iso8601` (`helpers.rs:60-64`) formats Unix + seconds for human-readable output (audit M11). The hardening is + load-bearing — see Invariants & Gotchas below. + +## Invariants & contracts + +These are the load-bearing rules the crate relies on. Each has been verified +in code; cite the line if you change it. + +- **Every vault-mutating command unlocks via `UnlockedVault`.** The struct + holds the master key in `Zeroizing<[u8; 32]>` and drops via `Zeroize` on + scope exit (`session.rs:22-25`). No command bypasses this except + `cmd_generate` outside a vault dir and `cmd_init` (which derives the key + inline before there is a vault to unlock). + +- **Every `git` invocation in production code goes through + `helpers::git_command`.** A grep for `Command::new("git")` outside + `helpers.rs` finds zero hits in `src/`; the only other match is in + `tests/edit_and_history.rs:18`, which is test-side verification of the git + log and is exempt by design. `git_command` injects + `core.hooksPath=/dev/null`, `commit.gpgsign=false`, and `core.editor=true` + via `-c` flags (`helpers.rs:48-52`). Direct `Command::new("git")` would + bypass the hardening — don't. + +- **Every file write to a vault file uses `atomic_write`.** `atomic_write` + (`session.rs:144-151`) writes `.tmp` then renames over ``; a + partial write never appears as the live file. All `UnlockedVault::save_*` + helpers route through it. (`cmd_init` writes pre-creation files via + `fs::write` at `main.rs:373-393`; that path doesn't need atomicity because + the vault doesn't exist yet — failure leaves a half-built vault that the + next run rejects via `relicario_dir.exists()` at `main.rs:326`.) + +- **Every commit during a mutating command uses `commit_paths`.** + `commit_paths` (`main.rs:767-775`) does `git add && git commit -m + ` through the hardened wrapper. Commit message convention is + `: (<id>)` — `add:`, `edit:`, `trash:`, `restore:`, `purge:`, + `attach:`, `detach:`, `settings: update`, `device: add <name>`, `device: + revoke <name>`, `init: new relicario vault (format v2)`, `trash empty: + purged N item(s)`. `cmd_purge` and `cmd_trash_empty` and `cmd_device` use + `git_command` directly (not `commit_paths`) because they need a slightly + different add/commit pattern; they still go through the hardened wrapper. + +- **`cmd_generate` is the only command that runs without unlock — and only + when invoked outside a vault directory.** Inside a vault, + `cmd_generate` unlocks to read `settings.generator_defaults` + (`main.rs:1440-1445`); explicit flags override the stored defaults. This is + why the smoke-test `cargo run -p relicario-cli -- generate --length 32` + works without any setup. + +- **Item IDs are minted by core.** The CLI never constructs an `ItemId` + directly; `Item::new` (called inside every `build_*_item`) does it via + `relicario-core::ids::new_item_id`. `ItemId`s are 8-char hex. + +- **Manifest is always saved last.** Within a single command, the order is: + write item file → mutate manifest → save manifest → commit. If the process + dies between step 1 and step 3, the next run sees an item file with no + manifest entry; `cmd_status` / `cmd_list` ignore it because they read the + manifest, not the directory. (Recovery would manually re-`add` to surface + it.) + +- **Vault root is always discovered, never assumed to be `cwd`.** + `helpers::vault_dir` walks up from `cwd` looking for `.relicario/`, so any + command run from a subdirectory of the vault works (verified by + `vault_detection.rs:23-40`). v1 vaults using `.idfoto/` are naturally + rejected because they don't contain `.relicario/` — no compat shim needed + (`vault_detection.rs:42-59`). + +- **`prompt_secret` reads `RELICARIO_TEST_ITEM_SECRET` before falling back to + `rpassword`.** This is the only way integration tests can drive the + per-item secret prompts (Login password, Card number, TOTP secret rotation, + Key material) without a real TTY. The check is at `main.rs:308-313`. + +## Key flows + +### Vault init (`cmd_init`, `main.rs:315-418`) + +1. Refuse if `.relicario/` already exists (`main.rs:326-328`). +2. Read passphrase twice (or once via `RELICARIO_TEST_PASSPHRASE`); confirm + they match; run `validate_passphrase_strength` (zxcvbn-backed) and bail + with audit-H3 message on weak input (`main.rs:331-348`). +3. Generate a 32-byte random `image_secret` via `OsRng`, embed it into the + carrier JPEG via `imgsecret::embed`, write the stego output to `--output` + (`main.rs:351-360`). +4. Generate a 32-byte salt and pin `KdfParams { argon2_m: 65536, argon2_t: 3, + argon2_p: 4 }` (production-grade) at `main.rs:363-365`. +5. `derive_master_key(passphrase, image_secret, salt, params)` → + `Zeroizing<[u8;32]>` (`main.rs:368`). +6. Create `.relicario/`, `items/`, `attachments/` dirs; write + `.relicario/{salt, params.json, devices.json}`; encrypt and write + `manifest.enc` (empty `Manifest::new()`) and `settings.enc` + (`VaultSettings::default()`) (`main.rs:370-393`). +7. Write `.gitignore` listing the reference image filename (so the second + factor never accidentally ends up in git) (`main.rs:396-400`). +8. `git init` then initial commit `init: new relicario vault (format v2)` + via `git_command` (`main.rs:403-412`). Note the initial commit does NOT + go through `commit_paths` — it precedes the existence of an + `UnlockedVault`, so the path list is hand-spelled. + +### Vault unlock (`UnlockedVault::unlock_interactive`, `session.rs:33-59`) + +1. `vault_dir()` walks up from cwd to find `.relicario/`; bails with the + "run `relicario init` first" message on miss (`helpers.rs:21-26`). +2. `read_salt` reads `.relicario/salt` (32 bytes; rejects any other length). +3. `read_params` deserializes `.relicario/params.json` and extracts the + nested `kdf` sub-object as `KdfParams` (`session.rs:110-121`). The nested + shape exists because `params.json` also stores `format_version`, `aead`, + and `salt_path` for forward-compat probing. +4. `get_image_path` honours `RELICARIO_IMAGE`, then a `<vault>/reference.jpg` + convention, then prompts (`session.rs:124-140`). +5. Read the reference image bytes; `imgsecret::extract` runs the DCT + majority-vote decode to recover the 32-byte image secret + (`session.rs:38-40`). +6. Read the passphrase via `RELICARIO_TEST_PASSPHRASE` or `rpassword` + (`session.rs:42-49`). +7. `derive_master_key` produces the master key; `UnlockedVault { root, + master_key }` is returned and lives until the command function returns. + +### Item add (`cmd_add`, `main.rs:419-456`) + +1. Unlock the vault and load the manifest. +2. Match on the `AddKind` variant and dispatch to the matching + `build_<type>_item` helper (`main.rs:423-438`). Seven variants → seven + builders; only `build_document_item` takes `&UnlockedVault` because it + needs `attachment_caps` and writes the encrypted blob alongside the item. +3. The builder returns a fully-populated `Item` (with title, group, tags, + favorite-flag, primary attachment if any). +4. Common wrap-up: `vault.save_item(&item)`, `manifest.upsert(&item)`, + `vault.save_manifest(&manifest)`. +5. Build the path list — `items/<id>.enc`, `manifest.enc`, plus one + `attachments/<id>/<aid>.enc` per attachment — and call `commit_paths` + with message `add: <title> (<id>)` (`main.rs:444-452`). + +### Item edit (`cmd_edit`, `main.rs:938-977`) + +1. Unlock, load manifest, resolve query → item id, load the item. +2. Universally-editable fields (title, group, tags) are prompted via + `prompt_keep` / `prompt_keep_opt` first; blank input keeps the current + value (`main.rs:952-956`). +3. Borrow `&mut item.field_history` once into a local `history` binding + (`main.rs:958`), then `match` on `&mut item.core` and dispatch to the + per-type `edit_<type>` helper (`main.rs:959-967`). The history-tracking + editors (`edit_login`, `edit_secure_note`, `edit_card`, `edit_key`, + `edit_totp`) take `&mut FieldHistory`; the others (`edit_identity`, + `edit_document_message`) don't. +4. Each editor that mutates a tracked secret calls `push_history(history, + "<key>", old_value)` (`main.rs:1095-1109`) — see the History flow below + for the synthetic-key convention. +5. `item.modified = now_unix()`, save, upsert manifest, commit + `edit: <title> (<id>)`. + +`edit_document_message` (`main.rs:1050-1052`) just prints "use `attach` / +`extract` instead" — Document items can't be field-edited; they're +attachment-shaped. + +The `FieldHistory` type alias (`main.rs:983-986`) is purely cosmetic; it +exists so the editor signatures don't have to spell out the full +`HashMap<FieldId, Vec<FieldHistoryEntry>>`. + +### History capture and view (`push_history` + `cmd_history`) + +`push_history` (`main.rs:1095-1109`) records an old value under a synthetic +`FieldId(format!("core:{key}"))`. The `core:` prefix namespaces these keys so +they can never collide with real custom-field UUIDs from the typed-item +custom-fields work. The keys used in the codebase are: + +- `core:login_password` (`main.rs:998`) +- `core:secure_note_body` (`main.rs:1012`) +- `core:card_number` (`main.rs:1031`) +- `core:key_material` (`main.rs:1045`) +- `core:totp_secret` (`main.rs:1063`) + +`cmd_history` (`main.rs:1111-1159`) reads `item.field_history`, sorts the +keys, strips the `core:` prefix for display, and prints each entry list +masked or revealed depending on `--show`. The `--field <name>` filter +matches against either the stripped name (`login_password`) or the raw key +(`core:login_password`) so both forms work (`main.rs:1126-1129`). The +`relicario history bank --field totp_secret` form is what +`edit_and_history.rs` exercises. + +### Trash & purge (`cmd_rm` / `cmd_restore` / `cmd_purge` / `cmd_trash_empty`) + +- `cmd_rm` (`main.rs:1161-1176`) calls `Item::soft_delete()` (sets + `trashed_at`), saves, upserts manifest, commits `trash:`. +- `cmd_restore` (`main.rs:1178-1193`) is the inverse: `Item::restore()`, + same wrap-up, commit `restore:`. +- `cmd_purge` (`main.rs:1220-1237`) calls `purge_item` (`main.rs:1197-1218`) + which removes the item file, the attachment dir, the manifest entry, and + `git rm -rf --ignore-unmatch`s the paths. Then a single `git add + manifest.enc` + commit `purge: <title> (<id>)`. +- `cmd_trash_empty` (`main.rs:1246-1282`) is the only multi-item mutating + command. It loads settings once, iterates all items past their + `trash_retention` window, calls `purge_item` for each, then does a single + `git add manifest.enc` + commit `trash empty: purged N item(s)`. The + single-unlock-per-batch shape was the fix in commit `b5015b3` — the + earlier version re-prompted for the passphrase per item. + +### Attach / detach / extract + +- `cmd_attach` (`main.rs:1283-1339`) loads `attachment_caps` from settings + and rejects if the item has hit `per_item_max_count`. `encrypt_attachment` + enforces `per_attachment_max_bytes`. The encrypted blob lands at + `attachments/<item_id>/<aid>.enc`; the `aid` is content-addressed by core. + Commit message: `attach: <file> → <title> (<id>)`. +- `cmd_detach` (`main.rs:1376-1424`, added in `3f0f5b1`) removes one + attachment from the item, deletes the encrypted blob, rewrites the item. + Refuses if the target `aid` is a `Document` item's `primary_attachment` + (`main.rs:1392-1400`) — that would orphan the item; use `purge` instead. + Commit message: `detach: <filename> from <title> (<id>)`. +- `cmd_extract` (`main.rs:1354-1375`) decrypts the blob and writes the + plaintext to `--out` or to `<filename>` in cwd. Read-only: no commit, no + state mutation. +- `cmd_attachments` (`main.rs:1341-1352`) lists `aid`, size, mime, filename + — read-only. + +### Generate (`cmd_generate`, `main.rs:1426-1489`) + +Has two distinct modes: + +- **Outside a vault** — `vault_dir()` returns `Err`; `vault_defaults` stays + `None`; defaults are hard-coded (`length: 20`, `symbols: SafeOnly`, + `words: 5`, `separator: " "`, `Capitalization::Lower`). No unlock prompt. +- **Inside a vault** — `vault_dir()` succeeds; full unlock; load + `settings.generator_defaults`. Explicit flags override the stored defaults + field-by-field. `--bip39` flips mode; absent that flag, the mode is + whatever the stored default is. Tests: + `settings.rs::generate_uses_vault_default_length` (length-tracking) and + `basic_flows.rs::generate_random_and_bip39` (no-vault smoke). + +The two-mode shape is deliberate (see Gotchas) and is why `cmd_generate` is +the only command outside `cmd_init` that touches `helpers::vault_dir()` +directly instead of going through `UnlockedVault::unlock_interactive()`. + +### Sync (`cmd_sync`, `main.rs:1582-1590`) + +`git pull --rebase` then `git push`, both via the hardened wrapper. No +unlock — sync moves opaque ciphertext, the master key is never needed. This +is the only command that fails on conflict; it doesn't try to resolve. +Resolution happens manually in the user's git tooling. + +### Status (`cmd_status`, `main.rs:1592-1631`, added in `3f0f5b1`) + +Unlocks; loads manifest; counts items (active vs trashed), attachments +(count + total bytes), devices (parsed from `devices.json`); shells out to +`git log -1 --pretty=format:%h %s` for the last-commit summary line. All +read-only — no commit, no state change. + +### Device management (`cmd_device`, `main.rs:1632-1702`) + +Add: generate ed25519 keypair via `OsRng`, append `{name, public_key}` to +`.relicario/devices.json`, write the secret signing key to +`<config_dir>/relicario/devices/<name>.key` with `0o600` on Unix, commit +`device: add <name>`. List: print `name pubkey_hex`. Revoke: filter by name, +rewrite `devices.json`, commit `device: revoke <name>`. Note that device +keys are kept entirely separate from the KDF (passphrase × image stays +unchanged across device add/revoke), as per the design spec. + +### Backup-passphrase-style commands (none yet) + +The import / export / `import-lastpass` commands described in +`docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` are +not yet implemented. When they land they'll fit in the dispatcher +(`main.rs:279-302`) alongside `Sync` and `Status`. Don't add stubs here +until that work begins. + +## Cross-cutting concerns + +- **Error model.** Every `cmd_*` returns `anyhow::Result<()>`. Core errors + bubble up through `?` from `RelicarioError`. Per-step context is added + via `with_context(|| ...)` chains, e.g. `format!("failed to read {}", + path.display())`. AEAD authentication failures intentionally surface as + the ambiguous "wrong passphrase or corrupt vault" message from core — the + CLI does not differentiate. clap argument errors are produced by clap + (e.g., `--days` and `--forever` together fail at the + `SettingsAction::TrashRetention` arm in `main.rs:1504-1510`). + +- **Atomicity.** Every disk write to a vault file goes through + `session.rs::atomic_write` (`session.rs:144-151`): write `<path>.tmp`, then + rename over `<path>`. Manifest is the single source of truth and is + always written *last* in any multi-file operation, so a process kill + between item-write and manifest-write leaves an orphan item file (which + doesn't appear in `list`/`status`) but never a manifest pointing to a + missing file. + +- **Git history as audit log.** Per-action commits, never amended, never + squashed. The verb prefix on commit subjects (`add:`, `edit:`, `trash:`, + `restore:`, `purge:`, `attach:`, `detach:`, `settings:`, `device:`, + `init:`) makes `git log --oneline` a literal audit trail. Tests verify + this by greping `git log` directly (e.g., `edit_and_history.rs:18-22`). + +- **Where secrets live.** + - Master key — `UnlockedVault.master_key: Zeroizing<[u8; 32]>` + (`session.rs:24`). Wipes on drop. + - Image secret — `Zeroizing<[u8; 32]>`, lives only inside + `unlock_interactive` until the KDF call (`session.rs:40`). + - Passphrase — `Zeroizing<String>` from `rpassword::prompt_password` or + the env var (`session.rs:42-49`, `main.rs:333-342`). + - Item secrets — `Zeroizing<String>` for `Login.password`, `Card.number`, + `Card.cvv`, `Card.pin`, `Key.key_material`, `SecureNote.body`, and + `Zeroizing<Vec<u8>>` for `TotpCore.config.secret` (decoded from + base32). All flow through core types. + - Clipboard copy — `Zeroizing<String>` cloned into the detached 30s + auto-clear thread (`main.rs:873-889`). + +- **Test escape hatches.** Three env vars exist for integration tests; all + are read at exactly one site each: + - `RELICARIO_TEST_PASSPHRASE` — `session.rs:42` (unlock) and + `main.rs:333,338` (init). + - `RELICARIO_IMAGE` — `session.rs:125` (image path resolution). + - `RELICARIO_TEST_ITEM_SECRET` — `main.rs:309` (`prompt_secret` only). + None of them have a production fall-through; absent the var, the code + always prompts. They are safe in production binaries because the user + would have to set them explicitly. + +- **Generate-without-unlock is intentional.** It is NOT an oversight. + `relicario generate --length 32` is the documented smoke test (see the + repo's CLAUDE.md) and works as a standalone CSPRNG password generator + outside any vault. Inside a vault it does require unlock — see Gotchas. + +## Test architecture + +All tests are integration tests; there are no `#[cfg(test)]` modules in +`src/main.rs` or `src/session.rs`. `helpers.rs` has four unit tests +(`helpers.rs:67-100`) that exercise vault-dir walking and `iso8601` +formatting in isolation. Everything else is `tests/`. + +- **`tests/common/mod.rs`** (`117 lines`) — the harness. `TestVault::init()` + spins up a fresh `TempDir`, generates a 400×300 JPEG via + `make_test_jpeg()` (deterministic noise; no binary fixtures), runs + `relicario init --image carrier.jpg --output reference.jpg` with + `RELICARIO_TEST_PASSPHRASE` set, and stashes the passphrase + reference + image path on the struct. `run` and `run_with_input` are the two ways to + invoke the binary against the test vault: both inherit + `RELICARIO_IMAGE` + `RELICARIO_TEST_PASSPHRASE`; the latter pipes extra + newlines into stdin (used for interactive prompts that aren't + `rpassword`-driven). The note at the top warns Task 23 implementers + about the new-item-password rpassword path; the fix landed as + `RELICARIO_TEST_ITEM_SECRET` in commit `20350d5`. + +- **`tests/basic_flows.rs`** (`136 lines`) — covers the init layout + (`.relicario/{salt,params.json,devices.json}`, `manifest.enc`, + `settings.enc`, `reference.jpg`, `.gitignore`, `.git`); the `params.json` + v2 shape; `add login` + `list`; `get` masking semantics (with and + without `--show`); the rm/restore/purge cycle including `list --trashed`; + and the two-mode `generate` smoke (random length + bip39 word count) run + outside a vault. + +- **`tests/edit_and_history.rs`** (`191 lines`) — drives `edit` end-to-end + by piping stdin lines (blank to keep, `y` to confirm) plus + `RELICARIO_TEST_ITEM_SECRET` for the rpassword leg. `edit_password_*` + verifies the item file is rewritten and the `edit: bank` commit lands. + The four `history_command_*` tests cover masked listing, `--show` + reveal, "no history captured" output, and per-field filtering. The + `edit_totp_rotates_secret_and_captures_history` test (added 2026-04-27 + in commit `3f0f5b1` — fixes a stub at the old `main.rs:925`) drives the + full TOTP edit including issuer / label / secret rotation. + +- **`tests/attachments.rs`** (`106 lines`) — `attach`/`attachments`/ + `extract` round-trip (verifies the bytes survive the encrypt-decrypt + hop); `detach` removes both the attachment ref and the encrypted blob + on disk; `detach` rejects an unknown `aid`; `attach` rejects payloads + over `per_attachment_max_bytes`. The detach test (`detach_*`) and the + cap test were added in `3f0f5b1` / `20350d5` respectively. + +- **`tests/settings.rs`** (`135 lines`) — `settings show` and + `settings trash-retention --days 60` round-trip; the conflicting-flags + rejection (`--days` + `--forever`); the + `generate_uses_vault_default_length` test that verifies (a) default + vault length is 20, (b) updating `settings generator-defaults --length + 32` changes the default, (c) explicit `--length 8` overrides the stored + default; the multi-shape `cmd_status` smoke; and the + `generate_works_outside_vault` test that verifies the no-unlock path + works in a bare `TempDir` with no `.relicario/`. + +- **`tests/vault_detection.rs`** (`59 lines`) — three tests covering audit + L8: `list` refuses without a marker; `list` from a nested subdirectory + finds the parent `.relicario/`; a v1 `.idfoto/` directory is rejected + with the `.relicario` hint in the error message. + +The whole test suite uses `assert_cmd` to spawn the real binary against a +real temp directory, so they exercise actual fs / git / KDF code paths. +The KDF runs with the production-grade `m=64MiB, t=3, p=4` parameters in +the test path (`main.rs:365`), which is why init takes a noticeable beat +in the test runner. The core's "fast Argon2id for tests" CLAUDE.md note +applies to `relicario-core` unit tests, not these CLI integration tests. + +## Gotchas & non-obvious decisions + +- **`cmd_generate` runs without unlock outside a vault, but with unlock + inside.** This is two ergonomic guarantees in one command. Outside, it's + a fast standalone CSPRNG tool — useful for smoke tests, scripts, and any + user who installed `relicario` just for the generator. Inside, it + consults `settings.generator_defaults` so the user gets the policy they + configured. The branch is the `vault_dir().is_ok()` check at + `main.rs:1440`. Tests pin both behaviours. + +- **TOTP edit pushes history under the synthetic key `core:totp_secret`, + not `core:totp` or anything else.** This is what `relicario history + <query> --field totp_secret` matches against. The naming convention + ("type underscore field") is shared across all five history-tracked + fields (see Invariants). If you add a new history-tracked field, pick a + matching `<type>_<field>` form so the user-facing `--field` filter + stays predictable. + +- **`detach` refuses a Document item's primary attachment.** + (`main.rs:1392-1400`) Document items model "this item *is* a file"; the + primary blob isn't optional. The error directs the user to `purge` + instead. Non-primary attachments on a Document (e.g., a scanned + contract with an addendum) detach normally. + +- **Per-type `build_*_item` / `edit_*` helpers exist by design after the + `3f0f5b1` refactor.** Before the refactor, `cmd_add` and `cmd_edit` + carried 217-line `match` arms. The split-out functions are easier to + read, easier to test individually (the existing integration tests still + drive them through the same paths), and easier to grow when a new + `ItemCore` variant lands. Keep this shape — don't fold them back. + +- **Why the CLI shells out to `git`, not libgit2 / gitoxide.** Three + reasons. (1) Dep tree: pulling in libgit2 doubles compile time and + adds a C dependency. (2) Override surface: users can put any + `~/.gitconfig` they want and it Just Works (subject to the hardening + flags). (3) Recovery: when something is wrong with a vault, the user + can poke around with `git log`, `git show`, `git fsck` directly; the + CLI's git interactions are not opaque. + +- **The hardened-`git` injection set is load-bearing.** `git_command` + prepends three `-c` flags before the user-supplied args + (`helpers.rs:48-52`): + - `core.hooksPath=/dev/null` — a malicious or buggy hook in a cloned + vault could otherwise run arbitrary code on every commit. Master key + is in memory at the time of commit; this matters. + - `commit.gpgsign=false` — if the user has global GPG signing on, the + GPG agent prompt would block on `git commit` and hold the master + key alive in memory until the user types the passphrase. Disable it + for relicario commits. + - `core.editor=true` — `true(1)` exits 0 with no output. If `git` + decides to drop into `$EDITOR` (rebase conflict markers, missing + `-m`), this neutralises it without crashing the rebase. We pass + `-m <msg>` ourselves; this flag is the seatbelt. + All three were added together in audit H4. A user can still run + `git` themselves with their own config to inspect or repair the + vault — the hardening only applies to relicario's invocations. + +- **`cmd_init` uses production-grade `KdfParams { m: 65536, t: 3, p: 4 + }`** (`main.rs:365`), even in tests. `RELICARIO_TEST_PASSPHRASE` + bypasses the prompt but does not lower the KDF cost. This is a + trade-off: integration tests pay the full Argon2id cost (~half a + second per init on a modern machine), but the same code path runs in + production. Don't lower the params here — the core's test-only fast + params are for `relicario-core` unit tests. + +- **`params.json` has a nested `kdf` object, not a flat one.** + `read_params` (`session.rs:110-121`) deserializes via a private + `ParamsFile { kdf: KdfParams }` struct. The nesting exists so + `format_version`, `aead`, and `salt_path` can co-exist in the same + file for forward-compat. An earlier version of `read_params` tried + to deserialize the whole file as `KdfParams` and failed silently — + that bug was fixed in commit `b263c27`. + +- **`commit_paths` is the convention but not always the call site.** + `cmd_purge`, `cmd_trash_empty`, and `cmd_device` use `git_command` + directly because their add/commit pattern doesn't quite fit + `commit_paths(vault, msg, &[paths...])`. They still use the + hardened wrapper, just at one level lower. If you find yourself + writing a new command with the same shape, prefer `commit_paths`; + reach for `git_command` directly only when you need the slightly + different control flow these three have. + +- **Initial commit at `cmd_init` does not use `commit_paths`.** + Reason: `commit_paths` takes `&UnlockedVault`, but `cmd_init` doesn't + construct one — it uses the master key inline before the vault + exists. The init commit goes through `git_command` directly + (`main.rs:403-412`). This is the only production code site outside + `commit_paths` that does so. + +- **`Lock` is a no-op (`main.rs:301`).** The CLI doesn't cache a + session — every command re-derives the master key. The command + exists only for UX parity with the extension, where `lock` actually + evicts a cached session. Printed message: `no cached session to + lock`. + +- **`resolve_query` accepts an item id or a case-insensitive title + substring** (`main.rs:855-871`). Exact id-match wins; otherwise it + defers to `Manifest::search`. Multi-hit substring matches are + rejected with an "ambiguous" error listing the matched titles. This + is why every `cmd_*` that takes a `query: String` (get, edit, + history, rm, restore, purge, attach, attachments, extract, detach) + works the same way. diff --git a/crates/relicario-core/ARCHITECTURE.md b/crates/relicario-core/ARCHITECTURE.md new file mode 100644 index 0000000..faef4bb --- /dev/null +++ b/crates/relicario-core/ARCHITECTURE.md @@ -0,0 +1,514 @@ +# Architecture: relicario-core + +## What this crate is for + +`relicario-core` is the platform-agnostic cryptographic and data-model heart of the +relicario password manager. It is strictly **bytes-in / bytes-out**: every public +function takes byte slices or owned typed structs and returns byte vectors or typed +structs. The crate performs no filesystem I/O, no network I/O, no git operations, +and no time-of-day reads beyond `chrono::Utc::now()` for timestamping items +(`time.rs:6`). This boundary is what lets the same compiled artifact serve the +native CLI (`relicario-cli`), a `wasm32-unknown-unknown` build embedded in the +Chrome MV3 / Firefox WebExtension popup (`relicario-wasm`), and (eventually) ARM +mobile builds — without conditional compilation. Anything that touches a +`Path`, opens a socket, or shells out belongs in `relicario-cli` or the +extension layer, never here. The historical rationale is in +`docs/superpowers/specs/2026-04-11-relicario-design.md` (sections "Crypto +Pipeline" and "Crate Layout"). + +## Module map + +- **`lib.rs`** — Public API surface. Re-exports the symbols that callers actually + need (`encrypt_item`, `derive_master_key`, `Item`, `ItemCore`, etc.). The + module list here is the contract; everything else is internal. +- **`error.rs`** — `RelicarioError` (a `thiserror`-derived enum) plus the crate + alias `Result<T> = std::result::Result<T, RelicarioError>`. One error type + for the whole crate so FFI / WASM bindings and CLI handlers each have a single + exhaustive `match` to maintain. `Decrypt` is intentionally opaque (no inner + detail string) — see "Cross-cutting concerns". +- **`crypto.rs`** — KDF (`derive_master_key`, Argon2id with NFC-normalized, + length-prefixed inputs) and AEAD (`encrypt`, `decrypt`, XChaCha20-Poly1305 + with `VERSION_BYTE = 0x02`). Owns the on-disk ciphertext layout. The KDF + parameters (`KdfParams`) are an owned struct that callers persist however + they like (CLI puts them in `.relicario/params.json`); the crate has no + opinion about storage. +- **`ids.rs`** — `ItemId`, `FieldId` (random 64-bit hex from `OsRng`, + `ids.rs:26-32`, `ids.rs:38-49`) and content-addressed `AttachmentId` + (first 8 bytes of `SHA-256(plaintext)`, `ids.rs:51-57`). Three separate + newtypes rather than `String` so misuses can't compile. +- **`time.rs`** — `now_unix()` and `MonthYear` (the validated 1..=12 / 2000..=2099 + card-expiry type). Trivially small; broken out only because every other module + needs `now_unix()` and `MonthYear` is used by both `item.rs` and + `item_types/card.rs`. +- **`item_types/mod.rs`** — `ItemType` enum (snake-case wire tag) and `ItemCore` + (internally tagged `#[serde(tag = "type")]` enum), with one variant per item + type. The "extension via match exhaustiveness" pattern is documented at + `item_types/mod.rs:1-7`: adding an item type is a `cargo check` walk through + every match arm. Re-exports each per-type core. +- **`item_types/login.rs`** — `LoginCore` (username, password as + `Zeroizing<String>`, optional `Url`, optional `TotpConfig`). +- **`item_types/secure_note.rs`** — `SecureNoteCore` (single `Zeroizing<String>` + body). +- **`item_types/identity.rs`** — `IdentityCore` (full name, address, phone, + email, DOB; all optional, none `Zeroizing` — they're personal data, not + secret material). +- **`item_types/card.rs`** — `CardCore` plus `CardKind` (Credit/Debit/Gift/ + Loyalty/Other). `number`, `cvv`, `pin` are `Zeroizing`; `holder` is plain + `String`. +- **`item_types/key.rs`** — `KeyCore`: opaque `Zeroizing<String>` `key_material` + with optional label / public key / algorithm. Used for SSH keys, GPG keys, + arbitrary blobs. +- **`item_types/document.rs`** — `DocumentCore`: filename + mime + a single + `AttachmentId` pointing at the primary blob. The body lives in the + attachment store, not the item. +- **`item_types/totp.rs`** — `TotpCore`, `TotpConfig`, `TotpAlgorithm` + (Sha1/Sha256/Sha512), `TotpKind` (Totp / Hotp{counter} / Steam), and the + `compute_totp_code()` function. Includes the Steam Mobile Authenticator + 5-character alphabet and its conversion (`item_types/totp.rs:103-110`). + The same `TotpConfig` is reused as a sub-struct of `LoginCore` (so a Login + item can carry its own TOTP without spawning a separate item). +- **`item.rs`** — The `Item` envelope. Holds the parallel `FieldKind` / + `FieldValue` enums (kept parallel so callers can ask the kind without + inspecting the value, `item.rs:1-6`), `Field`, `Section`, `FieldHistoryEntry`, + and the `Item` struct itself with its `set_field_value` / `soft_delete` / + `restore` / `prune_history` mutators. Custom-fields and field-history live + here, not in the per-type cores. +- **`attachment.rs`** — `AttachmentRef` (full record carried on `Item`), + `AttachmentSummary` (compact form carried in `Manifest`), + `EncryptedAttachment`, and the `encrypt_attachment` / `decrypt_attachment` + helpers. The size cap is enforced **before** any crypto work (`attachment.rs:69-74`). +- **`manifest.rs`** — The browse-without-decrypt index: `Manifest`, + `ManifestEntry`, `MANIFEST_SCHEMA_VERSION = 2`. `upsert(&item)` rebuilds the + entry from the item — there is no path for the manifest to drift from the + source-of-truth item file. Includes case-insensitive title/tag search + (`manifest.rs:59-68`) and Login icon-hint derivation (host of the URL, + `manifest.rs:93-99`). +- **`settings.rs`** — `VaultSettings` and its sub-types: `TrashRetention`, + `HistoryRetention`, `GeneratorRequest` (`Random` or `Bip39`), + `AttachmentCaps`, plus the `autofill_origin_acks` map for the extension's + TOFU prompt. +- **`generators.rs`** — Random-password and BIP-39 passphrase generation, both + driven by `GeneratorRequest` from `settings.rs`. zxcvbn-backed + `rate_passphrase` and the `validate_passphrase_strength` gate that rejects + any score < 3. +- **`vault.rs`** — Typed wrappers around `crypto::{encrypt, decrypt}`: + `encrypt_item`/`decrypt_item`, `encrypt_manifest`/`decrypt_manifest`, + `encrypt_settings`/`decrypt_settings`. Each does + `serde_json::to_vec → encrypt` (or the inverse). The plaintext `Vec<u8>` is + wrapped in `Zeroizing` between serde and the cipher + (`vault.rs:18-19`, `vault.rs:24-26`). +- **`imgsecret.rs`** — Self-contained DCT-based steganography for the second + auth factor. Owns its own `YChannel`, `EmbedRegion`, 8×8 DCT/IDCT, + Quantization Index Modulation, and crop-recovery extractor. No other module + imports it; it is consumed only via the public re-export from `lib.rs`. + +## Invariants & contracts + +- **No filesystem, no network, no git, no spawn.** Verified by inspecting + imports; the only I/O-shaped types in use are in-memory `Cursor<&[u8]>` + for image decoding (`imgsecret.rs:243`). +- **No `unsafe`.** Confirmed by `grep` over `src/`. The crate compiles to WASM + unmodified for that reason. +- **No `async`.** All operations are pure compute on byte slices. Async lives + in `relicario-cli` (process spawning) and in the extension's service worker + (message channels), not here. +- **`VERSION_BYTE = 0x02`** (`crypto.rs:59`). Every blob produced by + `encrypt()` starts with this byte; `decrypt()` rejects any other value with + `RelicarioError::UnsupportedFormatVersion { found, expected }` + (`crypto.rs:127-132`). v1 blobs (the pre-rewrite format) are explicitly + tested for rejection (`tests/format_v2.rs:28-42`). +- **AEAD blob layout** is fixed at `version(1) || nonce(24) || ciphertext+tag(≥16)` + (`crypto.rs:18-32`). Minimum valid blob length is 41 bytes + (`crypto.rs:118-124`). +- **Nonces are always fresh from `OsRng`** (`crypto.rs:87-89`). There is no + caller-supplied nonce path. With 192 bits of randomness, collision risk is + negligible across the lifetime of any vault. +- **`MANIFEST_SCHEMA_VERSION = 2`** (`manifest.rs:12`). v1 manifests (which + predate typed items) are not handled here and are rejected at the JSON-parse + step. +- **KDF input is length-prefixed.** `derive_master_key` builds the password + buffer as `u64_be(len(passphrase)) || passphrase || u64_be(32) || image_secret` + (`crypto.rs:229-236`). This eliminates the (`"abc"`, `0x44…`) vs (`"abcD"`, + `…`) collision, and is exercised in + `crypto.rs:352-368` and `tests/format_v2.rs:44-54`. +- **Passphrases are NFC-normalized before hashing.** Bytes that aren't valid + UTF-8 pass through unchanged (`crypto.rs:223-227`). This keeps "café" + (precomposed) and "café" (combining acute) from producing different keys + (`crypto.rs:370-385`). +- **Master key only ever lives in `Zeroizing<[u8; 32]>`.** Returned that way + by `derive_master_key` (`crypto.rs:212`) and accepted that way by + `encrypt_item` / `encrypt_attachment` / friends. No public function in + `vault.rs` or `attachment.rs` accepts a raw `[u8; 32]`. +- **Plaintext is wrapped in `Zeroizing` between serde and the cipher.** See + `vault.rs:18-19`, `vault.rs:24-26`, `vault.rs:31-32`, `vault.rs:37-38`, + `vault.rs:44-45`, `vault.rs:50-51`. The serde JSON intermediate buffer is the + most exposed point, so it is wiped on drop. +- **`AttachmentId` is content-addressed** to the first 8 bytes (= 16 hex chars) + of `SHA-256(plaintext)` (`ids.rs:51-57`). Identical plaintexts deduplicate + in git automatically — proven in `tests/attachments.rs:28-35`. The 64-bit + prefix is used (rather than the full digest) to keep filenames short; the + collision space is still adequate for the expected vault size. +- **`ItemId` and `FieldId` are 16 hex chars** = 64 bits of `OsRng` entropy + (`ids.rs:25-32`, `ids.rs:38-49`). The audit (M8) bumped them from the + original 8-char / 32-bit format. +- **Field kind/value discriminants must agree.** `Field::new` derives `kind` + from `value` (`item.rs:85-94`); `Field::validate` (called after deserialize) + rejects any mismatch (`item.rs:97-107`). `set_field_value` further refuses + to change a field's kind (`item.rs:184-189`). +- **Field-history capture is restricted to three kinds:** `Password`, + `Concealed`, `Totp` (`item.rs:68-71`). Any other kind's update silently + skips history. The TOTP secret is base32-encoded for the history entry + (`item.rs:245-249`) so a user reading their history sees a recognizable + string. +- **History captures the *previous* value, not the new one** (`item.rs:190-197`): + `set_field_value` serializes `field.value` *before* assigning the new value. +- **`hidden_by_default` is set automatically** when the field's kind is + `Password` or `Concealed` (`item.rs:92`). The extension and CLI both honor + this hint when rendering. +- **Attachment cap is checked before encryption** (`attachment.rs:69-74`). + An oversize blob fails with `RelicarioError::AttachmentTooLarge { size, max }` + without ever calling `encrypt`. The CLI/extension are expected to read the + cap from `VaultSettings::attachment_caps`. +- **`Item::soft_delete` does not erase data.** It sets `trashed_at` and bumps + `modified` (`item.rs:205-208`). Purging is the caller's responsibility, + driven by `TrashRetention::should_purge` (`settings.rs:38-44`). +- **`prune_history` is idempotent and explicit.** Items keep all history until + the caller invokes it with a `HistoryRetention` policy (`item.rs:219-237`). + Last-N drops oldest first; Days drops anything older than `now - days·86400`. +- **`item_type()` is the single source of truth** for the type tag stored on + `Item`. `Item::new` derives `r#type` from the supplied `ItemCore` + (`item.rs:159-164`). Manual construction can violate this — the JSON + round-trip does not re-validate beyond serde's tag matching. +- **Reserved serde key:** no `*Core` may have a JSON-serialized field named + `"type"` — that name is reserved for serde's discriminator on `ItemCore` + (`item_types/mod.rs:38-40`). Use `"kind"` instead (see `CardKind`, + `TotpKind`). +- **`MAX_DIMENSION = 10_000`** for imgsecret (`imgsecret.rs:71`). Enforced via + a header-only peek (`imgsecret.rs:127-176`) at the entry of both `embed` and + `extract` so an attacker-supplied 32000×32000 JPEG is rejected without + decoding pixels (audit M3). +- **`MIN_DIMENSION = 100`** plus a "must hold ≥5 redundant copies" floor + (`imgsecret.rs:66`, `imgsecret.rs:78`, `imgsecret.rs:682-689`). Smaller + carriers are rejected with `ImageTooSmall`. +- **Strength gate is `score >= 3`** (`generators.rs:124-130`). Vault-creation + callers must invoke `validate_passphrase_strength` themselves; the crate + does not internally call it inside `derive_master_key` (since that path is + also used to derive the key for *unlock*, not just create). +- **`SymbolCharset::Custom` must be ASCII-only** (`generators.rs:46-52`). + Non-ASCII custom charsets are rejected with `RelicarioError::Format`. + +## Key flows + +### Vault unlock — key derivation + +1. Caller obtains `passphrase: &[u8]` (UTF-8) and `image_secret: &[u8; 32]` + (typically from `imgsecret::extract` over the user's reference JPEG). +2. Caller loads `salt: [u8; 32]` and `KdfParams` from out-of-band storage + (CLI: `.relicario/salt` and `.relicario/params.json`). +3. `derive_master_key(passphrase, &image_secret, &salt, ¶ms)` — + `crypto.rs:207-244`: + - NFC-normalize the passphrase if it parses as UTF-8 (`crypto.rs:223-227`). + - Build the length-prefixed password buffer in a `Zeroizing<Vec<u8>>` + (`crypto.rs:229-236`). + - Run `Argon2id` with `Algorithm::Argon2id`, `Version::V0x13`, + output length 32 (`crypto.rs:213-221`, `crypto.rs:238-241`). +4. Returns `Zeroizing<[u8; 32]>` — automatically wiped on drop. + +A wrong passphrase or wrong image produces a *different* derived key. The crate +cannot tell them apart at this stage; the caller learns "wrong factor" only +when subsequent `decrypt_*` returns `RelicarioError::Decrypt`. + +### Item write + +1. Caller mutates an `Item` (e.g. `item.set_field_value(&fid, new_value)` — + `item.rs:181-203`). `set_field_value` captures previous value into + `field_history` if the kind is history-tracked, then bumps `modified`. +2. Caller calls `encrypt_item(&item, &master_key)` — `vault.rs:16-20`: + `serde_json::to_vec(item)` → wrap in `Zeroizing` → `crypto::encrypt`. +3. Caller calls `manifest.upsert(&item)` (`manifest.rs:45-48`) to refresh the + browse-index entry; then `encrypt_manifest(&manifest, &master_key)` + (`vault.rs:29-33`). +4. The two ciphertext blobs are returned to the caller, who writes them to disk + (or commits them, or sends them over a sync channel). + +### Item read (browse-without-decrypt path) + +1. Caller calls `decrypt_manifest(&manifest_blob, &master_key)` + (`vault.rs:35-40`). One AEAD decryption gets the entire searchable index. +2. `Manifest::search(query)` does a case-insensitive substring match over title + and tags (`manifest.rs:59-68`). `manifest.items.values()` gives every + `ManifestEntry` with `title`, `tags`, `favorite`, `group`, `icon_hint`, + `modified`, `trashed_at`, and `attachment_summaries` — enough to render a + list UI without touching any item file. +3. When the user picks an entry, the caller reads `entries/<id>.enc` and calls + `decrypt_item(&blob, &master_key)` (`vault.rs:22-27`) to get the full + `Item` including secret fields and `field_history`. + +### Attachment encryption + +1. Caller has `plaintext: &[u8]`, the `master_key`, and the active + `VaultSettings::attachment_caps.per_attachment_max_bytes`. +2. `encrypt_attachment(plaintext, &master_key, max_bytes)` — + `attachment.rs:64-78`: + - If `plaintext.len() > max_bytes`, return `AttachmentTooLarge` *immediately* + before any crypto. + - `AttachmentId::from_plaintext(plaintext)` (SHA-256, `ids.rs:51-57`). + - `crypto::encrypt(master_key, plaintext)`. +3. Returns `EncryptedAttachment { id, bytes }`. The caller persists `bytes` at + `attachments/<id>.enc` and adds an `AttachmentRef { id, filename, mime_type, + size, created }` (`attachment.rs:11-20`) to the owning `Item`. On + `Manifest::upsert`, an `AttachmentSummary` (no `created` field) is derived + automatically (`manifest.rs:87`). + +### Field-history capture + +1. Triggered exclusively by `Item::set_field_value` (`item.rs:181-203`). Direct + mutation of `field.value` bypasses history — the type system does not + prevent this. +2. The check `field.value.is_history_tracked()` runs *on the existing value* + (`item.rs:190`), so adding the *first* password value to a previously-empty + field does not create a history entry; updating an already-set password + does. +3. The previous value is serialized via `serialize_history_value` + (`item.rs:241-253`): + - `Password(p)` and `Concealed(c)` clone the inner string into a fresh + `Zeroizing<String>`. + - `Totp(cfg)` base32-encodes the raw secret bytes + (`item.rs:245-249`, `item.rs:256-275`). + - Any other kind would error (`item.rs:250`), but is unreachable because + `is_history_tracked` already gated the call. +4. Pruning is *not* automatic. Callers (CLI commit hook, extension save handler) + call `item.prune_history(&settings.field_history_retention, now_unix())` + when they want to enforce the policy. + +### imgsecret embed + +1. Caller passes a JPEG byte slice and a 32-byte secret to + `imgsecret::embed(carrier_jpeg, &secret)` (`imgsecret.rs:666-726`). +2. `enforce_dimension_cap` walks JPEG markers (`imgsecret.rs:127-161`) to read + the SOF dimensions; rejects > 10_000 × 10_000 before any pixel decode. +3. `extract_y_channel` decodes via `image::ImageReader` and converts each pixel + to BT.601 luminance (`imgsecret.rs:242-265`). +4. `central_region` picks the inner 70% of the image as the embed region; the + 15% margin per side is the "crumple zone" for crops + (`imgsecret.rs:268-293`). +5. `compute_embed_positions` / `select_embed_blocks` lay out + `num_copies × BLOCKS_PER_COPY` 8×8 blocks evenly across the region, with + `num_copies` = `min(50, total_blocks / 22)` (`imgsecret.rs:530-575`). +6. For each block: 2D DCT (`dct2_8x8`, `imgsecret.rs:393-412`) → embed 12 bits + into the 12 mid-frequency coefficients listed in `EMBED_POSITIONS` + (zig-zag positions 6–17, `imgsecret.rs:105-118`) via QIM with + `QUANT_STEP = 50.0` (`imgsecret.rs:462-467`) → 2D inverse DCT → write + back into Y. +7. `reconstruct_jpeg` (`imgsecret.rs:590-640`) re-derives Cb/Cr per pixel from + the original RGB (so chrominance is preserved), combines with the modified + Y, and re-encodes at JPEG quality 92. + +### imgsecret extract (with crop recovery) + +1. `extract(jpeg_bytes)` enforces the dimension cap, then delegates to + `extract_with_crop_recovery` (`imgsecret.rs:738-741`, + `imgsecret.rs:849-899`). +2. **Try 1** — assume uncropped: `try_extract_with_layout(&y, w, h, 0, 0)`. + This is the hot path; for a freshly embedded image it always succeeds. +3. **Try 2** — width-only crop, block-aligned: iterate `orig_w` from current + width up to `1.20 × current_w` in 8-px steps, with `dx = 0` + (assume right-edge crop). +4. **Try 3** — height-only crop, block-aligned: same strategy on the vertical + axis. +5. **Try 4** — width crops at non-block-aligned 1-px steps, skipping any + already covered in Try 2. +6. `try_extract_with_layout` (`imgsecret.rs:754-834`) tallies QIM votes for + each of the 256 bit positions across all `num_copies` copies. Each bit + must reach **≥60% confidence** (`imgsecret.rs:824`); below that, the + whole extraction fails with `ExtractionFailed` (no partial result is + ever returned). +7. The 60% threshold is per-bit, not aggregate — a single unconfident bit + aborts the whole try. This makes false-positive extractions from + never-embedded images vanishingly unlikely. + +## Cross-cutting concerns + +- **Error model.** `RelicarioError` (`error.rs:15-89`) is a single + `thiserror`-derived enum. `Decrypt` is the deliberately-opaque "wrong key + or tampered ciphertext" variant (audit M4 — `error.rs:28-30`, + `tests/integration.rs:99-111`): the message is just `"decryption failed"` + with no inner string, and it does not distinguish wrong-passphrase from + wrong-image-secret from corrupted ciphertext. `Format` is the + "input bytes don't make sense" variant (e.g. blob too short, schema + mismatch). `UnsupportedFormatVersion` is the structured "wrong version + byte" variant — separate from `Format` because callers want to react to + it differently (offer migration, etc.). +- **Where secrets live.** Every secret type wraps `Zeroizing<...>`: + - The derived master key: `Zeroizing<[u8; 32]>` (`crypto.rs:212`). + - Field values: `FieldValue::Password(Zeroizing<String>)` and + `FieldValue::Concealed(Zeroizing<String>)` (`item.rs:39-40`). + - `FieldHistoryEntry::value`: `Zeroizing<String>` (`item.rs:127`). + - Per-type cores: `LoginCore::password`, `CardCore::{number,cvv,pin}`, + `KeyCore::key_material`, `SecureNoteCore::body`, `TotpConfig::secret` + (a `Zeroizing<Vec<u8>>` of the raw HMAC key). + - Decrypted attachment plaintext: `Zeroizing<Vec<u8>>` + (`attachment.rs:88-92`). + - Argon2id input buffer (`crypto.rs:232`) and JSON serialization buffers in + `vault.rs` are wrapped in `Zeroizing` to wipe the intermediate plaintext. +- **Format versioning.** Three independent version channels exist, each + gating something different: + - `crypto::VERSION_BYTE = 0x02` (`crypto.rs:59`) — gates the AEAD blob + layout. Bumped if the nonce length, header layout, or cipher changes. + A v1 blob is rejected with a typed + `UnsupportedFormatVersion { found: 0x01, expected: 0x02 }`. + - `manifest::MANIFEST_SCHEMA_VERSION = 2` (`manifest.rs:12`) — gates the + JSON-level shape of the manifest. v1 manifests had a different layout + and would fail to parse against the current `Manifest` struct. + - The `.relbak` import/export format defined in + `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` + will introduce a third version channel for backups; that surface lives + outside this crate. +- **KDF parameter handling.** `KdfParams` (`crypto.rs:156-168`) is just a + serializable struct. The crate has no opinion about where it is stored, + how it is rotated, or who increments it. `Default` gives the production + values (`m=65536`, `t=3`, `p=4` — `crypto.rs:175-183`) calibrated for + ~0.5–1 s on a modern desktop. Tests universally use the fast triplet + `(m=256, t=1, p=1)` defined as a `fn fast_params()` near the top of every + test file. +- **NFC normalization is the only Unicode op.** All passphrase canonicalization + happens in one place (`crypto.rs:223-227`). Item titles, field labels, + tags, etc. are stored verbatim — only the passphrase fed to the KDF is + normalized. +- **No per-entry subkeys.** Every encrypted blob (item, manifest, settings, + attachment) is encrypted with the *same* master key. The design rationale + is in `docs/superpowers/specs/2026-04-11-relicario-design.md` lines 66: + per-entry subkey derivation would add complexity for no real-world benefit + given the expected family-vault size. +- **CSPRNG is `OsRng` everywhere.** `ItemId::new`, `FieldId::new`, + `derive_master_key` (no-op — the salt is caller-supplied), + `crypto::encrypt` (nonce), `generators::random_password`, + `generators::bip39_passphrase`. A single `rand::thread_rng()` call exists + inside an `imgsecret` test (`imgsecret.rs:1033`) to generate a random test + secret; production code is `OsRng` only. +- **`ed25519-dalek` is a dependency placeholder.** Listed in + `Cargo.toml:17` but unused in `src/`. It exists for the future + device-key surface (`RelicarioError::DeviceKey` is the reserved variant, + `error.rs:84-88`); device-key signing currently happens in + `relicario-cli` instead. + +## Test architecture + +All `tests/` files use the fast Argon2id triplet `m=256, t=1, p=1` so the +suite runs in seconds, not minutes. Test JPEGs are synthesized at runtime via +`make_test_jpeg(width, height)` (`imgsecret.rs:908-924`) — a deterministic RGB +pattern at quality 92 — so no binary fixtures live in git. + +- **`tests/integration.rs`** — End-to-end vault workflows: encrypt+decrypt a + Login and a SecureNote through `Manifest`/`VaultSettings`, two-factor + independence (different passphrase or different image_secret yields + different keys), field-history surviving an encrypt/decrypt round-trip, + and the wrong-key-→-`Decrypt` opaqueness contract. +- **`tests/attachments.rs`** — Round-trip a 5 KB blob, prove identical + plaintexts produce identical `AttachmentId`s (despite different ciphertext + bytes due to fresh nonces), and exercise the cap boundary at exactly the + max byte and one over. +- **`tests/field_history.rs`** — Sequential `set_field_value` calls accumulate + history in oldest→newest order; `prune_history(LastN(3))` keeps the most + recent 3; field-history survives `encrypt_item` →`decrypt_item`. +- **`tests/format_v2.rs`** — `VERSION_BYTE == 0x02`, fresh ciphertext starts + with `0x02`, a v1-shaped blob (`[0x01][24 nonce][16 tag]`) is rejected with + the typed `UnsupportedFormatVersion`, and the length-prefix construction + prevents `("abc", 0x44…)` / `("abcD", …)` collisions. +- **`tests/generators.rs`** — Aggregates 80 × 128 = 10,240 chars from + `generate_password` to assert per-character-class proportions are within + ±5 pp of the expected uniform distribution; verifies that 5-word BIP-39 + passes the strength gate while common weak passwords ("password", + "12345678", "letmein", "qwertyui", "hunter2") all fail; asserts uniqueness + across 1000 default-config calls. The opening doc comment + (`tests/generators.rs:1-13`) explains why the original "10,000-char single + call" plan switched to aggregation: `generate_password` enforces + `length ≤ 128`. + +In-module `#[cfg(test)] mod tests` blocks cover unit-level invariants (kind/ +value mismatches, snake-case serde tags, base32 round-trips, `MonthYear` +constructor bounds, the Steam alphabet ambiguity audit). The `imgsecret` +test block additionally proves DCT round-tripping, QIM noise tolerance below +`Q/4 = 12.5`, embed→Q85-recompress→extract round-trip, embed→10%-crop→extract +round-trip, and the oversized-image-header rejection path. + +## Gotchas & non-obvious decisions + +- **`QUANT_STEP = 50.0` is intentionally double the academic value of 25** + (`imgsecret.rs:62`). Higher quantization steps make the watermark more robust + to JPEG recompression at Q85 and below — at the cost of more visible + artifacts in the carrier. The reference image is a personal photo, not a + publication, so the trade-off favors robustness. +- **The embed region is the *central 70%* (15% margin per side, "crumple + zone")** — `imgsecret.rs:212-218`, `imgsecret.rs:276-293`. Anything in the + outer 15% is sacrificed so that mild edge crops (e.g. social-media platform + trims) leave the embedded data intact. Tested up to 10% crop in + `imgsecret.rs:1108-1137`. +- **Per-bit majority voting with a 60% confidence floor.** + `try_extract_with_layout` tallies votes from every redundant copy and + fails the entire extraction if any single bit position is below 60% + agreement (`imgsecret.rs:824`). This is more conservative than a global + threshold and is what makes false positives from never-embedded images + essentially zero — see `extract_from_non_embedded_image_fails` + (`imgsecret.rs:1041-1045`). +- **Number of redundant copies is capped at 50** (`imgsecret.rs:536`, + `imgsecret.rs:692-693`). Beyond that, per-block visual artifacts compound + faster than the error-correction benefit grows. +- **`peek_jpeg_dimensions` walks JPEG markers manually instead of using the + `image` crate.** `imgsecret.rs:127-161`. A full `ImageReader::decode` of an + attacker-supplied 30 000 × 30 000 JPEG would allocate ~3.6 GB of pixel + buffer in the WASM service worker before failing — the manual walk reads + only the SOF segment and bails in O(marker-count) (audit M3). +- **`bip39` always generates 128 bits of entropy** (12 mnemonic words) and + truncates to `word_count` (`generators.rs:82-89`). This is because + `bip39 v2` rejects entropy below 128 bits, but we want to support 3–12 word + passphrases. Truncation preserves the per-word independence — the words + the user sees still come from a uniformly-sampled-then-truncated 12-word + draw. +- **Steam TOTP output is exactly 5 characters from a 26-glyph alphabet, + regardless of the `digits` field on `TotpConfig`** (`item_types/totp.rs:103-110`, + asserted in `item_types/totp.rs:240-253`). The alphabet + (`23456789BCDFGHJKMNPQRTVWXY`) excludes `0/O`, `1/I/L`, `S` (so `5` is + unambiguous), `A`, `E`, `U`, `Z` — all glyphs Valve considered ambiguous + in the Steam Mobile Authenticator. Verified at + `item_types/totp.rs:274-283`. +- **`ItemCore` is internally-tagged with `#[serde(tag = "type")]`** — the + outer JSON object gets a `"type"` key. This means *no* `*Core` struct may + have a field literally named `type`. The convention chosen for + type-discriminant fields *inside* a core is `kind` — see `CardKind`, + `TotpKind` (`item_types/mod.rs:38-40`). +- **The TOTP base32 in field-history strips padding.** `base32_encode` + (`item.rs:256-275`) is RFC-4648 with no `=` padding — appropriate because + the value is for human display in history, not for re-decoding. +- **`AttachmentId::from_plaintext` uses only the first 8 bytes (= 16 hex + chars) of the SHA-256 digest** (`ids.rs:51-57`). 64 bits of collision + resistance is sufficient for a personal-vault attachment count; it keeps + filenames short. If a future use case demands collision resistance against + motivated adversaries (e.g. dedup across untrusted vaults), this width is + the lever. +- **`Field::new` derives `kind` from `value`, but the public struct still + stores both** (`item.rs:73-94`). The duplication exists so callers can + match on `kind` without inspecting (and potentially decrypting / cloning) + `value`. `validate()` is the safety net that runs after deserialization. +- **`set_field_value` refuses to change a field's kind** (`item.rs:184-189`). + The intent is that fields are conceptually fixed-shape after creation; + changing a `Text` to a `Password` should be done by deleting the old field + and creating a new one (so history doesn't get confused). +- **`hidden_by_default` is *not* `Zeroize`.** It's purely a UI hint — the + rendering layer (CLI output, popup card) decides whether to mask the value + on initial display. Secrecy at rest is enforced by the `Zeroizing` wrappers + on the value itself, not this flag. +- **`Manifest::upsert` rebuilds the entry from scratch every call** + (`manifest.rs:45-48`, `manifest.rs:75-89`). There is no "patch the + existing entry" path. This means the manifest can never carry a stale + `icon_hint` or `attachment_summaries` — they are derived freshly from the + source `Item` each time. +- **The strength gate is *not* called inside `derive_master_key`.** It must + be invoked separately by the caller during *vault creation* only — not + during unlock, where calling it would let an attacker probe whether a + wrong passphrase happens to be "strong enough" before the Argon2id work + even starts. See `generators.rs:124-130`. +- **`now_unix()` is `chrono::Utc::now().timestamp()` and is the single time + source in this crate** (`time.rs:6-8`). Tests that need determinism pass an + explicit `now: i64` to `prune_history` (`item.rs:219`) and similar — they + do not stub `now_unix`. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000..963f8ee --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,207 @@ +# Architecture overview — relicario + +This is the cross-codebase entry point. It describes how the three relicario codebases fit together, the contracts that flow between them, and the conventions they share. It is **deliberately thin**; the deep content lives in per-codebase docs. + +> If you are about to make a change in a single codebase, read its `ARCHITECTURE.md` first: +> +> - [crates/relicario-core/ARCHITECTURE.md](../../crates/relicario-core/ARCHITECTURE.md) +> - [crates/relicario-cli/ARCHITECTURE.md](../../crates/relicario-cli/ARCHITECTURE.md) +> - [extension/ARCHITECTURE.md](../../extension/ARCHITECTURE.md) +> +> If you want historical *why*, see `docs/superpowers/specs/` — those are time-stamped decision artifacts. This overview describes what *is*. + +## The three codebases + +``` + ┌─────────────────────┐ + │ relicario-core │ + │ (Rust, no I/O) │ + │ crypto · items │ + │ manifest · stego │ + └──────────┬──────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌────────────────┐ ┌────────────────────┐ (compiled to WASM + │ relicario-cli │ │ relicario-wasm │ inside the ) + │ (Rust binary) │ │ (#[wasm_bindgen] │ extension │ + │ │ │ bindings) │ │ + │ filesystem + │ │ │ │ + │ git + │ └────────┬───────────┘ │ + │ clap UX │ │ │ + └────────────────┘ ▼ │ + ┌─────────────────────┐ │ + │ extension │ │ + │ (TypeScript) │ │ + │ popup · vault │ │ + │ setup · content │ │ + │ service worker │ │ + └─────────────────────┘ +``` + +| Codebase | Language | Role | Key boundary | +|---|---|---|---| +| `relicario-core` | Rust | Crypto, item types, manifest, attachments, imgsecret, generators. Pure, no I/O. | Only `bytes-in / bytes-out`. No filesystem, no git, no network. | +| `relicario-cli` | Rust binary | Wraps core with filesystem ops, git plumbing, clap UX. | Only entry point that runs without a browser; sole working interface during disaster recovery. | +| `relicario-wasm` | Rust → WASM | Thin `#[wasm_bindgen]` exports from core for the extension. | Compiles `relicario-core` to WASM; no extra logic. | +| `extension` | TypeScript | Browser-resident UI. Five entry-point bundles (popup, vault tab, setup, content script, service worker). | The service worker is the only crypto holder; popup/vault/content/setup never touch the master key. | + +The CLI and the extension are **at parity**: every user-facing capability lands in both surfaces together. Diverging is allowed only with a documented reason. See the per-codebase docs for which surface owns which user flow. + +## Inter-codebase contracts + +There are four boundaries where the codebases agree on a wire format. Each is versioned independently. + +### 1. Core → WASM ABI (Rust / JS edge) + +The `relicario-wasm` crate is the JS/Rust contract. Every WASM export takes `JsValue` / `&[u8]` / `&str` and returns the same. Strings on the wire are JSON-encoded for any structured data; raw bytes for ciphertext / images / attachments. + +Adding a new core capability for the extension requires: +1. Add the capability to `relicario-core/src/`. +2. Re-export through `lib.rs`. +3. Add a thin `#[wasm_bindgen]` wrapper to `relicario-wasm/src/lib.rs`. +4. Run `wasm-pack build` (via `npm run build:wasm` in `extension/`). +5. Use it from the extension's service worker (or setup wizard). + +The `SessionHandle` is the cross-language opaque token: WASM owns the `Zeroizing<[u8;32]>` master key behind a numeric handle; JS only ever holds the number. JS calling `wasm.lock(handle)` zeroes the WASM-side memory and invalidates the handle. + +### 2. Service worker ↔ popup / vault tab / content script (chrome.runtime messages) + +All extension bundles other than the SW communicate with the SW exclusively via `chrome.runtime.sendMessage`. The protocol is defined in `extension/src/shared/messages.ts`: + +- `PopupMessage` — sent by popup, vault tab, or setup wizard +- `ContentMessage` — sent by content scripts injected into web pages +- `Response` — returned by the SW: `{ ok: true, data?: ... } | { ok: false, error: string }` + +Two **capability sets** in `messages.ts` gate which sender can issue which message: + +- `POPUP_ONLY_TYPES` — accepted only from popup.html, vault.html, or setup.html +- `CONTENT_CALLABLE_TYPES` — accepted only from content scripts + +The router (`service-worker/router/index.ts`) dispatches by sender. Adding a new message type requires adding it to one of the capability sets, **or it is silently rejected**. Vault tab parity (commit `a7dbf35`) is implemented by recognizing `vault.html` as a popup-class sender at the router level. + +### 3. Vault on disk (shared by CLI and extension) + +Every relicario vault — whether on disk for the CLI or in a git remote read by the extension — has the same layout: + +``` +<vault root>/ +├── .relicario/ +│ ├── salt # 32 bytes, random per vault, stays constant +│ ├── params.json # KdfParams: argon2_m, argon2_t, argon2_p +│ └── devices.json # [{ name, public_key }, ...] +├── manifest.enc # encrypted Manifest (browse-without-decrypt index) +├── settings.enc # encrypted VaultSettings +├── items/ +│ └── <id>.enc # encrypted Item, one per file +└── attachments/ + └── <item-id>/ + └── <aid>.enc # encrypted attachment blob; aid is content-addressed SHA-256 +``` + +The reference image (`reference.jpg`) lives **outside** the vault by convention — it is the second factor and the user's responsibility to safeguard. It is not in `.relicario/`, not in `items/`, and never committed to git. + +This layout is not formally versioned — the **content** within each `.enc` file carries its own version byte (see § Versioning below). The directory layout itself is conventional and changes would be breaking. + +### 4. Git remote API (extension's `GitHost`) + +The extension cannot shell out to `git`; it talks to the remote via the host's REST API. Two implementations live in `extension/src/service-worker/`: + +- `gitea.ts` — Gitea / Forgejo API +- `github.ts` — GitHub API + +Both implement the `GitHost` interface in `git-host.ts`. Adding a third host (GitLab, Bitbucket, custom) means implementing that interface — the rest of the extension is host-agnostic. + +The CLI does not use `GitHost`; it shells out to `git` directly via the hardened wrapper in `relicario-cli/src/helpers.rs:46`. + +## Versioning strategy + +There is no single "relicario format version." Each piece of the format is versioned independently so we can evolve without coordinated upgrades. + +| Artifact | Where versioned | Current value | Failure mode on read | +|---|---|---|---| +| AEAD ciphertext | First byte of every `.enc` blob | `VERSION_BYTE = 0x02` (in `relicario-core/src/crypto.rs`) | `RelicarioError::Format` — refuses to attempt decryption | +| Manifest schema | `Manifest.schema_version` field | `2` (set in `relicario-core/src/manifest.rs`) | v1 manifests are explicitly rejected with a clear error | +| KDF parameters | `.relicario/params.json` | Vault-specific (initially m=64MiB, t=3, p=4) | Read at unlock; stored alongside the vault | +| Backup container | First 5 bytes of `.relbak`: magic `"RBAK"` + version byte | `0x01` (designed; see import/export spec) | Format-version error if newer-version backup is read by older binary | +| Device entry | `devices.json` array of `{ name, public_key }` | Unversioned (extend by adding optional fields) | — | + +The intentional design: **no big-bang upgrades**. A user can run an older CLI against a newer vault as long as the AEAD version, manifest schema, and KDF params are still compatible. + +## Where secrets live + +The threat model differs by codebase. This is the per-secret per-codebase residence map: + +| Secret | relicario-core | relicario-cli | extension SW | extension popup/vault/content/setup | +|---|---|---|---|---| +| Passphrase (UTF-8 bytes) | `Zeroizing<String>` only during a single `derive_master_key` call | Same, in `UnlockedVault::unlock_interactive` | Same, used briefly to derive master key inside WASM | Never seen — entered into a `<input type="password">`, sent to SW via `unlock` message, immediately forgotten | +| Reference image bytes | Held by caller; core only reads | Held by `UnlockedVault::unlock_interactive` long enough to extract the secret | Same | Setup wizard holds the bytes briefly during create/attach modes | +| Image secret (32 B) | `Zeroizing<[u8;32]>` during KDF | Same | Same | Never sees it | +| Master key | `Zeroizing<[u8;32]>` returned by `derive_master_key` | `UnlockedVault.master_key` for the lifetime of one CLI invocation | WASM-side memory behind an opaque `SessionHandle`; JS never sees the bytes | Never sees it | +| Item secret (password, card number, etc.) | `Zeroizing<String>` / `Zeroizing<Vec<u8>>` | Same | Briefly held in WASM during `item_decrypt`; results passed to popup as plaintext for display | Held in DOM (the user is staring at it); cleared when view changes | +| Device private key | — | Filesystem under `~/.config/relicario/devices/<name>.key` (mode 0600) | `chrome.storage.local.device_private_key` | — | + +The popup / vault / content surfaces of the extension cannot decrypt an item independently — they all message the SW. Content scripts in particular get back already-prepared payloads (e.g. `{ username, password }`) from `fill_credentials` after the SW resolved everything. + +The CLI keeps its master key in process memory; if the process exits or crashes, the key is gone (Zeroize on drop). There is no CLI session daemon. The `lock` subcommand exists only for UX parity with the extension and is a no-op. + +## Build matrix + +| Target | Tool | Output | When to run | +|---|---|---|---| +| Native CLI | `cargo build` (debug or `--release`) | `target/{debug,release}/relicario` | After CLI changes; for distribution | +| Native test suites | `cargo test` (workspace) | — | After any Rust change | +| WASM module | `wasm-pack build --target web` (via `npm run build:wasm`) | `extension/wasm/relicario_wasm{,_bg.wasm,.js}` | After core or wasm crate changes | +| Chrome extension | `webpack` (`npm run build`) | `extension/dist/` | After TS or WASM changes; for Chrome distribution | +| Firefox extension | `webpack --config webpack.firefox.config.js` (`npm run build:firefox`) | `extension/dist-firefox/` | After TS or WASM changes; for Firefox distribution | +| All extension targets | `npm run build:all` | Both `dist/` and `dist-firefox/` plus rebuilt WASM | Pre-release | +| Extension tests | `npm test` (vitest, happy-dom) | — | After TS changes | + +The WASM build sequence matters: `wasm-pack` writes the binary into `extension/wasm/` before `webpack` picks it up. `npm run build:all` runs them in order. Manual builds need the same order. + +## Test strategy at the workspace level + +| Layer | Tool | Where | What it covers | +|---|---|---|---| +| Core unit tests | `cargo test -p relicario-core` | `crates/relicario-core/src/**/#[cfg(test)]` and `tests/*.rs` | Crypto round-trip, item serialization, manifest schema, generators, imgsecret embed/extract, format-v2 parsing | +| CLI integration tests | `cargo test -p relicario-cli` | `crates/relicario-cli/tests/*.rs` | End-to-end via `TestVault::init()` harness with synthetic JPEGs and `RELICARIO_TEST_*` env-var escape hatches; covers basic flows, edit + history (incl. TOTP), attachments, settings, vault detection | +| Extension unit tests | `npm test` (vitest) | `extension/src/**/__tests__/*.test.ts` | Component render + click handlers (mocked SW), router sender dispatch, SW handler logic (mocked WASM + chrome.storage) | +| End-to-end | none | — | No real-browser tests; mocks stand in. Build-vs-test gap is documented in extension/ARCHITECTURE.md | + +Core tests use **fast Argon2id params** (m=256, t=1, p=1) so they don't take forever; the production path is the same code with real params. The CLI's `init` command always uses production-grade params even under tests. + +## Conventions that span all three codebases + +| Rule | Where enforced | Why | +|---|---|---| +| Master key only in `Zeroizing<[u8;32]>` | core types; CLI follows; extension WASM follows | Drop-on-scope-exit zeroization; never leaves stack | +| AEAD ciphertext starts with version byte | `core/crypto.rs` | Format identification; reject v1 blobs cleanly | +| Item IDs are random 8-char hex | `core/ids.rs` | Stable, short, no information leak | +| Attachment IDs are content-addressed (SHA-256) | `core/ids.rs` | Dedup; integrity check | +| KDF input is length-prefixed | `core/crypto.rs` | Prevents `passphrase || image_secret` collisions | +| Git history is preserved as audit log; never squash | CLI commits; SW commits | Per-action history is a feature | +| Per-action git commits with structured messages | `cli` (via `commit_paths`); SW (via vault.ts helpers) | Greppable, useful as audit log | +| Hardened git invocations (`-c core.hooksPath=/dev/null` etc.) | CLI's `helpers::git_command`; SW does not shell out | Prevent hostile hooks; no GPG prompt holding key alive | +| Atomic writes (write `.tmp` → rename) | CLI's `session::atomic_write`; SW's vault.ts equivalents | Partial-write safety | +| Tests use synthesized JPEGs (`make_test_jpeg`), not committed binaries | Both Rust and TS test harnesses | Repo stays small; reproducible | +| Test-only env vars (`RELICARIO_TEST_*`) have no production fall-through | Verified in `relicario-cli` audit | Escape hatches don't leak into builds | + +## Where to look next + +| If you're working on... | Start with | +|---|---| +| Crypto, item types, manifest format | [`crates/relicario-core/ARCHITECTURE.md`](../../crates/relicario-core/ARCHITECTURE.md) | +| A new CLI command or a CLI bug | [`crates/relicario-cli/ARCHITECTURE.md`](../../crates/relicario-cli/ARCHITECTURE.md) | +| A new popup view, vault tab feature, or autofill change | [`extension/ARCHITECTURE.md`](../../extension/ARCHITECTURE.md) | +| A new SW message type | `extension/src/shared/messages.ts` (capability sets), then [`extension/ARCHITECTURE.md § Invariants`](../../extension/ARCHITECTURE.md) | +| A new GitHost (e.g. GitLab support) | `extension/src/service-worker/git-host.ts` (interface) and existing implementations | +| Adding a new item type | core's `item_types/` mod, then CLI's `build_*_item`/`edit_*` helpers, then extension's `popup/components/types/<type>.ts` | +| Threat model / why a primitive was chosen | `docs/superpowers/specs/2026-04-11-relicario-design.md` (historical, but authoritative for rationale) | +| Format of the import/export feature | `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` (designed but not yet implemented) | +| Running the full test suite | `cargo test && (cd extension && npm test)` | +| Bumping the WASM module after a core change | `cd extension && npm run build:wasm` | + +## Stale spec docs + +The `docs/superpowers/specs/` tree is **historical** — it captures the design decisions made at planning time. Some specs (e.g. `Plan 1A`, `1B`, `1C-α`/`β`/`γ`) describe work that has shipped. Do not edit them as if they were the architecture docs; instead update the appropriate `ARCHITECTURE.md`. The specs are valuable for *why* (why XChaCha20-Poly1305, why central-embed DCT, why two-factor with steganography); the architecture docs are valuable for *what* (current invariants, current flows, current contracts). diff --git a/extension/ARCHITECTURE.md b/extension/ARCHITECTURE.md new file mode 100644 index 0000000..c689602 --- /dev/null +++ b/extension/ARCHITECTURE.md @@ -0,0 +1,831 @@ +# Architecture: relicario extension + +> Strategic-depth doc for the `extension/` codebase. Pairs with `/CLAUDE.md` +> at the repo root (project-level summary) and the typed-items design spec +> under `docs/superpowers/specs/`. Things that are easy to recover from +> reading code are deliberately omitted; things that are not — invariants, +> multi-file control flow, design rationale — go here. + +## What this codebase is for + +The extension is the browser-resident face of relicario: the same vault the +`relicario` CLI manages, but rendered as Chrome MV3 / Firefox WebExtension +UI plus a content-script autofill surface. It does not invent its own data +model or crypto — `crates/relicario-core` compiled to WASM +(`extension/wasm/relicario_wasm.js` + `relicario_wasm_bg.wasm`) holds the +KDF, AEAD, manifest/item/settings (de)serialization, password generators, +TOTP, steganography, and field-history routines. The extension is, above +that core, three things: a message router and crypto fortress (the service +worker), a small UI shell that runs in the popup and a fullscreen vault +tab, and a content script that detects login forms and shuttles +already-resolved credentials into them. + +Design intent is CLI parity. Every capability in the CLI is reachable from +the extension; the popup is the everyday surface (unlock, search, fill, +TOTP, generator, capture); heavy workflows (setup wizard, vault-level +settings, trash, devices, future backup/restore and importer) live in the +fullscreen vault tab so they have screen real estate without the popup's +600px constraint. Both Chrome MV3 and Firefox WebExtension are first-class +build targets — `manifest.json` (Chrome) and `manifest.firefox.json` +(Firefox) differ only in the manifest envelope; the same TypeScript bundles +back both. + +## Bundle structure + +Webpack produces five entry points in the Chrome build, four in the +Firefox build (the vault tab is Chrome-only for the moment). Verify in +`extension/webpack.config.js` and `extension/webpack.firefox.config.js`. + +| Bundle | Entry | Sandbox | Has WASM access? | +| ------------------ | -------------------------------------- | ------------------ | --------------------- | +| `service-worker` | `src/service-worker/index.ts` | extension SW / bg | yes — initialized lazily on first message | +| `popup` | `src/popup/popup.ts` | popup.html | no — goes through SW | +| `vault` | `src/vault/vault.ts` (Chrome only) | vault.html (tab) | no — goes through SW | +| `setup` | `src/setup/setup.ts` | setup.html (tab) | yes — direct dynamic import (predates SW handle) | +| `content` | `src/content/detector.ts` | host page (top frame only by router check) | no | + +### What each bundle owns + +- **service-worker** — the only place a vault `SessionHandle` and + decrypted `Manifest` ever live. Initializes WASM lazily on the first + message (`service-worker/index.ts:20`). Every other bundle goes through + this bundle for crypto. It also implements both `GitHost`s, owns the + inactivity timer (`session-timer.ts`), and reads/writes + `chrome.storage.local` for device-local state. +- **popup** — small MV3 popup at `popup.html`. Locked-or-list state + machine, search/sort/edit, attachments + TOTP. Cannot access + `SessionHandle` directly — every operation is a `chrome.runtime.sendMessage` + to the SW. +- **vault** — fullscreen "desktop-like" sidebar+pane shell. Imports the + same component renderers as the popup via the `StateHost` service + locator (see Cross-cutting). The vault tab is Chrome-only because + Firefox MV3 still treats `chrome.tabs.create` to extension pages + differently and the popup pop-out wasn't worth the cost yet. +- **setup** — first-run wizard. Lives in its own page (`setup.html`) + rather than the popup so the carrier-image upload + zxcvbn meter + + remote-host probing all have room. Loads WASM directly because it must + do crypto before any extension config exists for the SW to read + (`setup.ts:27`). +- **content** — injected into every page (`<all_urls>`) at + `document_idle`. Detects login forms, paints a small "id" icon, runs + the autofill picker / TOFU hint inside closed Shadow DOMs, and prompts + on form submit to save or update credentials. Cannot decrypt — the + SW always returns already-resolved `{ username, password }` payloads. + +### Output trees + +`webpack.config.js` writes to `dist/` and copies both +`relicario_wasm_bg.wasm` and `relicario_wasm.js` next to the bundles so +the SW's `chrome.runtime.getURL('relicario_wasm_bg.wasm')` resolves and +the setup page's dynamic `import('../relicario_wasm.js')` works. The +Firefox config writes to `dist-firefox/`, swaps in the Firefox manifest +under the name `manifest.json`, and skips the vault entry. Both pin +`experiments.asyncWebAssembly: true`. The Chrome content_security_policy +keeps `'wasm-unsafe-eval'` for extension pages (necessary for the WASM +init in setup.ts and the SW). + +### WASM module + +The wasm-pack output lives at `extension/wasm/`. Built from +`crates/relicario-wasm` (see project-root `CLAUDE.md`). The exported +surface — `unlock`, `lock`, `manifest_encrypt/decrypt`, `item_encrypt/decrypt`, +`settings_encrypt/decrypt`, `attachment_encrypt/decrypt`, +`embed_image_secret`, `extract_image_secret`, `totp_compute`, the +generators, `rate_passphrase`, `generate_device_keypair`, and the opaque +`SessionHandle` class — is enumerated in +`extension/wasm/relicario_wasm.d.ts`. Two patterns matter: + +1. The SW initializes via `initSync(new WebAssembly.Module(bytes))` when + running as a real service worker (no top-level await), and the + default async `initDefault(url)` path otherwise (jest-style harness or + fallback). See `service-worker/index.ts:24-35`. +2. Setup uses `import(/* webpackIgnore: true */ '../relicario_wasm.js')` + so webpack doesn't try to inline the runtime — it's served as a flat + sibling file (`setup.ts:30-33`). + +## Module map + +### `src/popup/` + +- `popup.ts` — entry. Owns the popup state machine (`View` enum: + `locked | list | detail | add | edit | settings | settings-vault | trash + | devices | field-history`), captures the active tab at popup-open for + TOCTOU-safe fill (`popup.ts:230-233`), translates cryptic backend errors + to user-readable strings (`humanizeError`, `popup.ts:135-160`), and + registers itself as the shared `StateHost`. +- `index.html` / `styles.css` — markup + dark monospace theme. + +### `src/popup/components/` + +The popup UI. Each module exports a `renderXxx(app: HTMLElement)` and, +where it owns disposable resources (timers, DOM listeners), a +`teardown()` that the dispatcher in `popup.ts` and `vault.ts` calls +before any new render. + +- `unlock.ts` — passphrase input + Enter-to-submit. Calls `unlock` SW + message; on success, fetches `list_items` and navigates to `list`. +- `item-list.ts` — toolbar (search/new/sync/lock/settings) + virtualized-ish + row list. Owns the keyboard navigation handler (`/`, `+`, arrow keys, + Enter, Esc) and the settings-picker popover that splits "device + settings" from "vault settings". +- `item-detail.ts` / `item-form.ts` — type dispatchers; each delegates to + one of `components/types/{login,secure-note,identity,card,key,document,totp}.ts`. +- `components/types/*.ts` — per-item-type detail+form pairs. Each exports + `renderDetail`, `renderForm`, and `teardown`. Uses the shared `fields.ts` + primitives (concealed rows, signature blocks, sections editor) and the + `attachments-disclosure.ts` widget. +- `fields.ts` — pure HTML-string primitives (`renderRow`, + `renderConcealedRow`, `renderSignatureBlock`, `renderSections*`) + consumed by every type. Mounting is the caller's job; after mount, + `wireFieldHandlers(scope)` binds the reveal/copy click handlers once. +- `generator-panel.ts` — inline password / passphrase generator. Mounts + inside any host element; round-trips knob changes through the SW's + `generate_password` / `generate_passphrase` (debounced 150ms). Has two + action-row modes: fill-field (cancel + use) and configure-defaults + (save-as-default). +- `attachments-disclosure.ts` — the per-item attachment list (edit/view + modes). Image-MIME rows lazy-load thumbnails as object URLs; teardown + revokes them. Per-item-count and per-vault soft/hard size caps are + enforced here client-side; the SW also enforces per-attachment max + bytes via WASM (defense in depth — see + `router/popup-only.ts:223-228`). +- `settings.ts` — device-local UX settings (capture toggle, prompt + style), trash/devices/sync-now buttons, blacklist editor. +- `settings-vault.ts` — vault-wide settings (retention, generator + defaults, autofill origin acks). Reads/writes via the SW's + `get_vault_settings` / `update_vault_settings`. +- `trash.ts` — soft-delete listing with restore + purge buttons. +- `devices.ts` — device list with revoke. Inline "register this device" + flow lives here (banner shown when current device is not in the list); + see commit `a7dbf35`. +- `field-history.ts` — audit-log of value changes on a single item; + driven by the SW's `get_field_history` which calls into WASM + `get_field_history(item_json)`. + +### `src/vault/` + +- `vault.ts` — fullscreen tab entry. Hash-based router (`#detail/<id>`, + `#add/<type>`, `#trash`, `#devices`, `#settings`, `#settings-vault`, + `#field-history`). Registers itself as the StateHost so all + `popup/components/*` renderers run unchanged. Maintains its own + `selectedItem` cache so hash navigation between already-loaded items + doesn't refetch. +- `vault.html` / `vault.css` — sidebar + pane layout. + +### `src/setup/` + +- `setup.ts` (1137 lines) — the wizard state machine. Six steps + (0..5): mode picker (new vault / attach this device), host type + (Gitea/GitHub), host config + connection test + repo probe, the + forking step 3 (create-vault vs attach-this-device), device name, + finish. Loads WASM directly. State-coupled `updateStrengthUi` stays + here because it walks the live wizard state. +- `setup-helpers.ts` (84 lines, extracted in commit `f79a67b`) — pure + helpers: `escapeHtml`, `ratePassphrase`, `scheduleRate` (150ms + debounced zxcvbn round-trip), `STRENGTH_LABELS`, `entropyText`, the + `Strength` interface. +- `probe.ts` — best-effort detection of an existing vault on the remote + (any of `.relicario/salt`, `.relicario/params.json`, or `manifest.enc` + → `exists: true`). Drives the warning banner that disambiguates "new + vault" vs "attach this device". + +### `src/content/` + +- `detector.ts` — entry. Finds password fields (skipping <20×10px + honeypots), associates each with a username field via a five-priority + cascade (autocomplete=username → autocomplete=email → type=email → + name/id pattern → preceding visible text input), injects the + `id`-icon, and starts a MutationObserver to rescan on SPA navigation. +- `icon.ts` — the in-page autofill icon and candidate picker / + TOFU-ack hint. Each overlay mounts in its own closed Shadow DOM + (`shadow.ts`). On icon click → `get_autofill_candidates`; one + candidate auto-fills (if origin is acked), multiple candidates show + the picker. +- `fill.ts` — listener for the SW-forwarded `fill_credentials` message. + Re-checks `location.href`'s hostname against the SW-provided + `expectedHost` (the second of two TOCTOU gates) and writes values + using the native HTMLInputElement setter trick so React/Vue pick up + the change. +- `capture.ts` — submit handler. Runs `check_credential` to ask whether + the (host, username, password) tuple is already in the vault; if not, + shows a save-or-update prompt in a closed Shadow DOM. The "Save" + button issues `capture_save_login` (content-callable); the SW figures + out add-vs-update and binds the new item to the sender's origin. +- `shadow.ts` — closed-mode `attachShadow` host helper. Comments here + enforce the "never innerHTML, never insertAdjacentHTML" rule — + page-supplied strings (hostname, username) only ever land via + `textContent`. + +### `src/service-worker/` + +- `index.ts` — thin entry. Wires the WASM init, owns the shared + `RouterState`, plumbs `chrome.runtime.onMessage` and + `chrome.commands.onCommand` (the `open-vault` keyboard command), + resets the inactivity timer on every popup-class message, and + broadcasts a `session_expired` notification when the timer fires. +- `router/index.ts` — single classify-and-dispatch function. Determines + whether a sender is popup/vault tab, setup tab, content top-frame, or + none-of-the-above (`router/index.ts:39-43`); routes to + `popup-only.ts` or `content-callable.ts`; rejects everything else + with `unauthorized_sender`. Setup tab is allowed exactly three + popup-only messages (`SETUP_ALLOWED`, `router/index.ts:23-27`): + `save_setup`, `rate_passphrase`, `is_unlocked`. +- `router/popup-only.ts` — handler match arms for every + `POPUP_ONLY_TYPES` message. The mutation-heavy ones (`add_item`, + `update_item`, `delete_item`) pull `SessionHandle` from + `session.getCurrent()`, load via `vault.fetchAndDecrypt*`, mutate, + re-encrypt, and `gitHost.writeFile`. `fill_credentials` lives here + with its own captured-tab verification (see Key flows). New in + commit `a7dbf35`: `register_this_device`. +- `router/content-callable.ts` — handler match arms for every + `CONTENT_CALLABLE_TYPES` message. Origin always derived from + `sender.tab.url`, never from message fields. `capture_save_login` + has a defense-in-depth check that the existing item's `core.url` + hostname matches the sender's hostname before mutating, in case + manifest `icon_hint` has drifted from the underlying URL. +- `vault.ts` — typed-item vault operations. Crypto goes through the + ambient `wasm` module set at SW init by `setWasm`; nothing here + touches the master key directly. Includes + `findByHostname(manifest, hostname)` (the autofill matcher — coarse: + no www-stripping, no public-suffix), trash helpers + (`listTrashed`, `restoreItem`, `purgeItem`, `purgeAllTrash`), and + attachment helpers (`addAttachmentToItem`, `removeAttachmentsFromItem`, + with manifest summary sync). +- `session.ts` — single module-scope `SessionHandle | null`. α assumes + one vault per install. Multi-vault would replace this with a `Map` + keyed by vault id. +- `session-timer.ts` — inactivity timer. Modes: `inactivity` (N + minutes since last popup-class message) and `every_time` (no timer; + rely on popup-close to clear). The router resets the timer for every + message that is NOT in `CONTENT_CALLABLE_TYPES` + (`service-worker/index.ts:76-78`). +- `git-host.ts` — abstract interface (`readFile`, `writeFile`, + `writeFileCreateOnly`, `deleteFile`, `listDir`, `lastCommit`, + `putBlob`, `getBlob`, `deleteBlob`) and the `createGitHost` factory. + `BLOB_THRESHOLD_BYTES = 900*1024` is the cutover point at which + attachment writes switch from the Contents API to the Git Data API. +- `gitea.ts` / `github.ts` — the two GitHost implementations. Both use + the host's Contents API for files under threshold, and Git Data API + (blobs + tree + commit) for large attachment uploads. Auth differs + (Gitea: `token X`, GitHub: `Bearer X`). Both pre-check existence on + write to decide between create vs update; `writeFileCreateOnly` + refuses to clobber. +- `devices.ts` — read-modify-write helpers around + `.relicario/devices.json`. `addDevice` rejects duplicates by name; + `revokeDevice` rejects unknown names. + +### `src/shared/` + +- `messages.ts` — every `Request` and `Response` shape, plus the + capability sets `POPUP_ONLY_TYPES` and `CONTENT_CALLABLE_TYPES` the + router consults. Adding a new SW message requires (a) adding it to + the `PopupMessage` or `ContentMessage` union, AND (b) adding it to + the matching capability set, AND (c) adding a handler arm. Forget any + one of these and you get a silent rejection at runtime. +- `state.ts` — `StateHost` interface + module-scope singleton. Both + `popup.ts` and `vault.ts` register themselves on boot. All + `popup/components/*` import from here, never from popup.ts directly, + so the same render code runs in both bundles. +- `types.ts` — TypeScript mirrors of the Rust core's serde shapes: + `Item`, `ItemCore` (internally-tagged on `type`), `Field` and + `FieldValue` (adjacently-tagged on `kind` / `value`), `Manifest`, + `ManifestEntry`, `VaultSettings`, `GeneratorRequest`, etc. Hand-kept + in sync with `crates/relicario-core/src/{item.rs,item_types/,settings.rs}`. +- `base32.ts` — RFC 4648 base32 encode/decode for TOTP secrets. (Pure + TS; secrets never leave WASM after unlock anyway, but we store user + input as bytes via `base32Decode`.) + +## Invariants & contracts + +These are load-bearing rules. Some are enforced by code, some are +enforced by code-review and convention; both are listed. + +- **Master key never crosses the WASM boundary.** It lives inside WASM + linear memory wrapped in `Zeroizing<[u8;32]>` (Rust side); JS holds + only the opaque `SessionHandle` (a `u32` index). `wasm.lock(handle)` + zeroes the slot; `session.clearCurrent()` calls it + (`session.ts:24-28`). No popup, vault, content, or setup code can + observe the key bytes. +- **Single SessionHandle per SW instance.** `session.ts` is module-scope. + α assumes one vault per install (deliberate; not an oversight). +- **Sender check on every SW message.** `router/index.ts:39-66` builds + `isPopup | isSetup | isContent` from `sender.url` and `sender.tab` / + `sender.frameId` / `sender.id`, then dispatches: + - popup-only types accept `popup.html` OR `vault.html` senders + (commit `a7dbf35` added `vault.html`). + - popup-only types ALSO accept `setup.html` for exactly three + messages: `save_setup`, `rate_passphrase`, `is_unlocked` + (`router/index.ts:23-27`). + - content-callable types require `sender.tab` defined, + `sender.frameId === 0` (top frame), AND + `sender.id === chrome.runtime.id` (same extension — + `router.test.ts:373-384` covers the third clause). Subframes and + other extensions are rejected. + - everything else: `unauthorized_sender`. +- **Capability sets are exhaustive.** Every message must appear in + exactly one of `POPUP_ONLY_TYPES` or `CONTENT_CALLABLE_TYPES` + (`shared/messages.ts:144-161`). A message in the union but in + neither set falls through to `unknown_message_type` and is silently + rejected. This is the easy mistake to make when adding a new + message type. +- **Content scripts cannot decrypt.** All paths from content end with + the SW returning either an opaque manifest projection (titles, + hostnames) or a fully-resolved `{ username, password }`. There is no + WASM in the content bundle and no pathway for content to obtain + ciphertext. +- **Origin TOFU on autofill.** Before returning credentials to a + content script, the SW checks + `VaultSettings.autofill_origin_acks[hostname]` + (`router/content-callable.ts:46-51`). Missing → return + `{ requires_ack: true, hostname }` so the icon shows the TOFU hint + and the user must open the popup to ack. The ack is recorded in + vault settings (encrypted, syncs across devices), keyed by hostname, + to a unix timestamp. +- **Two-stage TOCTOU close on `fill_credentials`.** The popup snapshots + `(capturedTabId, capturedUrl)` at popup-open (`popup.ts:230-233`). + The SW re-fetches the tab on fill, compares hostnames against the + snapshot AND against the item's own `core.url` hostname + (`router/popup-only.ts:397-410`), and forwards `expectedHost` along + with the credentials. The content script's fill listener + (`content/fill.ts:32-43`) re-checks `location.href`'s hostname + against `expectedHost` before typing — covering the gap between + `chrome.tabs.get` and `chrome.tabs.sendMessage`. +- **Origin binding on capture.** `capture_save_login` derives the + hostname from `sender.tab.url` only — never from message fields. + When updating an existing entry, the SW re-checks the entry's + `core.url` hostname against the sender's hostname; mismatch → + `origin_mismatch` (`router/content-callable.ts:113-117`). Otherwise a + drifted manifest `icon_hint` could rebind a password to the wrong + origin. +- **`writeFileCreateOnly` cannot clobber.** Setup uses it for the four + init artifacts (`.relicario/salt`, `.relicario/params.json`, + `manifest.enc`, `settings.enc`). If any exists, it throws — the + wizard catches and tells the user to switch to attach mode + (`setup.ts:888-893`). +- **AEAD failure surfaces as "wrong passphrase".** The setup attach + flow stages errors and rewrites failures during `derive session + handle` or `decrypt manifest` to the deliberately-ambiguous + "Could not decrypt vault — wrong passphrase or reference image." + (`setup.ts:396-401`). The popup `humanizeError` does the same for + `vault_locked`, `origin_mismatch`, `unauthorized_sender`, and + URL parse errors. +- **Inactivity timer modes.** `inactivity` resets on every + popup/vault/setup message (NOT on content messages — + `service-worker/index.ts:76-78`); fires after `minutes` of idle. + `every_time` has no timer; the popup-close handler is expected to + clear (handled implicitly because the popup re-checks `is_unlocked` + on each open). +- **Manifest mutation requires both writes.** Any item-changing handler + (`add_item`, `update_item`, `delete_item`, `restore_item`, + `purge_item`, `capture_save_login`, the attachment paths) writes + BOTH `items/<id>.enc` AND `manifest.enc` (the manifest entry is + derived via the local `itemToManifestEntry`). Forgetting the second + write breaks list/search/autofill until the next sync round-trip. +- **Both manifests stay in sync.** `manifest.json` (Chrome) and + `manifest.firefox.json` declare the same permissions, host + permissions, content scripts, and CSP. Drift is a portability bug. + +## Key flows + +### First-run setup (new vault) + +`setup.ts`, six steps. WASM is loaded at the top of step 3. + +1. **Step 0** — mode picker. `state.mode` ∈ `{ 'new', 'attach' }`. +2. **Step 1** — host type (Gitea / GitHub) + per-host instructions. +3. **Step 2** — host URL + repo path + API token. Click "test + connection" → `gitHost.listDir('')` succeeds → `probeVault(host)` + detects existing vault. Banner disambiguates: empty repo + new + mode = OK; populated repo + new mode = warn (would clobber); + empty repo + attach mode = warn (no vault to attach to). +4. **Step 3 (new branch)** — carrier JPEG + passphrase + confirm. + zxcvbn meter via SW `rate_passphrase` on a 150ms debounce + (`setup-helpers.ts:54-63`). Submit gate requires score ≥ 3 AND + passphrases match. + 1. `crypto.getRandomValues(imageSecret)` — fresh 32-byte secret. + 2. `wasm.embed_image_secret(carrierBytes, imageSecret)` → reference + JPEG bytes (DCT-embedded via central-embed; see core spec). + 3. `crypto.getRandomValues(salt)` — fresh 32-byte vault salt. + 4. `wasm.unlock(passphrase, referenceJpeg, salt, paramsJson)` — + Argon2id derives master key inside WASM; returns `SessionHandle`. + Note: `unlock` takes JPEG bytes, not the raw 32-byte secret — + the WASM side extracts internally. + 5. Encrypt empty manifest + default settings. `writeFileCreateOnly` + pushes salt, params, manifest.enc, settings.enc — refuses to + clobber. + 6. `wasm.lock(handle)` — release. Advance to step 4. +5. **Step 3 (attach branch)** — reference JPEG + passphrase. Fetches + salt + params + ciphertext, runs `wasm.unlock` and + `wasm.manifest_decrypt`. AEAD failure → "wrong passphrase or + reference image". Success → save handle in + `state.verifiedHandle`, advance. +6. **Step 4** — device name (default `${browser} on ${os}`). +7. **Step 5** — finish. If `chrome.runtime.sendMessage` reaches the + extension, "register this device" pushes everything in one go + (`setup.ts:1039-1112`): + 1. `wasm.generate_device_keypair()` → `{ public_key_hex, + private_key_base64 }`. + 2. `chrome.storage.local.set({ device_name, device_private_key })`. + 3. `save_setup` SW message → `chrome.storage.local.set({ vaultConfig, + imageBase64 })`. + 4. `addDevice(host, ...)` → read-modify-write + `.relicario/devices.json`. + 5. `wasm.lock(verifiedHandle)` — release the attach-mode handle. + If the extension is NOT detected, the wizard offers to download the + reference JPEG and copy a JSON config blob to paste into the + extension manually. + +### Unlock from popup + +1. Popup opens → `chrome.tabs.query` snapshots active tab into + `state.capturedTabId` / `state.capturedUrl` (`popup.ts:231-233`). + Used later by `fill_credentials`. +2. `get_setup_state` → if not configured, opens setup tab and closes + popup. +3. `is_unlocked` → if unlocked, `list_items` + `get_vault_settings`, + navigate to `list`. Otherwise, navigate to `locked`. +4. User types passphrase → `unlock` SW message + (`router/popup-only.ts:38-55`): + 1. Load `vaultConfig` + `imageBase64` from `chrome.storage.local`. + 2. `createGitHost` if not already present. + 3. `gitHost.readFile('.relicario/salt')` + `params.json` (cached on + `state.gitHost` for the SW lifetime). + 4. `wasm.unlock(passphrase, imageBytes, salt, paramsJson)` → + `SessionHandle`. + 5. Wipe `msg.passphrase` (best-effort — JS strings are immutable, but + we drop the reference). + 6. `fetchAndDecryptManifest` and cache on `state.manifest`. + +### Item create from popup + +1. Form component (`components/types/login.ts` etc.) collects fields + and emits `add_item` with the full Item. +2. `router/popup-only.ts:74-83`: + 1. `wasm.new_item_id()` — 16-char hex. + 2. `wasm.item_encrypt(handle, JSON.stringify(item))` → + ciphertext. + 3. `gitHost.writeFile('items/<id>.enc', ciphertext, "add: <title>")`. + 4. Update `state.manifest.items[id]`; re-encrypt + write + `manifest.enc`. +3. Popup re-renders list with the new entry. + +### Autofill (content-script flow) + +1. `detector.ts` finds password fields, `icon.ts` injects an icon + inside a closed Shadow DOM near each. +2. User clicks icon → `get_autofill_candidates` (content-callable, no + `url` field — router derives hostname from `sender.tab.url`). +3. SW: `vault.findByHostname(manifest, senderHost)` matches + `manifest.items[i].icon_hint === hostname.toLowerCase()` (note: no + www-stripping, no PSL — coarse on purpose for α). +4. One candidate → content calls `get_credentials`. SW resolves origin + match (`router/content-callable.ts:42-44`) and TOFU + (`router/content-callable.ts:46-51`). + - First time on this hostname → `{ requires_ack: true, hostname }`. + `icon.ts` shows the in-page hint instructing the user to open + relicario; user opens popup, picks the item, and the SW path that + writes the credential calls `ack_autofill_origin`. + - Acked → `{ username, password }`. `fill.ts.fillFields` types + directly without a SW round-trip (content script IS the page + origin; no need to go through the SW just to write to its own + DOM). This is the only flow where credentials reach the page, + and the request was originated by the user via the icon click. +5. Multiple candidates → picker (also closed Shadow DOM). + Selection → same `get_credentials` path. + +### Capture-save-login + +1. `capture.ts` hooks `<form>` submit and any submit-shaped button. +2. On submit: `findUsernameValue(pwField)` + `password` → + `check_credential` (content-callable). SW returns one of: + `skip` (already match), `save` (no match), or + `update` (same username, different password). +3. If not skip, `capture.ts` shows a save-or-update prompt in a closed + Shadow DOM. Settings (capture style: bar/toast) fetched directly + from `chrome.storage.local` to avoid round-tripping through the SW + (which would also fail the router's content→popup-only check for + `get_settings`). +4. "Save" → `capture_save_login`. SW (`router/content-callable.ts:99-163`): + - Update path: existing `(host, username)` match → defense-in-depth + check that the item's `core.url` hostname matches sender hostname + → re-encrypt only the password + modified, push. + - Add path: build a new Login bound to the sender's origin + (`title = senderHost`, `core.url = senderOrigin`), encrypt + push, + update manifest. +5. "Never" → `blacklist_site`. SW pushes hostname into + `chrome.storage.local.captureBlacklist`. Future submits on this + host short-circuit at step 2. + +### Sync (manual, post-`a7dbf35`) + +1. Settings view → "Sync now" (`components/settings.ts:83-92`) or + item-list toolbar "sync" (`item-list.ts:103-117`). +2. `sync` SW message → `vault.fetchAndDecryptManifest` re-pulls + `manifest.enc` from the host and re-decrypts. No git-side push or + merge — git host is the source of truth, and writes are immediate. + Sync is essentially "refresh the in-memory manifest cache". +3. Status text on the popup updates to "synced ✓" or + "sync failed: <error>". + +### Device register from popup (post-`a7dbf35`) + +1. Devices view detects `chrome.storage.local.device_name` is missing + from the remote device list → shows banner. +2. User clicks "Register this device" → inline name input + (`devices.ts:81-119`). +3. On confirm → `register_this_device` SW message + (`router/popup-only.ts:313-329`): + 1. `wasm.generate_device_keypair()` → + `{ public_key_hex, private_key_base64 }`. + 2. `chrome.storage.local.set({ device_name, device_private_key })`. + 3. `devices.addDevice(host, ...)` → read-modify-write + `.relicario/devices.json`. +4. Devices view re-renders; banner gone. + +### Session lock (timer-driven) + +1. `service-worker/index.ts:51-58` registers `onExpired` callback at + SW boot. +2. Every popup-class message resets the timer (every content-callable + message does NOT — page-side traffic shouldn't keep the vault + unlocked; `service-worker/index.ts:76-78`). +3. After the configured idle window: callback fires → + `session.clearCurrent()` (zeroes WASM key) → `state.manifest = null` + → broadcast `{ type: 'session_expired' }`. +4. Popup and vault tab listen for that broadcast and snap back to the + locked view (`popup.ts:299-307`, `vault.ts:521-531`). + +### Trash + purge + +1. `delete_item` is a soft-delete: the item gets a `trashed_at` and is + re-encrypted; the manifest entry mirrors that. List views filter + `trashed_at !== undefined`. +2. `list_trashed` returns trashed entries sorted newest-first. +3. `restore_item` clears `trashed_at` and bumps `modified`. +4. `purge_item` deletes the encrypted item + every attachment blob in + its `attachment_summaries`, removes the manifest entry, and rewrites + `manifest.enc`. +5. `purge_all_trash` purges every trashed item AND scans + `attachments/` for orphan blobs (not referenced by any remaining + manifest entry) and deletes them. Returns + `{ itemCount, orphanCount }`. + +## Cross-cutting concerns + +### State sharing across bundles + +`shared/state.ts` is a service-locator for the popup component layer. +It defines a `StateHost` interface (`getState`, `setState`, `navigate`, +`sendMessage`, `escapeHtml`, `popOutToTab`, `isInTab`, `openVaultTab`) +and a single module-scope `host` slot. `popup.ts` and `vault.ts` each +call `registerHost({...})` at boot with their own implementations of +those methods. The `popup/components/*` files only know the locator; +they never import from `popup.ts` or `vault.ts`. + +This is why every component renderer takes `app: HTMLElement`: the +host gives the component the mount point, and the locator gives the +component everything else (current state, message channel, navigation). +The same `renderItemDetail` runs unchanged in the 360px popup and the +fullscreen vault tab — the host's `getState()` projects different state +shapes that happen to share field names. + +### Error surface + +All SW handlers return `{ ok: true, data?: ... } | { ok: false, error: string }`. +Conventions: + +- Vault-state errors (`vault_locked`, `item_not_found`, `not_a_login`, + `no_totp`, `attachment_not_found`) are bare snake_case strings the + popup can pattern-match in `humanizeError` (`popup.ts:135-160`). +- Origin / sender errors (`origin_mismatch`, `tab_navigated`, + `captured_tab_gone`, `unauthorized_sender`, `origin_changed`) are + also bare strings; they're the security-sensitive ones and must + remain testable by handler-level tests + (`router.test.ts:237-285`). +- Crypto failures bubble up as Rust error strings via wasm-bindgen. + AEAD authentication failures are deliberately conflated with + "wrong passphrase" (no oracle for "right passphrase, wrong image"). +- Network / git-host failures bubble up as native `Error` instances + that the SW catches in `service-worker/index.ts:93-97` and flattens + to `{ ok: false, error: err.message }`. + +### TS ↔ Rust type sync + +`shared/types.ts` mirrors the Rust core's serde shapes. Internally-tagged +enums (`ItemCore`) match `#[serde(tag = "type")]`; adjacently-tagged +enums (`FieldValue`) match `#[serde(tag = "kind", content = "value")]`. +Optional fields use `?` because Rust's +`#[serde(skip_serializing_if = "Option::is_none")]` omits them and +`serde_wasm_bindgen` produces `undefined`. `r#type` Rust → `type` JSON +key. The mirror is hand-kept; if a Rust field changes, the TS shape +must be updated explicitly. Drift = silent runtime crash on first +encounter with a value the TS type says is impossible. + +### Storage layout + +**Local** (`chrome.storage.local`): + +| Key | Set by | Holds | +| -------------------- | ------------------- | ----------------------------------------------- | +| `vaultConfig` | setup `save_setup` | `{ hostType, hostUrl, repoPath, apiToken }` | +| `imageBase64` | setup `save_setup` | reference JPEG bytes (base64). Re-read on every unlock. | +| `device_name` | setup / register | This device's name (must match a remote device record) | +| `device_private_key` | setup / register | base64 ed25519 private key. **Highest-value device-local secret.** | +| `relicarioSettings` | popup settings | `DeviceSettings` (capture toggle + style) | +| `captureBlacklist` | content `blacklist_site` / popup `remove_blacklist` | `string[]` of hostnames | +| `session_timeout` | popup `update_session_config` | `SessionTimeoutConfig` — restored on SW boot | + +**Remote** (the git repo): + +- `.relicario/salt` — 32-byte vault salt (KDF input). +- `.relicario/params.json` — Argon2id parameters (`m`, `t`, `p`). +- `.relicario/devices.json` — `{ devices: Device[] }`. +- `manifest.enc` — XChaCha20-Poly1305 ciphertext of the manifest. +- `items/<id>.enc` — per-item ciphertext. +- `attachments/<aid>.bin` — content-addressed encrypted attachment + blobs. +- `settings.enc` — vault settings (retention + caps + generator + defaults + `autofill_origin_acks`) ciphertext. + +The remote is end-to-end encrypted; the host (Gitea/GitHub) sees only +opaque ciphertext. `chrome.storage.local` is NOT encrypted, so +`device_private_key` is the user's "this device" credential — losing +the local profile means revoking the device server-side and creating a +new keypair, but a non-zero local-attacker model. Documented in the +design spec. + +### Two GitHosts + +`gitea.ts` and `github.ts` implement the `GitHost` interface +(`git-host.ts:7-44`). They diverge on: + +- Auth header (`token X` vs `Bearer X`). +- Read response shape (both base64-content; GitHub adds `\n` line + breaks the Gitea endpoint sometimes also adds — both implementations + strip). +- Update semantics (Gitea has separate POST-create / PUT-update; + GitHub's PUT is create-or-update, so the SHA presence is what + decides). +- Large-blob path. Both switch from Contents API to Git Data API + above `BLOB_THRESHOLD_BYTES`; the API shapes differ but both + produce a commit on the default branch. + +Adding a third host (Codeberg, Gitlab) = implement `GitHost`, add a +case to `createGitHost` (`git-host.ts:74-84`), and surface the option +in `setup.ts` step 1. + +## Test architecture + +Tests run under `vitest` with `happy-dom` +(`extension/vitest.config.ts`). There is no real browser in CI; the +tests cover logic that is browser-API-shaped but doesn't actually +touch a real Chrome. + +Patterns: + +- **`globalThis.chrome` shim** at the top of each test + (`router/__tests__/router.test.ts:36-45`). Stubs only what the + test needs: `chrome.runtime.id`, `chrome.runtime.getURL`, + `chrome.storage.local.{get,set}`, `chrome.tabs.{get,sendMessage}`. +- **Module mocks via `vi.mock`** for the SW's `vault` and `session` + modules (`router/__tests__/router.test.ts:10-27`) so router tests + don't pull in WASM. The `vi.mock(..., importOriginal)` form keeps + the real `findByHostname`/`listItems` while overriding the + encrypt/decrypt boundary. +- **Component tests** (`popup/components/__tests__/*.test.ts`) mock + `shared/state` so `sendMessage` / `navigate` / etc. become + spies, and assert that the rendered DOM has the right shape and + that user actions emit the right SW messages. + +Coverage highlights: + +- `service-worker/router/__tests__/router.test.ts` — exhaustive sender + matrix: each popup-only and content-callable type tested from + popup, vault tab, setup tab, top-frame content, and an + "external"/wrong-extension-id sender. The vault-tab-as-popup + acceptance was added in commit `a7dbf35`. Setup-tab exception + scope (`save_setup`, `rate_passphrase`, `is_unlocked` allowed; + `unlock`, `fill_credentials` rejected) verified explicitly. Also + covers the `fill_credentials` TOCTOU verification, capture + add/update/origin-mismatch paths, get_totp on both Login.totp and + standalone Totp.config, and vault-settings get/set. +- `service-worker/__tests__/devices.test.ts` — devices.json + read/modify/write semantics (add/revoke). +- `service-worker/__tests__/git-host*.test.ts` — Contents API vs + Git Data API switching, SHA-on-update behavior. +- `service-worker/__tests__/session-timer.test.ts` — `inactivity` + vs `every_time` modes; reset/stop semantics. +- `service-worker/__tests__/trash.test.ts` — soft-delete, restore, + purge, orphan-blob cleanup. +- `popup/components/__tests__/devices.test.ts` — devices view including + the new register-this-device inline flow. +- `popup/components/__tests__/settings.test.ts` — sync button + + feedback (added in commit `a7dbf35`). +- `popup/components/__tests__/{attachments-disclosure,field-history, + fields,generator-panel,sections-{editor,render},settings-vault,trash}.test.ts` + — per-component coverage. +- `popup/components/types/__tests__/*.save.test.ts` — each item type's + form-to-Item serialization. +- `setup/__tests__/probe.test.ts` — vault-detection probe. +- `shared/__tests__/base32.test.ts` — RFC 4648 vectors. + +**Test-vs-build gap**: tests run with happy-dom and stub crypto. +Browser-API semantics that depend on a real engine — service-worker +restart behavior, real `chrome.tabs.sendMessage` delivery timing, +`chrome.runtime.lastError` paths, MV3 cold-start bundle execution — +are NOT exercised. Treat tests as a logic-bug net, not a +browser-bug net; manual smoke-testing in both Chrome and Firefox is +still required before shipping. + +## Gotchas & non-obvious decisions + +- **Why the popup never loads WASM directly.** Crypto in one place + (the SW) means one set of bundle-size and CSP concerns. The popup + message round-trips are cheap enough; the architectural win is + worth more than the latency. +- **Why setup loads WASM directly anyway.** Setup needs to derive a + master key, encrypt an empty manifest, and push it to the remote + BEFORE `chrome.storage.local.vaultConfig` exists for the SW to read. + There's no `SessionHandle` to pass to the SW yet, and the SW's + `unlock` handler reads config from local storage — chicken-and-egg. + Setup's WASM module is independent of the SW's; both share the same + bytes but each has its own linear memory. +- **Why `vault.html` is treated as popup-class.** The audit flagged + that fullscreen workflows (settings-vault editor, future + backup/restore, future LastPass importer, devices) need more space + than the popup gives. Rather than introducing a third class of + sender, the router was extended to accept `vault.html` as a + popup-equivalent — the message vocabulary is identical, just the + surface is bigger. Commit `a7dbf35`. +- **Why setup.ts is huge but not split per-step.** A previous audit + recommended one-module-per-step; that risked introducing flow bugs + in a hand-tested wizard. Instead, only the pure helpers (no wizard + state) were extracted (`setup-helpers.ts`, commit `f79a67b`). The + step renderers and their event handlers stay inline because they + share `state` heavily and re-render on almost every input. +- **Why every "view" is just a render-into-`#app` function.** No + framework. The popup is small enough that a 50-line state machine + in `popup.ts` plus per-view render functions is shorter and faster + than React. The `StateHost` indirection lets the same components + render in the vault tab without changes — the price of "no + framework" is paid by `shared/state.ts`, which is 62 lines. +- **Why the SW caches `manifest` and `gitHost` in module memory.** + Service workers in MV3 are restartable but persistent during + activity; caching avoids re-decrypting the manifest on every + popup-open (which is constant) and re-fetching `salt` + `params` + on every unlock would be wasteful. On `lock`, `state.manifest` is + cleared (`router/popup-only.ts:60`) and on `session_expired` too + (`service-worker/index.ts:55-56`). +- **Why content scripts have direct `chrome.storage.local` access.** + The `storage` permission applies to all extension contexts. Content + uses it for capture style settings (`capture.ts:101-103`) because + routing through the SW would fail the router's + content→popup-only check for `get_settings`, and adding a + content-callable variant would expand the attack surface. +- **Why `device_private_key` lives in `chrome.storage.local` even + though it's a long-term secret.** The "device" IS the local + machine; the user is implicitly trusting whatever can read + `chrome.storage.local` (the same threat model as the SW's session + state). Promoting the key into the SW's WASM linear memory + wouldn't help — a local attacker capable of reading + `chrome.storage.local` is also capable of attaching a debugger to + the SW. The correct mitigation is OS-level (full-disk encryption) + and remote-side (revoke on loss). +- **Why `capture_save_login` is a single message with internal + add-vs-update branching.** Two messages (`capture_add` / + `capture_update`) would let a malicious page guess which one was + expected and craft a request to mutate an existing entry's password + on a sibling host. Funneling through one handler that derives + origin server-side and chooses the path itself eliminates that + class of bug. +- **Why `findByHostname` is intentionally coarse.** No + www.-stripping, no public-suffix matching: in α, `github.com` and + `www.github.com` saved logins are independent. Smarter matching + has UX failure modes (filling subdomain credentials cross-site) + that need design before code; tracked for 1C-β/γ. See + `service-worker/vault.ts:127-142`. +- **Why the inactivity timer ignores content-callable messages.** + A page making periodic background fetches (e.g. SSE, polling) + shouldn't keep the vault unlocked indefinitely. Only popup/vault + tab activity counts as "user is at the keyboard" + (`service-worker/index.ts:76-78`). +- **Why `is_unlocked` is in the setup-tab allowlist.** Setup's + step-5 detects whether the extension is reachable; pinging + `is_unlocked` is the cheapest available probe, and the response + is non-sensitive (a boolean). The two other allowed messages + (`save_setup`, `rate_passphrase`) are unavoidable. +- **Why fill goes through the SW for the credential resolution but + the actual DOM write happens in content.** The SW knows which + hostname the active tab is on and can match the right item; but + once the credentials are resolved and bound to `expectedHost`, + the content script is the only context with DOM access. The SW + could `chrome.tabs.executeScript` to inject a one-shot writer, + but that doubles the attack surface for no benefit — the + content script already has DOM access by the time the page is + loaded. +- **Why setup uses `webpackIgnore` to load WASM.** Webpack would + otherwise try to chunk-split or inline `relicario_wasm.js`, breaking + the wasm-pack runtime expectation that it lives at a stable URL + next to `relicario_wasm_bg.wasm`. The runtime calls + `WebAssembly.instantiateStreaming(fetch(URL))` against a + hardcoded path; we just hand it that path.