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