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

31 KiB
Raw Blame History

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.rsRelicarioError (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.rsItemId, 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.rsnow_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.rsItemType 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.rsLoginCore (username, password as Zeroizing<String>, optional Url, optional TotpConfig).
  • item_types/secure_note.rsSecureNoteCore (single Zeroizing<String> body).
  • item_types/identity.rsIdentityCore (full name, address, phone, email, DOB; all optional, none Zeroizing — they're personal data, not secret material).
  • item_types/card.rsCardCore plus CardKind (Credit/Debit/Gift/ Loyalty/Other). number, cvv, pin are Zeroizing; holder is plain String.
  • item_types/key.rsKeyCore: opaque Zeroizing<String> key_material with optional label / public key / algorithm. Used for SSH keys, GPG keys, arbitrary blobs.
  • item_types/document.rsDocumentCore: filename + mime + a single AttachmentId pointing at the primary blob. The body lives in the attachment store, not the item.
  • item_types/totp.rsTotpCore, 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.rsAttachmentRef (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.rsVaultSettings 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 Zeroizingcrypto::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=4crypto.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 AttachmentIds (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_itemdecrypt_item.
  • tests/format_v2.rsVERSION_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.