# Architecture: relicario-core

> **Audience:** contributors editing or extending `relicario-core`. This doc owns the module map for this crate, module-level invariants (e.g., no filesystem, no network), key flows at the module level, and the crate's test architecture. **Does NOT own:** crypto primitives or threat model (see [../../docs/CRYPTO.md](../../docs/CRYPTO.md), [../../docs/SECURITY.md](../../docs/SECURITY.md)), wire formats (see [../../docs/FORMATS.md](../../docs/FORMATS.md)).

## 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`.
- **`org.rs`** — Org-vault data model and ECIES key-wrapping layer
  (`crates/relicario-core/src/org.rs`). Types: `OrgId` (L15), `MemberId`
  (L19; `is_valid` L41 — 16 lowercase hex), `OrgRole` (L54;
  `can_manage_members` L61 = Owner | Admin, `can_manage_owners` L64 = Owner
  only), `OrgMember` (L72; carries `ed25519_pubkey` in OpenSSH wire format,
  `collections` grant list, `role`), `OrgMembers` (L86; `schema_version: 1`
  L93; `validate` L104), `CollectionDef` (L123), `OrgCollections` (L131;
  `schema_version: 1` L138; `validate` L145 rejects empty / `/` / `.` slugs),
  `OrgMeta` (L164; `schema_version: 1` L174), `OrgManifestEntry` (L185;
  carries `collection` slug plus id/type/title/tags/modified/trashed\_at),
  `OrgManifest` (L199; `schema_version: 1` L206; `filter_for_member` L210
  returns only entries whose collection slug appears in the member's grants).
  All four JSON containers carry `schema_version: 1` — distinct from the
  personal `Manifest` whose `MANIFEST_SCHEMA_VERSION = 2` (`manifest.rs:12`).
  Crypto: `generate_org_key` (L230) → `Zeroizing<[u8;32]>` (256-bit
  CSPRNG org master key); `wrap_org_key` (L265) / `unwrap_org_key` (L299) —
  ECIES over X25519, described in detail under **Invariants & contracts**
  below. `vault.rs` adds `encrypt_org_manifest` / `decrypt_org_manifest` typed
  wrappers (JSON-serialize → `crypto::encrypt` under the org key, plaintext in
  `Zeroizing`) consistent with the personal-vault pattern.
- **`backup.rs`** — `.relbak` v1 container format: `pack_backup` /
  `unpack_backup` plus the `BackupInput` / `BackupOutput` / `BackupItem` /
  `BackupAttachment` shapes. Wraps a zstd-compressed JSON envelope of vault
  bytes (salt, params.json, devices.json, manifest, settings, items,
  attachments, optional reference JPEG, optional `.git/` tar) in an
  XChaCha20-Poly1305 envelope keyed by Argon2id over a user-chosen *backup*
  passphrase. The backup key is independent of any vault master key, and
  Argon2id parameters are pinned to the v1 values (m=64MiB, t=3, p=4) so a v1
  reader doesn't need to negotiate them.
- **`import_lastpass.rs`** — `parse_lastpass_csv` plus `ImportWarning`. Pure
  bytes-in / `Vec<Item>`-out LastPass CSV importer: validates the fixed
  8-column header, mints fresh IDs and timestamps for each row, downgrades or
  skips malformed rows into `ImportWarning`s instead of aborting the import.
  Only fatal error is a missing/malformed header.
- **`device.rs`** — Device-identity surface: `DeviceEntry`, `RevokedEntry`,
  `generate_keypair`, `sign`, `verify`, `fingerprint`. ed25519 in OpenSSH
  format (so private keys are interchangeable with `ssh-keygen`-produced
  keys); the same module backs both `.relicario/devices.json` entries and the
  server's pre-receive commit-verification hook.
- **`tar_safe.rs`** — `safe_unpack_git_archive` + `DEFAULT_MAX_UNCOMPRESSED`
  (1 GiB). Hardened tar reader used by `backup::unpack_backup` for the
  bundled `.git/` directory: rejects `..` components, absolute paths, Windows
  drive prefixes, symlinks, hardlinks, and any entry whose declared size
  (or running total across all entries) exceeds the supplied cap.
- **`recovery_qr.rs`** — `generate_recovery_qr` / `unwrap_recovery_qr` plus
  `recovery_qr_to_svg`. Produces a 109-byte XChaCha20-Poly1305 envelope
  around the 32-byte image_secret, keyed by Argon2id over a user-chosen
  recovery passphrase with the domain-separation prefix
  `b"relicario-recovery-v1\0"`. Parameters are pinned at module scope —
  changing them invalidates every printed QR — and both salt and nonce are
  freshly randomized per call so two QRs printed from the same inputs are
  different bytes.

## 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`.
- **ECIES wrap-blob layout is fixed** at
  `ephemeral_x25519_pk(32) || version(1) || nonce(24) || ciphertext+tag`
  (`org.rs:264`). The `version(1)` byte is the same `VERSION_BYTE = 0x02`
  emitted by `crypto::encrypt`, which is what occupies that slot — the layout
  merely names the regions for clarity.
- **KDF wrap key = `SHA-256(dh_shared || ephemeral_pk || recipient_pk)`**
  (`org.rs:278-281`). The concatenation order is identical in `wrap_org_key`
  and `unwrap_org_key`; a mismatch in either direction would produce a
  different key and fail the AEAD open. The intermediate `kdf_input` buffer is
  held in `Zeroizing<Vec<u8>>`; `org_key`, `wrap_key`, and the decrypted
  `plaintext` from unwrap are also held in `Zeroizing`.
- **ed25519 → X25519 conversion** applies `SHA-512(seed)[..32]` then the
  RFC 7748 scalar clamp
  (`scalar[0] &= 248; scalar[31] &= 127; scalar[31] |= 64`) to derive the
  private X25519 scalar (`org.rs:242`); the recipient public key is obtained
  via `ed25519_dalek`'s `to_montgomery()`. This lets device ed25519 keys serve
  double duty as X25519 recipients without storing a separate DH key.
- **Org crypto bypasses Argon2id.** The ECIES inner cipher delegates to
  `crate::crypto::encrypt` / `decrypt` (XChaCha20-Poly1305, random 24-byte
  nonce, `VERSION_BYTE = 0x02`) — no AEAD re-implementation. The X25519 KDF
  output is used directly as the AEAD key; the Argon2id path in `crypto.rs`
  is not invoked for org key wrapping.

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

### Org key wrap / unwrap

1. **Wrap** (`org.rs:265`): caller supplies a recipient's OpenSSH ed25519
   public key string.
   - Parse the OpenSSH wire format via `ssh-key` to recover the raw 32-byte
     ed25519 public key bytes; apply `to_montgomery()` (ed25519-dalek) to
     obtain the recipient's X25519 public key.
   - Generate an ephemeral X25519 keypair from `OsRng`.
   - `dh_shared = ephemeral_secret × recipient_x25519_pk` (X25519 DH).
   - `wrap_key = SHA-256(dh_shared || ephemeral_pk || recipient_pk)`
     (`org.rs:278-281`), intermediates in `Zeroizing`.
   - `ct = crate::crypto::encrypt(&wrap_key, &org_key)` — yields the standard
     `version(1) || nonce(24) || ciphertext+tag` blob.
   - Return `ephemeral_x25519_pk(32) || ct` (`org.rs:264`).
2. **Unwrap** (`org.rs:299`): caller supplies the device ed25519 seed bytes
   (from `current_device_seed` in the CLI layer, not from `relicario-core`).
   - Derive X25519 private scalar from seed: `SHA-512(seed)[..32]` + RFC 7748
     clamp (`org.rs:242`).
   - Slice the first 32 bytes of the blob as `ephemeral_pk`; read recipient's
     own X25519 public key via the same `to_montgomery()` path.
   - `dh_shared = device_x25519_secret × ephemeral_pk`.
   - Reconstruct `wrap_key` identically; `crypto::decrypt` recovers `org_key`
     into `Zeroizing`.

Integration tests: `crates/relicario-core/tests/org.rs` (5 acceptance tests
covering wrap/unwrap round-trip, revoked-after-rotation, and manifest
`filter_for_member`). A pinned RFC 8032 ed25519→X25519 known-answer vector
lives in the `#[cfg(test)]` block inside `org.rs` itself.

### imgsecret embed

1. Caller passes a JPEG byte slice and a 32-byte secret to
   `imgsecret::embed(carrier_jpeg, &secret)` (`imgsecret.rs:666-726`).
2. `enforce_dimension_cap` walks JPEG markers (`imgsecret.rs:127-161`) to read
   the SOF dimensions; rejects > 10_000 × 10_000 before any pixel decode.
3. `extract_y_channel` decodes via `image::ImageReader` and converts each pixel
   to BT.601 luminance (`imgsecret.rs:242-265`).
4. `central_region` picks the inner 70% of the image as the embed region; the
   15% margin per side is the "crumple zone" for crops
   (`imgsecret.rs:268-293`).
5. `compute_embed_positions` / `select_embed_blocks` lay out
   `num_copies × BLOCKS_PER_COPY` 8×8 blocks evenly across the region, with
   `num_copies` = `min(50, total_blocks / 22)` (`imgsecret.rs:530-575`).
6. For each block: 2D DCT (`dct2_8x8`, `imgsecret.rs:393-412`) → embed 12 bits
   into the 12 mid-frequency coefficients listed in `EMBED_POSITIONS`
   (zig-zag positions 6–17, `imgsecret.rs:105-118`) via QIM with
   `QUANT_STEP = 50.0` (`imgsecret.rs:462-467`) → 2D inverse DCT → write
   back into Y.
7. `reconstruct_jpeg` (`imgsecret.rs:590-640`) re-derives Cb/Cr per pixel from
   the original RGB (so chrominance is preserved), combines with the modified
   Y, and re-encodes at JPEG quality 92.

### imgsecret extract (with crop recovery)

1. `extract(jpeg_bytes)` enforces the dimension cap, then delegates to
   `extract_with_crop_recovery` (`imgsecret.rs:738-741`,
   `imgsecret.rs:849-899`).
2. **Try 1** — assume uncropped: `try_extract_with_layout(&y, w, h, 0, 0)`.
   This is the hot path; for a freshly embedded image it always succeeds.
3. **Try 2** — width-only crop, block-aligned: iterate `orig_w` from current
   width up to `1.20 × current_w` in 8-px steps, with `dx = 0`
   (assume right-edge crop).
4. **Try 3** — height-only crop, block-aligned: same strategy on the vertical
   axis.
5. **Try 4** — width crops at non-block-aligned 1-px steps, skipping any
   already covered in Try 2.
6. `try_extract_with_layout` (`imgsecret.rs:754-834`) tallies QIM votes for
   each of the 256 bit positions across all `num_copies` copies. Each bit
   must reach **≥60% confidence** (`imgsecret.rs:824`); below that, the
   whole extraction fails with `ExtractionFailed` (no partial result is
   ever returned).
7. The 60% threshold is per-bit, not aggregate — a single unconfident bit
   aborts the whole try. This makes false-positive extractions from
   never-embedded images vanishingly unlikely.

## Cross-cutting concerns

- **Error model.** `RelicarioError` (`error.rs:15-89`) is a single
  `thiserror`-derived enum. `Decrypt` is the deliberately-opaque "wrong key
  or tampered ciphertext" variant (audit M4 — `error.rs:28-30`,
  `tests/integration.rs:99-111`): the message is just `"decryption failed"`
  with no inner string, and it does not distinguish wrong-passphrase from
  wrong-image-secret from corrupted ciphertext. `Format` is the
  "input bytes don't make sense" variant (e.g. blob too short, schema
  mismatch). `UnsupportedFormatVersion` is the structured "wrong version
  byte" variant — separate from `Format` because callers want to react to
  it differently (offer migration, etc.).
