Move docs/architecture/overview.md to ARCHITECTURE.md at the repo root — it is the primary cross-codebase architecture doc (four-codebase diagram, inter-codebase contracts, secrets map, build matrix, test strategy, where-to-look table) and belongs at the root alongside STATUS.md, ROADMAP.md, etc. Update relative paths inside the file (../../crates/ → crates/, etc.). Update CHANGELOG.md's one active reference to the old path. Add a "Living docs — update discipline" table to CLAUDE.md that maps every ALLCAPS.md file to the area it covers and the trigger for updating it. This closes the loop on the ALLCAPS.md documentation system. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
18 KiB
Architecture overview — Relicario
This is the cross-codebase entry point. It describes how the four Relicario codebases fit together, the contracts that flow between them, and the conventions they share. It is deliberately thin; the deep content lives in per-codebase docs.
If you are about to make a change in a single codebase, read its
ARCHITECTURE.mdfirst:
- crates/relicario-core/ARCHITECTURE.md
- crates/relicario-cli/ARCHITECTURE.md
- extension/ARCHITECTURE.md
If you want historical why, see
docs/superpowers/specs/— those are time-stamped decision artifacts. This overview describes what is.
The four codebases
┌─────────────────────┐
│ relicario-core │
│ (Rust, no I/O) │
│ crypto · items │
│ manifest · stego │
│ device keys + fp │
└──┬───────────┬──────┘
│ │
┌────────────────┼───────────┴──────┬────────────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌────────────────┐ ┌──────────────────┐ ┌────────────────────┐
│ relicario-cli │ │ relicario-server │ │ relicario-wasm │
│ (Rust binary) │ │ (Rust binary) │ │ (#[wasm_bindgen] │
│ │ │ │ │ bindings) │
│ filesystem + │ │ pre-receive hook │ │ │
│ git + │ │ verify-commit + │ │ compiled to WASM │
│ clap UX │ │ generate-hook │ │ for the extension │
└────────────────┘ └──────────────────┘ └──────────┬─────────┘
│
▼
┌─────────────────────┐
│ extension │
│ (TypeScript) │
│ popup · vault │
│ setup · content │
│ service worker │
└─────────────────────┘
| Codebase | Language | Role | Key boundary |
|---|---|---|---|
relicario-core |
Rust | Crypto, item types, manifest, attachments, imgsecret, generators, device keys / fingerprints. Pure, no I/O. | Only bytes-in / bytes-out. No filesystem, no git, no network. |
relicario-cli |
Rust binary | Wraps core with filesystem ops, git plumbing, clap UX. | Only entry point that runs without a browser; sole working interface during disaster recovery. |
relicario-wasm |
Rust → WASM | Thin #[wasm_bindgen] exports from core for the extension. |
Compiles relicario-core to WASM; no extra logic. |
relicario-server |
Rust binary | Pre-receive Git hook (verify-commit) plus hook installer (generate-hook) running on the vault remote. Verifies SSH-signed commits against .relicario/devices.json and .relicario/revoked.json. |
Lives on the git server, not on a client device. The only Relicario component the user does not run themselves. Sees only public key material. |
extension |
TypeScript | Browser-resident UI. Five entry-point bundles (popup, vault tab, setup, content script, service worker). | The service worker is the only crypto holder; popup/vault/content/setup never touch the master key. |
The CLI and the extension are at parity: every user-facing capability lands in both surfaces together. Diverging is allowed only with a documented reason. See the per-codebase docs for which surface owns which user flow. The server has no user-facing surface — it is a server-side enforcer of the device-auth invariant the clients already agreed to.
Inter-codebase contracts
There are four boundaries where the codebases agree on a wire format. Each is versioned independently.
1. Core → WASM ABI (Rust / JS edge)
The relicario-wasm crate is the JS/Rust contract. Every WASM export takes JsValue / &[u8] / &str and returns the same. Strings on the wire are JSON-encoded for any structured data; raw bytes for ciphertext / images / attachments.
Adding a new core capability for the extension requires:
- Add the capability to
relicario-core/src/. - Re-export through
lib.rs. - Add a thin
#[wasm_bindgen]wrapper torelicario-wasm/src/lib.rs. - Run
wasm-pack build(vianpm run build:wasminextension/). - Use it from the extension's service worker (or setup wizard).
The SessionHandle is the cross-language opaque token: WASM owns the Zeroizing<[u8;32]> master key behind a numeric handle; JS only ever holds the number. JS calling wasm.lock(handle) zeroes the WASM-side memory and invalidates the handle.
2. Service worker ↔ popup / vault tab / content script (chrome.runtime messages)
All extension bundles other than the SW communicate with the SW exclusively via chrome.runtime.sendMessage. The protocol is defined in extension/src/shared/messages.ts:
PopupMessage— sent by popup, vault tab, or setup wizardContentMessage— sent by content scripts injected into web pagesResponse— returned by the SW:{ ok: true, data?: ... } | { ok: false, error: string }
Two capability sets in messages.ts gate which sender can issue which message:
POPUP_ONLY_TYPES— accepted only from popup.html, vault.html, or setup.htmlCONTENT_CALLABLE_TYPES— accepted only from content scripts
The router (service-worker/router/index.ts) dispatches by sender. Adding a new message type requires adding it to one of the capability sets, or it is silently rejected. Vault tab parity (commit a7dbf35) is implemented by recognizing vault.html as a popup-class sender at the router level.
3. Vault on disk (shared by CLI and extension)
Every relicario vault — whether on disk for the CLI or in a git remote read by the extension — has the same layout:
<vault root>/
├── .relicario/
│ ├── salt # 32 bytes, random per vault, stays constant
│ ├── params.json # KdfParams: argon2_m, argon2_t, argon2_p
│ └── devices.json # [{ name, public_key }, ...]
├── manifest.enc # encrypted Manifest (browse-without-decrypt index)
├── settings.enc # encrypted VaultSettings
├── items/
│ └── <id>.enc # encrypted Item, one per file
└── attachments/
└── <item-id>/
└── <aid>.enc # encrypted attachment blob; aid is content-addressed SHA-256
The reference image (reference.jpg) lives outside the vault by convention — it is the second factor and the user's responsibility to safeguard. It is not in .relicario/, not in items/, and never committed to git.
This layout is not formally versioned — the content within each .enc file carries its own version byte (see § Versioning below). The directory layout itself is conventional and changes would be breaking.
4. Git remote API (extension's GitHost)
The extension cannot shell out to git; it talks to the remote via the host's REST API. Two implementations live in extension/src/service-worker/:
gitea.ts— Gitea / Forgejo APIgithub.ts— GitHub API
Both implement the GitHost interface in git-host.ts. Adding a third host (GitLab, Bitbucket, custom) means implementing that interface — the rest of the extension is host-agnostic.
The CLI does not use GitHost; it shells out to git directly via the hardened wrapper in relicario-cli/src/helpers.rs:46.
Versioning strategy
There is no single "relicario format version." Each piece of the format is versioned independently so we can evolve without coordinated upgrades.
| Artifact | Where versioned | Current value | Failure mode on read |
|---|---|---|---|
| AEAD ciphertext | First byte of every .enc blob |
VERSION_BYTE = 0x02 (in relicario-core/src/crypto.rs) |
RelicarioError::Format — refuses to attempt decryption |
| Manifest schema | Manifest.schema_version field |
2 (set in relicario-core/src/manifest.rs) |
v1 manifests are explicitly rejected with a clear error |
| KDF parameters | .relicario/params.json |
Vault-specific (initially m=64MiB, t=3, p=4) | Read at unlock; stored alongside the vault |
| Backup container | First 5 bytes of .relbak: magic "RBAK" + version byte |
0x01 (designed; see import/export spec) |
Format-version error if newer-version backup is read by older binary |
| Device entry | devices.json array of { name, public_key } |
Unversioned (extend by adding optional fields) | — |
The intentional design: no big-bang upgrades. A user can run an older CLI against a newer vault as long as the AEAD version, manifest schema, and KDF params are still compatible.
Where secrets live
The threat model differs by codebase. This is the per-secret per-codebase residence map:
| Secret | relicario-core | relicario-cli | extension SW | extension popup/vault/content/setup |
|---|---|---|---|---|
| Passphrase (UTF-8 bytes) | Zeroizing<String> only during a single derive_master_key call |
Same, in UnlockedVault::unlock_interactive |
Same, used briefly to derive master key inside WASM | Never seen — entered into a <input type="password">, sent to SW via unlock message, immediately forgotten |
| Reference image bytes | Held by caller; core only reads | Held by UnlockedVault::unlock_interactive long enough to extract the secret |
Same | Setup wizard holds the bytes briefly during create/attach modes |
| Image secret (32 B) | Zeroizing<[u8;32]> during KDF |
Same | Same | Never sees it |
| Master key | Zeroizing<[u8;32]> returned by derive_master_key |
UnlockedVault.master_key for the lifetime of one CLI invocation |
WASM-side memory behind an opaque SessionHandle; JS never sees the bytes |
Never sees it |
| Item secret (password, card number, etc.) | Zeroizing<String> / Zeroizing<Vec<u8>> |
Same | Briefly held in WASM during item_decrypt; results passed to popup as plaintext for display |
Held in DOM (the user is staring at it); cleared when view changes |
| Device private key | — | Filesystem under ~/.config/relicario/devices/<name>.key (mode 0600) |
chrome.storage.local.device_private_key |
— |
The popup / vault / content surfaces of the extension cannot decrypt an item independently — they all message the SW. Content scripts in particular get back already-prepared payloads (e.g. { username, password }) from fill_credentials after the SW resolved everything.
The CLI keeps its master key in process memory; if the process exits or crashes, the key is gone (Zeroize on drop). There is no CLI session daemon. The lock subcommand exists only for UX parity with the extension and is a no-op.
Build matrix
| Target | Tool | Output | When to run |
|---|---|---|---|
| Native CLI | cargo build (debug or --release) |
target/{debug,release}/relicario |
After CLI changes; for distribution |
| Server hook | cargo build -p relicario-server --release |
target/release/relicario-server |
After server changes; deploy onto the git remote |
| Native test suites | cargo test (workspace) |
— | After any Rust change |
| WASM module | wasm-pack build --target web (via npm run build:wasm) |
extension/wasm/relicario_wasm{,_bg.wasm,.js} |
After core or wasm crate changes |
| Chrome extension | webpack (npm run build) |
extension/dist/ |
After TS or WASM changes; for Chrome distribution |
| Firefox extension | webpack --config webpack.firefox.config.js (npm run build:firefox) |
extension/dist-firefox/ |
After TS or WASM changes; for Firefox distribution |
| All extension targets | npm run build:all |
Both dist/ and dist-firefox/ plus rebuilt WASM |
Pre-release |
| Extension tests | npm test (vitest, happy-dom) |
— | After TS changes |
The WASM build sequence matters: wasm-pack writes the binary into extension/wasm/ before webpack picks it up. npm run build:all runs them in order. Manual builds need the same order.
Test strategy at the workspace level
| Layer | Tool | Where | What it covers |
|---|---|---|---|
| Core unit tests | cargo test -p relicario-core |
crates/relicario-core/src/**/#[cfg(test)] and tests/*.rs |
Crypto round-trip, item serialization, manifest schema, generators, imgsecret embed/extract, format-v2 parsing |
| CLI integration tests | cargo test -p relicario-cli |
crates/relicario-cli/tests/*.rs |
End-to-end via TestVault::init() harness with synthetic JPEGs and RELICARIO_TEST_* env-var escape hatches; covers basic flows, edit + history (incl. TOTP), attachments, settings, vault detection |
| Extension unit tests | npm test (vitest) |
extension/src/**/__tests__/*.test.ts |
Component render + click handlers (mocked SW), router sender dispatch, SW handler logic (mocked WASM + chrome.storage) |
| End-to-end | none | — | No real-browser tests; mocks stand in. Build-vs-test gap is documented in extension/ARCHITECTURE.md |
Core tests use fast Argon2id params (m=256, t=1, p=1) so they don't take forever; the production path is the same code with real params. The CLI's init command always uses production-grade params even under tests.
Conventions that span all three codebases
| Rule | Where enforced | Why |
|---|---|---|
Master key only in Zeroizing<[u8;32]> |
core types; CLI follows; extension WASM follows | Drop-on-scope-exit zeroization; never leaves stack |
| AEAD ciphertext starts with version byte | core/crypto.rs |
Format identification; reject v1 blobs cleanly |
| Item IDs are random 16-char hex (64 bits) | core/ids.rs |
Stable, short, no information leak |
| Attachment IDs are content-addressed (first 32 hex chars / 128 bits of SHA-256) | core/ids.rs |
Dedup; integrity check |
| KDF input is length-prefixed | core/crypto.rs |
Prevents `passphrase |
| Git history is preserved as audit log; never squash | CLI commits; SW commits | Per-action history is a feature |
| Per-action git commits with structured messages | cli (via commit_paths); SW (via vault.ts helpers) |
Greppable, useful as audit log |
Hardened git invocations (-c core.hooksPath=/dev/null etc.) |
CLI's helpers::git_command; SW does not shell out |
Prevent hostile hooks; no GPG prompt holding key alive |
Atomic writes (write .tmp → rename) |
CLI's session::atomic_write; SW's vault.ts equivalents |
Partial-write safety |
Tests use synthesized JPEGs (make_test_jpeg), not committed binaries |
Both Rust and TS test harnesses | Repo stays small; reproducible |
Test-only env vars (RELICARIO_TEST_*) have no production fall-through |
Verified in relicario-cli audit |
Escape hatches don't leak into builds |
Where to look next
| If you're working on... | Start with |
|---|---|
| Crypto, item types, manifest format | crates/relicario-core/ARCHITECTURE.md |
| A new CLI command or a CLI bug | crates/relicario-cli/ARCHITECTURE.md |
| A new popup view, vault tab feature, or autofill change | extension/ARCHITECTURE.md |
| A new SW message type | extension/src/shared/messages.ts (capability sets), then extension/ARCHITECTURE.md § Invariants |
| A new GitHost (e.g. GitLab support) | extension/src/service-worker/git-host.ts (interface) and existing implementations |
| The pre-receive hook / device-auth enforcement | crates/relicario-server/src/main.rs, then docs/superpowers/specs/2026-05-02-device-authentication-design.md for rationale |
| Adding a new item type | core's item_types/ mod, then CLI's build_*_item/edit_* helpers, then extension's popup/components/types/<type>.ts |
| Threat model / why a primitive was chosen | docs/superpowers/specs/2026-04-11-relicario-design.md (historical, but authoritative for rationale) |
| Format of the import/export feature | docs/superpowers/specs/2026-04-27-relicario-import-export-design.md (designed but not yet implemented) |
| Running the full test suite | cargo test && (cd extension && npm test) |
| Bumping the WASM module after a core change | cd extension && npm run build:wasm |
Stale spec docs
The docs/superpowers/specs/ tree is historical — it captures the design decisions made at planning time. Some specs (e.g. Plan 1A, 1B, 1C-α/β/γ) describe work that has shipped. Do not edit them as if they were the architecture docs; instead update the appropriate ARCHITECTURE.md. The specs are valuable for why (why XChaCha20-Poly1305, why central-embed DCT, why two-factor with steganography); the architecture docs are valuable for what (current invariants, current flows, current contracts).