Files
relicario/crates/relicario-core/ARCHITECTURE.md
adlee-was-taken c66fd520f8 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>
2026-04-27 21:41:26 -04:00

515 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.