- **Where secrets live.** Every secret type wraps `Zeroizing<...>`:
  - The derived master key: `Zeroizing<[u8; 32]>` (`crypto.rs:212`).
  - Field values: `FieldValue::Password(Zeroizing<String>)` and
    `FieldValue::Concealed(Zeroizing<String>)` (`item.rs:39-40`).
  - `FieldHistoryEntry::value`: `Zeroizing<String>` (`item.rs:127`).
  - Per-type cores: `LoginCore::password`, `CardCore::{number,cvv,pin}`,
    `KeyCore::key_material`, `SecureNoteCore::body`, `TotpConfig::secret`
    (a `Zeroizing<Vec<u8>>` of the raw HMAC key).
  - Decrypted attachment plaintext: `Zeroizing<Vec<u8>>`
    (`attachment.rs:88-92`).
  - Argon2id input buffer (`crypto.rs:232`) and JSON serialization buffers in
    `vault.rs` are wrapped in `Zeroizing` to wipe the intermediate plaintext.
- **Format versioning.** Three independent version channels exist, each
  gating something different:
  - `crypto::VERSION_BYTE = 0x02` (`crypto.rs:59`) — gates the AEAD blob
    layout. Bumped if the nonce length, header layout, or cipher changes.
    A v1 blob is rejected with a typed
    `UnsupportedFormatVersion { found: 0x01, expected: 0x02 }`.
  - `manifest::MANIFEST_SCHEMA_VERSION = 2` (`manifest.rs:12`) — gates the
    JSON-level shape of the manifest. v1 manifests had a different layout
    and would fail to parse against the current `Manifest` struct.
  - The `.relbak` import/export format defined in
    `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md`
    will introduce a third version channel for backups; that surface lives
    outside this crate.
