docs(arch): per-codebase ARCHITECTURE.md + cross-codebase overview

Strategic-depth architecture documentation, the kind that's hard to
recover by reading code: invariants, multi-file flows, design rationale,
gotchas. Goal is to cut the token cost for future Claude sessions.

Four new docs (2091 lines total):

- crates/relicario-core/ARCHITECTURE.md (514 lines) — bytes-in/bytes-out
  boundary, 24 verified invariants (VERSION_BYTE=0x02, length-prefixed
  KDF input, NFC normalization, content-addressed AttachmentId, history-
  tracked field kinds, 60% imgsecret confidence floor, MAX_DIMENSION=
  10000, etc.), 7 multi-module flows, 16 non-obvious gotchas (QUANT_STEP=
  50, central-70%-embed, BIP39-128bit-then-truncate, Steam alphabet
  rationale).

- crates/relicario-cli/ARCHITECTURE.md (539 lines) — module map for the
  three source files; the cmd_add/cmd_edit per-type helper pattern (post-
  2026-04-27 refactor); the hardened-git invariant (Command::new("git")
  is gated to helpers.rs:46); the five history synthetic keys; the env-
  var escape-hatch policy; cmd_generate's two-mode design (no-unlock
  outside vault, unlock-and-read-defaults inside).

- extension/ARCHITECTURE.md (831 lines) — five-bundle structure (popup,
  vault, setup, content, service-worker); SW-as-crypto-fortress model;
  capability-set-or-silent-rejection contract; vault-tab-as-popup-class
  router parity (commit a7dbf35); origin TOFU flow; setup state machine;
  test-vs-build gap.

- docs/architecture/overview.md (207 lines) — cross-codebase entry point.
  How the three codebases fit together, the four versioned wire formats
  between them (core→WASM ABI, SW chrome.runtime protocol, vault on-disk
  layout, GitHost API), per-codebase secret residency table, build
  matrix, conventions that span all three.

Specs in docs/superpowers/specs/ remain as historical decision artifacts
("why we chose this") — the new arch docs are the source of truth for
"what is" current invariants and flows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-27 21:41:26 -04:00
parent b951741366
commit c66fd520f8
4 changed files with 2091 additions and 0 deletions

View File

@@ -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_<verb>`, and a layer of
per-type item helpers (`build_<type>_item` for `cmd_add`, `edit_<type>` 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 `<path>.tmp` then renames over `<path>`; 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 <paths> && git commit -m
<msg>` through the hardened wrapper. Commit message convention is
`<verb>: <title> (<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.

View File

@@ -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, &params)`
`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 617, `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.51 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 312 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`.

View File

@@ -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).

831
extension/ARCHITECTURE.md Normal file
View File

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