Doc-audit Finding 1. The repo has had four Rust crates since early May when the pre-receive hook crate landed, but docs/architecture/overview.md still framed itself around three. Update: - "The three codebases" → "The four codebases" (intro + heading) - ASCII diagram fans core out to cli + server + wasm, with wasm feeding the extension - Table gains a relicario-server row noting it lives on the git server and only sees public key material - Build matrix adds `cargo build -p relicario-server --release` - "Where to look next" points at server src + the device-auth design spec Server has no user-facing surface, so the CLI/extension parity rule is clarified to exclude it (it is server-side enforcement of an invariant the clients already agreed to). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
214 lines
18 KiB
Markdown
214 lines
18 KiB
Markdown
# 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.md` first:
|
||
>
|
||
> - [crates/relicario-core/ARCHITECTURE.md](../../crates/relicario-core/ARCHITECTURE.md)
|
||
> - [crates/relicario-cli/ARCHITECTURE.md](../../crates/relicario-cli/ARCHITECTURE.md)
|
||
> - [extension/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:
|
||
1. Add the capability to `relicario-core/src/`.
|
||
2. Re-export through `lib.rs`.
|
||
3. Add a thin `#[wasm_bindgen]` wrapper to `relicario-wasm/src/lib.rs`.
|
||
4. Run `wasm-pack build` (via `npm run build:wasm` in `extension/`).
|
||
5. 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 wizard
|
||
- `ContentMessage` — sent by content scripts injected into web pages
|
||
- `Response` — 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.html
|
||
- `CONTENT_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 API
|
||
- `github.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 || image_secret` collisions |
|
||
| 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`](../../crates/relicario-core/ARCHITECTURE.md) |
|
||
| A new CLI command or a CLI bug | [`crates/relicario-cli/ARCHITECTURE.md`](../../crates/relicario-cli/ARCHITECTURE.md) |
|
||
| A new popup view, vault tab feature, or autofill change | [`extension/ARCHITECTURE.md`](../../extension/ARCHITECTURE.md) |
|
||
| A new SW message type | `extension/src/shared/messages.ts` (capability sets), then [`extension/ARCHITECTURE.md § Invariants`](../../extension/ARCHITECTURE.md) |
|
||
| 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).
|