- **KDF parameter handling.** `KdfParams` (`crypto.rs:156-168`) is just a
  serializable struct. The crate has no opinion about where it is stored,
  how it is rotated, or who increments it. `Default` gives the production
  values (`m=65536`, `t=3`, `p=4` — `crypto.rs:175-183`) calibrated for
  ~0.5–1 s on a modern desktop. Tests universally use the fast triplet
  `(m=256, t=1, p=1)` defined as a `fn fast_params()` near the top of every
  test file.
- **NFC normalization is the only Unicode op.** All passphrase canonicalization
  happens in one place (`crypto.rs:223-227`). Item titles, field labels,
  tags, etc. are stored verbatim — only the passphrase fed to the KDF is
  normalized.
- **No per-entry subkeys.** Every encrypted blob (item, manifest, settings,
  attachment) is encrypted with the *same* master key. The design rationale
  is in `docs/superpowers/specs/2026-04-11-relicario-design.md` lines 66:
  per-entry subkey derivation would add complexity for no real-world benefit
  given the expected family-vault size.
- **CSPRNG is `OsRng` everywhere.** `ItemId::new`, `FieldId::new`,
  `derive_master_key` (no-op — the salt is caller-supplied),
  `crypto::encrypt` (nonce), `generators::random_password`,
  `generators::bip39_passphrase`. A single `rand::thread_rng()` call exists
  inside an `imgsecret` test (`imgsecret.rs:1033`) to generate a random test
  secret; production code is `OsRng` only.
- **`ed25519-dalek` is consumed by `device.rs`.** Together with `ssh-key` (for
  OpenSSH wire encoding) it backs `generate_keypair`, `sign`, and `verify` —
  the same primitives the CLI uses to populate `.relicario/devices.json` and
  the server uses to verify pre-receive commit signatures. The corresponding
  error variant is `RelicarioError::DeviceKey`.

## Test architecture

All `tests/` files use the fast Argon2id triplet `m=256, t=1, p=1` so the
suite runs in seconds, not minutes. Test JPEGs are synthesized at runtime via
`make_test_jpeg(width, height)` (`imgsecret.rs:908-924`) — a deterministic RGB
pattern at quality 92 — so no binary fixtures live in git.

- **`tests/integration.rs`** — End-to-end vault workflows: encrypt+decrypt a
  Login and a SecureNote through `Manifest`/`VaultSettings`, two-factor
  independence (different passphrase or different image_secret yields
  different keys), field-history surviving an encrypt/decrypt round-trip,
  and the wrong-key-→-`Decrypt` opaqueness contract.
- **`tests/attachments.rs`** — Round-trip a 5 KB blob, prove identical
  plaintexts produce identical `AttachmentId`s (despite different ciphertext
  bytes due to fresh nonces), and exercise the cap boundary at exactly the
  max byte and one over.
- **`tests/field_history.rs`** — Sequential `set_field_value` calls accumulate
  history in oldest→newest order; `prune_history(LastN(3))` keeps the most
  recent 3; field-history survives `encrypt_item` →`decrypt_item`.
- **`tests/format_v2.rs`** — `VERSION_BYTE == 0x02`, fresh ciphertext starts
  with `0x02`, a v1-shaped blob (`[0x01][24 nonce][16 tag]`) is rejected with
  the typed `UnsupportedFormatVersion`, and the length-prefix construction
  prevents `("abc", 0x44…)` / `("abcD", …)` collisions.
- **`tests/generators.rs`** — Aggregates 80 × 128 = 10,240 chars from
  `generate_password` to assert per-character-class proportions are within
  ±5 pp of the expected uniform distribution; verifies that 5-word BIP-39
  passes the strength gate while common weak passwords ("password",
  "12345678", "letmein", "qwertyui", "hunter2") all fail; asserts uniqueness
  across 1000 default-config calls. The opening doc comment
  (`tests/generators.rs:1-13`) explains why the original "10,000-char single
  call" plan switched to aggregation: `generate_password` enforces
  `length ≤ 128`.

In-module `#[cfg(test)] mod tests` blocks cover unit-level invariants (kind/
value mismatches, snake-case serde tags, base32 round-trips, `MonthYear`
constructor bounds, the Steam alphabet ambiguity audit). The `imgsecret`
test block additionally proves DCT round-tripping, QIM noise tolerance below
`Q/4 = 12.5`, embed→Q85-recompress→extract round-trip, embed→10%-crop→extract
round-trip, and the oversized-image-header rejection path.

## Gotchas & non-obvious decisions

- **`QUANT_STEP = 50.0` is intentionally double the academic value of 25**
  (`imgsecret.rs:62`). Higher quantization steps make the watermark more robust
  to JPEG recompression at Q85 and below — at the cost of more visible
  artifacts in the carrier. The reference image is a personal photo, not a
  publication, so the trade-off favors robustness.
- **The embed region is the *central 70%* (15% margin per side, "crumple
  zone")** — `imgsecret.rs:212-218`, `imgsecret.rs:276-293`. Anything in the
  outer 15% is sacrificed so that mild edge crops (e.g. social-media platform
  trims) leave the embedded data intact. Tested up to 10% crop in
  `imgsecret.rs:1108-1137`.
- **Per-bit majority voting with a 60% confidence floor.**
  `try_extract_with_layout` tallies votes from every redundant copy and
  fails the entire extraction if any single bit position is below 60%
  agreement (`imgsecret.rs:824`). This is more conservative than a global
  threshold and is what makes false positives from never-embedded images
  essentially zero — see `extract_from_non_embedded_image_fails`
  (`imgsecret.rs:1041-1045`).
- **Number of redundant copies is capped at 50** (`imgsecret.rs:536`,
  `imgsecret.rs:692-693`). Beyond that, per-block visual artifacts compound
  faster than the error-correction benefit grows.
- **`peek_jpeg_dimensions` walks JPEG markers manually instead of using the
  `image` crate.** `imgsecret.rs:127-161`. A full `ImageReader::decode` of an
  attacker-supplied 30 000 × 30 000 JPEG would allocate ~3.6 GB of pixel
  buffer in the WASM service worker before failing — the manual walk reads
  only the SOF segment and bails in O(marker-count) (audit M3).
- **`bip39` always generates 128 bits of entropy** (12 mnemonic words) and
  truncates to `word_count` (`generators.rs:82-89`). This is because
  `bip39 v2` rejects entropy below 128 bits, but we want to support 3–12 word
  passphrases. Truncation preserves the per-word independence — the words
  the user sees still come from a uniformly-sampled-then-truncated 12-word
  draw.
- **Steam TOTP output is exactly 5 characters from a 26-glyph alphabet,
  regardless of the `digits` field on `TotpConfig`** (`item_types/totp.rs:103-110`,
  asserted in `item_types/totp.rs:240-253`). The alphabet
  (`23456789BCDFGHJKMNPQRTVWXY`) excludes `0/O`, `1/I/L`, `S` (so `5` is
  unambiguous), `A`, `E`, `U`, `Z` — all glyphs Valve considered ambiguous
  in the Steam Mobile Authenticator. Verified at
  `item_types/totp.rs:274-283`.
- **`ItemCore` is internally-tagged with `#[serde(tag = "type")]`** — the
  outer JSON object gets a `"type"` key. This means *no* `*Core` struct may
  have a field literally named `type`. The convention chosen for
  type-discriminant fields *inside* a core is `kind` — see `CardKind`,
  `TotpKind` (`item_types/mod.rs:38-40`).
- **The TOTP base32 in field-history strips padding.** `base32_encode`
  (`item.rs:256-275`) is RFC-4648 with no `=` padding — appropriate because
  the value is for human display in history, not for re-decoding.
- **`AttachmentId::from_plaintext` uses only the first 8 bytes (= 16 hex
  chars) of the SHA-256 digest** (`ids.rs:51-57`). 64 bits of collision
  resistance is sufficient for a personal-vault attachment count; it keeps
  filenames short. If a future use case demands collision resistance against
  motivated adversaries (e.g. dedup across untrusted vaults), this width is
  the lever.
- **`Field::new` derives `kind` from `value`, but the public struct still
  stores both** (`item.rs:73-94`). The duplication exists so callers can
  match on `kind` without inspecting (and potentially decrypting / cloning)
  `value`. `validate()` is the safety net that runs after deserialization.
- **`set_field_value` refuses to change a field's kind** (`item.rs:184-189`).
  The intent is that fields are conceptually fixed-shape after creation;
  changing a `Text` to a `Password` should be done by deleting the old field
  and creating a new one (so history doesn't get confused).
- **`hidden_by_default` is *not* `Zeroize`.** It's purely a UI hint — the
  rendering layer (CLI output, popup card) decides whether to mask the value
  on initial display. Secrecy at rest is enforced by the `Zeroizing` wrappers
  on the value itself, not this flag.
- **`Manifest::upsert` rebuilds the entry from scratch every call**
  (`manifest.rs:45-48`, `manifest.rs:75-89`). There is no "patch the
  existing entry" path. This means the manifest can never carry a stale
  `icon_hint` or `attachment_summaries` — they are derived freshly from the
  source `Item` each time.
- **The strength gate is *not* called inside `derive_master_key`.** It must
  be invoked separately by the caller during *vault creation* only — not
  during unlock, where calling it would let an attacker probe whether a
  wrong passphrase happens to be "strong enough" before the Argon2id work
  even starts. See `generators.rs:124-130`.
- **`now_unix()` is `chrono::Utc::now().timestamp()` and is the single time
  source in this crate** (`time.rs:6-8`). Tests that need determinism pass an
  explicit `now: i64` to `prune_history` (`item.rs:219`) and similar — they
  do not stub `now_unix`.

---

**Next:** [../relicario-cli/ARCHITECTURE.md](../relicario-cli/ARCHITECTURE.md) — how the CLI wraps the core.
