Files
relicario/extension/ARCHITECTURE.md
adlee-was-taken 7c7efa7c43 release: v0.7.0 — extension restructure complete (Plan C Phases 3/4/6)
Completes the extension restructure begun in v0.6.0. Phases 3 (setup
wizard SW migration + step registry), 4 (vault.ts split + vault_locked
lift), and 6 (get_vault_status + sidebar status indicator) all merged to
main (9df2fee, 3b8368d, 397cc78) via three parallel worktree streams.

This commit is the release-prep wrap-up:
- Version bump to v0.7.0 across the three relicario crates + Cargo.lock,
  extension/package.json, and both extension manifests (the manifests had
  lagged at 0.5.0 — corrected here).
- CHANGELOG.md v0.7.0 entry.
- STATUS.md: extension restructure moved to shipped; Phases 3/4/6 landing
  section added.
- ROADMAP.md: v0.7.0 row added; Up-next now command palette.
- extension/ARCHITECTURE.md: all three phases integrated (new vault-*
  modules, setup-steps.ts, get_vault_status protocol + status indicator,
  vault_locked lift, git-host sync cache).
- Plan completion checkboxes ticked.

Task 7.1 verification: done-criteria sweep all green; 423/423 vitest;
build:all clean (only the pre-existing 4MB WASM size warning).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:50:17 -04:00

51 KiB
Raw Permalink Blame History

Architecture: relicario extension

Audience: contributors editing the browser extension. This doc owns the bundle structure (popup, vault tab, background SW, content scripts), the SW ↔ popup message contract, the component / pane architecture, routing, and the build pipeline. Does NOT own: WASM crypto internals (see ../crates/relicario-core/ARCHITECTURE.md), wire formats (see ../docs/FORMATS.md), or threat model (see ../docs/SECURITY.md).

What this codebase is for

The extension is the browser-resident face of relicario: the same vault the relicario CLI manages, but rendered as Chrome MV3 / Firefox WebExtension UI plus a content-script autofill surface. It does not invent its own data model or crypto — crates/relicario-core compiled to WASM (extension/wasm/relicario_wasm.js + relicario_wasm_bg.wasm) holds the KDF, AEAD, manifest/item/settings (de)serialization, password generators, TOTP, steganography, and field-history routines. The extension is, above that core, three things: a message router and crypto fortress (the service worker), a small UI shell that runs in the popup and a fullscreen vault tab, and a content script that detects login forms and shuttles already-resolved credentials into them.

Design intent is CLI parity. Every capability in the CLI is reachable from the extension; the popup is the everyday surface (unlock, search, fill, TOTP, generator, capture); heavy workflows (setup wizard, vault-level settings, trash, devices, future backup/restore and importer) live in the fullscreen vault tab so they have screen real estate without the popup's 600px constraint. Both Chrome MV3 and Firefox WebExtension are first-class build targets — manifest.json (Chrome) and manifest.firefox.json (Firefox) differ only in the manifest envelope; the same TypeScript bundles back both.

Bundle structure

Webpack produces five entry points in the Chrome build, four in the Firefox build (the vault tab is Chrome-only for the moment). Verify in extension/webpack.config.js and extension/webpack.firefox.config.js.

Bundle Entry Sandbox Has WASM access?
service-worker src/service-worker/index.ts extension SW / bg yes — initialized lazily on first message
popup src/popup/popup.ts popup.html no — goes through SW
vault src/vault/vault.ts (Chrome only) vault.html (tab) no — goes through SW
setup src/setup/setup.ts setup.html (tab) no — goes through SW (create_vault/attach_vault)
content src/content/detector.ts host page (top frame only by router check) no

What each bundle owns

  • service-worker — the only place a vault SessionHandle and decrypted Manifest ever live. Initializes WASM lazily on the first message (service-worker/index.ts:20). Every other bundle goes through this bundle for crypto. It also implements both GitHosts, owns the inactivity timer (session-timer.ts), and reads/writes chrome.storage.local for device-local state.
  • popup — small MV3 popup at popup.html. Locked-or-list state machine, search/sort/edit, attachments + TOTP. Cannot access SessionHandle directly — every operation is a chrome.runtime.sendMessage to the SW.
  • vault — fullscreen "desktop-like" sidebar+pane shell. Imports the same component renderers as the popup via the StateHost service locator (see Cross-cutting). The vault tab is Chrome-only because Firefox MV3 still treats chrome.tabs.create to extension pages differently and the popup pop-out wasn't worth the cost yet.
  • setup — first-run wizard. Lives in its own page (setup.html) rather than the popup so the carrier-image upload + zxcvbn meter + remote-host probing all have room. Loads WASM directly because it must do crypto before any extension config exists for the SW to read (setup.ts:27).
  • content — injected into every page (<all_urls>) at document_idle. Detects login forms, paints a small "id" icon, runs the autofill picker / TOFU hint inside closed Shadow DOMs, and prompts on form submit to save or update credentials. Cannot decrypt — the SW always returns already-resolved { username, password } payloads.

Output trees

webpack.config.js writes to dist/ and copies both relicario_wasm_bg.wasm and relicario_wasm.js next to the bundles so the SW's chrome.runtime.getURL('relicario_wasm_bg.wasm') resolves and the setup page's dynamic import('../relicario_wasm.js') works. The Firefox config writes to dist-firefox/, swaps in the Firefox manifest under the name manifest.json, and skips the vault entry. Both pin experiments.asyncWebAssembly: true. The Chrome content_security_policy keeps 'wasm-unsafe-eval' for extension pages (necessary for the WASM init in setup.ts and the SW).

WASM module

The wasm-pack output lives at extension/wasm/. Built from crates/relicario-wasm (see project-root CLAUDE.md). The exported surface — unlock, lock, manifest_encrypt/decrypt, item_encrypt/decrypt, settings_encrypt/decrypt, attachment_encrypt/decrypt, embed_image_secret, extract_image_secret, totp_compute, the generators, rate_passphrase, generate_device_keypair, and the opaque SessionHandle class — is enumerated in extension/wasm/relicario_wasm.d.ts. Two patterns matter:

  1. The SW initializes via initSync(new WebAssembly.Module(bytes)) when running as a real service worker (no top-level await), and the default async initDefault(url) path otherwise (jest-style harness or fallback). See service-worker/index.ts:24-35.
  2. Setup uses import(/* webpackIgnore: true */ '../relicario_wasm.js') so webpack doesn't try to inline the runtime — it's served as a flat sibling file (setup.ts:30-33).

Module map

src/popup/

  • popup.ts — entry. Owns the popup state machine (View enum: locked | list | detail | add | edit | settings | settings-vault | trash | devices | field-history), captures the active tab at popup-open for TOCTOU-safe fill (popup.ts:230-233), translates cryptic backend errors to user-readable strings (humanizeError, popup.ts:135-160), and registers itself as the shared StateHost.
  • index.html / styles.css — markup + dark monospace theme.

src/popup/components/

The popup UI. Each module exports a renderXxx(app: HTMLElement) and, where it owns disposable resources (timers, DOM listeners), a teardown() that the dispatcher in popup.ts and vault.ts calls before any new render.

  • unlock.ts — passphrase input + Enter-to-submit. Calls unlock SW message; on success, fetches list_items and navigates to list.
  • item-list.ts — toolbar (search/new/sync/lock/settings) + virtualized-ish row list. Owns the keyboard navigation handler (/, +, arrow keys, Enter, Esc) and the settings-picker popover that splits "device settings" from "vault settings".
  • item-detail.ts / item-form.ts — type dispatchers; each delegates to one of components/types/{login,secure-note,identity,card,key,document,totp}.ts.
  • components/types/*.ts — per-item-type detail+form pairs. Each exports renderDetail, renderForm, and teardown. Uses the shared fields.ts primitives (concealed rows, signature blocks, sections editor) and the attachments-disclosure.ts widget.
  • fields.ts — pure HTML-string primitives (renderRow, renderConcealedRow, renderSignatureBlock, renderSections*) consumed by every type. Mounting is the caller's job; after mount, wireFieldHandlers(scope) binds the reveal/copy click handlers once.
  • form-header.ts — extracted renderFormHeader({ title, subtitle, ...}) helper used by every type's renderForm (shared .form-header CSS, static "esc to cancel" subtitle in fullscreen mode). Takes an options object so callers don't need to remember positional argument order.
  • generator-panel.ts — inline password / passphrase generator. Mounts inside any host element; round-trips knob changes through the SW's generate_password / generate_passphrase (debounced 150ms). Has two action-row modes: fill-field (cancel + use) and configure-defaults (save-as-default).
  • attachments-disclosure.ts — the per-item attachment list (edit/view modes). Image-MIME rows lazy-load thumbnails as object URLs; teardown revokes them. Per-item-count and per-vault soft/hard size caps are enforced here client-side; the SW also enforces per-attachment max bytes via WASM (defense in depth — see router/popup-only.ts:223-228).
  • settings.ts — device-local UX settings (capture toggle, prompt style), trash/devices/sync-now buttons, blacklist editor. Revamped in commit 299e7db to split synced (vault) vs local (device) sections and surface the per-device session-timeout UI (radio + minutes input).
  • settings-vault.ts — vault-wide settings (retention, generator defaults, autofill origin acks). Reads/writes via the SW's get_vault_settings / update_vault_settings.
  • settings-security.ts — security sub-pane of the vault-tab settings shell: three-state recovery QR display (hidden → revealed → printed) and an inline devices summary. Mounted from the settings left-nav. Restored from main in commit 8baef5b after the Stream C real implementation landed.
  • trash.ts — soft-delete listing with per-item purge countdown (via shared/relative-time.ts::daysUntilPurge), glyph restore (), and a bottom-right destructive "empty trash" button.
  • devices.ts — device list. Three-line rhythm per row: name + revoke glyph ( with inline two-step confirm — no browser modal), full SHA256 fingerprint (computed in-popup via shared/ssh-fingerprint.ts — no SW round-trip), added X ago · by Y meta. Inline "register this device" banner shown when current device is not in the list.
  • field-history.ts — audit-log of value changes on a single item; driven by the SW's get_field_history which calls into WASM get_field_history(item_json). Section header per field (PASSWORD · N entries); reveal/copy via explicit glyph buttons (decoupled from row click); revealed values colorized via shared/password-coloring.ts.
  • item-history-index.ts — top-level history pane: iterates the manifest and fans out one get_field_history per item, lists those with ≥1 entry sorted by recency. Click drills into field-history.ts for the per-item view. Reachable via #history (the sidebar slot) and from the URL.

src/vault/

  • vault.ts (194 lines) — fullscreen tab entry, now a thin routing + state shell after the Phase 4 split. Registers itself as the StateHost so all popup/components/* renderers run unchanged, maintains its own selectedItem cache so hash navigation between already-loaded items doesn't refetch, and delegates DOM scaffolding, navigation, list/drawer/form rendering, and route dispatch to the sibling modules below. The hash-route set is #detail/<id>, #add/<type>, #trash, #devices, #settings, #settings-vault, #history, #history/<id>, #backup, #import.
  • vault-context.ts — the VaultController contract plus the shared types and pure helpers the split modules depend on. Added so the split is acyclic: the rendering modules import the controller interface from here rather than from vault.ts.
  • vault-router.ts — hash routing + pane dispatch + data loading, extracted to keep vault.ts ≤250 LOC. Owns parseHash; legacy #field-history/<id> URLs are normalized to #history/<id> here, but the internal view value stays 'field-history' so the per-item pane renders unchanged.
  • vault-shell.ts — DOM scaffolding, color-scheme apply, and the onMessage wiring for the tab.
  • vault-sidebar.ts — sidebar categories nav, 80ms-debounced search (SEARCH_DEBOUNCE_MS), and the bottom-nav (+ new item · ▦ trash · ⌬ devices · ⚙ settings · ◷ history · ⏻ lock). Also owns the footer: a #vault-status-slot plus a manual refresh button (GLYPH_REFRESH). wireSidebar calls refreshStatus() once on mount and again on the button's click — sending get_vault_status via ctx.sendMessage and rendering the result into the slot through vault-status.ts. There is no timer polling: the indicator only refreshes on mount + explicit button press, matching the spec's no-network-without-user-intent discipline (sync is user-initiated).
  • vault-status.ts — sidebar-footer sync indicator renderer. renderStatusIndicator(el, status) is pure DOM: it renders, by priority, N pending / N ahead / N behind, falling back to in sync, plus a last sync <relativeTime> / never synced line. Reuses shared/glyphs.ts (GLYPH_PENDING/AHEAD/BEHIND/SYNCED) and shared/relative-time.ts. VaultStatus is an alias of GetVaultStatusResponse['data'], so the renderer's input shape is single-sourced from the message contract and can't drift from the SW handler.
  • vault-list.ts — the list pane and its row rendering.
  • vault-drawer.ts — drawer open/close/render plus ensureDrawerClosedForRoute, which closes the drawer on any non-list navigation.
  • vault-form-wrapper.tsrenderFormWrapped plus the sticky bar and header that wrap form panes.
  • vault.html / vault.css — sidebar + pane layout.

src/vault/components/

Vault-tab-only panes (popup is too small for these workflows). Each exports render…(app) and a teardown(), same convention as popup/components/*.

  • backup-panel.ts.relbak export / restore UI. Routable as #backup (vault.ts case at :167). Drives the SW's backup handlers; the actual tar packing happens in relicario-core via WASM exports.
  • import-panel.ts — LastPass CSV importer surface. Routable as #import (vault.ts case at :168). Parses CSV client-side and pipes parsed rows through add_item SW messages.

src/setup/

  • setup.ts (58 lines) — a thin UI-only shell after the Phase 3 split: the render loop + progress track + boot + re-exports. No longer imports relicario-wasm; the wizard now drives vault creation/attach through the SW. Binds clearWizardState to window.addEventListener('beforeunload', clearWizardState) (setup.ts:53) and also calls it on goto('mode') (setup.ts:44).
  • setup-steps.ts (extracted in Phase 3) — the setup step registry + wizard state + clearWizardState + finishSetup. One-directional import (setup.tssetup-steps.ts, no cycle). Crypto orchestration no longer lives in the wizard: the device step (where deviceName exists) fires create_vault and attach_vault SW messages instead of calling WASM directly. State-coupled updateStrengthUi stays here because it walks the live wizard state.
  • setup-helpers.ts (84 lines, extracted in commit f79a67b) — pure helpers: escapeHtml, ratePassphrase, scheduleRate (150ms debounced zxcvbn round-trip), STRENGTH_LABELS, entropyText, the Strength interface.
  • probe.ts — best-effort detection of an existing vault on the remote (any of .relicario/salt, .relicario/params.json, or manifest.encexists: true). Drives the warning banner that disambiguates "new vault" vs "attach this device".

src/content/

  • detector.ts — entry. Finds password fields (skipping <20×10px honeypots), associates each with a username field via a five-priority cascade (autocomplete=username → autocomplete=email → type=email → name/id pattern → preceding visible text input), injects the id-icon, and starts a MutationObserver to rescan on SPA navigation.
  • icon.ts — the in-page autofill icon and candidate picker / TOFU-ack hint. Each overlay mounts in its own closed Shadow DOM (shadow.ts). On icon click → get_autofill_candidates; one candidate auto-fills (if origin is acked), multiple candidates show the picker.
  • fill.ts — listener for the SW-forwarded fill_credentials message. Re-checks location.href's hostname against the SW-provided expectedHost (the second of two TOCTOU gates) and writes values using the native HTMLInputElement setter trick so React/Vue pick up the change.
  • capture.ts — submit handler. Runs check_credential to ask whether the (host, username, password) tuple is already in the vault; if not, shows a save-or-update prompt in a closed Shadow DOM. The "Save" button issues capture_save_login (content-callable); the SW figures out add-vs-update and binds the new item to the sender's origin.
  • shadow.ts — closed-mode attachShadow host helper. Comments here enforce the "never innerHTML, never insertAdjacentHTML" rule — page-supplied strings (hostname, username) only ever land via textContent.

src/service-worker/

  • index.ts — thin entry. Wires the WASM init, owns the shared RouterState, plumbs chrome.runtime.onMessage and chrome.commands.onCommand (the open-vault keyboard command), resets the inactivity timer on every popup-class message, and broadcasts a session_expired notification when the timer fires.
  • router/index.ts — single classify-and-dispatch function. Determines whether a sender is popup/vault tab, setup tab, content top-frame, or none-of-the-above (router/index.ts:39-43); routes to popup-only.ts or content-callable.ts; rejects everything else with unauthorized_sender. Setup tab is allowed exactly three popup-only messages (SETUP_ALLOWED, router/index.ts:23-27): save_setup, rate_passphrase, is_unlocked.
  • router/popup-only.ts — handler match arms for every POPUP_ONLY_TYPES message. The mutation-heavy ones (add_item, update_item, delete_item) pull SessionHandle from session.getCurrent(), load via vault.fetchAndDecrypt*, mutate, re-encrypt, and gitHost.writeFile. fill_credentials lives here with its own captured-tab verification (see Key flows). New in commit a7dbf35: register_this_device. Phase 3 added create_vault and attach_vault (full SW-side vault creation/attach: embed/unlock, encrypt+push, register_device + addDevice, persist config+image, session.setCurrent; the failure path locks and frees the handle). The lock handler now also nulls state.gitHost (symmetric with session-expiry) so the status cache can't go stale across a lock→unlock. Phase 6 added get_vault_status (popup-only, read-only) — returns the cached sync summary { ahead, behind, lastSyncAt, pendingItems } with no network call. ahead/behind/lastSyncAt are read straight off state.gitHost (populated by the sync handler, which records lastSyncAt = Math.floor(Date.now()/1000) — unix seconds — after a successful manifest fetch). pendingItems is a live count of active (non-trashed) manifest entries via vault.listItems(manifest).length. ahead/behind are structurally always 0 in the extension (it writes straight to the host via the Contents REST API; there is no local commit graph) and exist for parity with relicario status.
  • router/content-callable.ts — handler match arms for every CONTENT_CALLABLE_TYPES message. Origin always derived from sender.tab.url, never from message fields. capture_save_login has a defense-in-depth check that the existing item's core.url hostname matches the sender's hostname before mutating, in case manifest icon_hint has drifted from the underlying URL.
  • vault.ts — typed-item vault operations. Crypto goes through the ambient wasm module set at SW init by setWasm; nothing here touches the master key directly. Includes findByHostname(manifest, hostname) (the autofill matcher — coarse: no www-stripping, no public-suffix), trash helpers (listTrashed, restoreItem, purgeItem, purgeAllTrash), and attachment helpers (addAttachmentToItem, removeAttachmentsFromItem, with manifest summary sync). Now also includes the create_vault/attach_vault orchestration handlers (Phase 3) and handleGetVaultStatus(state) (Phase 6) — synchronous, no network; returns the cached { ahead, behind, lastSyncAt, pendingItems }. Its Pick<GitHost,'lastSyncAt'|'ahead'|'behind'>-typed param both breaks the PopupState import cycle and structurally forbids it from making a network call.
  • session.ts — single module-scope SessionHandle | null. α assumes one vault per install. Multi-vault would replace this with a Map keyed by vault id.
  • session-timer.ts — inactivity timer. Modes: inactivity (N minutes since last popup-class message) and every_time (no timer; rely on popup-close to clear). The router resets the timer for every message that is NOT in CONTENT_CALLABLE_TYPES (service-worker/index.ts:76-78).
  • git-host.ts — abstract interface (readFile, writeFile, writeFileCreateOnly, deleteFile, listDir, lastCommit, putBlob, getBlob, deleteBlob) and the createGitHost factory. BLOB_THRESHOLD_BYTES = 900*1024 is the cutover point at which attachment writes switch from the Contents API to the Git Data API. The GitHost interface also carries cached sync metadata — lastSyncAt: number | null (unix seconds), ahead: number, behind: number — initialized to null/0/0 in both GiteaHost and GitHubHost. The cache rides the gitHost lifecycle: created on unlock and cleared whenever state.gitHost is nulled — on session-timer expiry (index.ts) and on the explicit lock message handler (popup-only.ts), which now nulls state.gitHost symmetrically so a lock→unlock cycle can't surface a stale lastSyncAt.
  • gitea.ts / github.ts — the two GitHost implementations. Both use the host's Contents API for files under threshold, and Git Data API (blobs + tree + commit) for large attachment uploads. Auth differs (Gitea: token X, GitHub: Bearer X). Both pre-check existence on write to decide between create vs update; writeFileCreateOnly refuses to clobber.
  • devices.ts — read-modify-write helpers around .relicario/devices.json. addDevice rejects duplicates by name; revokeDevice rejects unknown names.

src/shared/

  • messages.ts — every Request and Response shape, plus the capability sets POPUP_ONLY_TYPES and CONTENT_CALLABLE_TYPES the router consults. Adding a new SW message requires (a) adding it to the PopupMessage or ContentMessage union, AND (b) adding it to the matching capability set, AND (c) adding a handler arm. Forget any one of these and you get a silent rejection at runtime.
  • state.tsStateHost interface + module-scope singleton. Both popup.ts and vault.ts register themselves on boot. All popup/components/* import from here, never from popup.ts directly, so the same render code runs in both bundles. Its sendMessage wrapper intercepts vault_locked responses (lifted out of vault.ts in Phase 4, so the intercept now applies uniformly to both bundles).
  • types.ts — TypeScript mirrors of the Rust core's serde shapes: Item, ItemCore (internally-tagged on type), Field and FieldValue (adjacently-tagged on kind / value), Manifest, ManifestEntry, VaultSettings, GeneratorRequest, etc. Hand-kept in sync with crates/relicario-core/src/{item.rs,item_types/,settings.rs}.
  • base32.ts — RFC 4648 base32 encode/decode for TOTP secrets. (Pure TS; secrets never leave WASM after unlock anyway, but we store user input as bytes via base32Decode.)

Invariants & contracts

These are load-bearing rules. Some are enforced by code, some are enforced by code-review and convention; both are listed.

  • Master key never crosses the WASM boundary. It lives inside WASM linear memory wrapped in Zeroizing<[u8;32]> (Rust side); JS holds only the opaque SessionHandle (a u32 index). wasm.lock(handle) zeroes the slot; session.clearCurrent() calls it (session.ts:24-28). No popup, vault, content, or setup code can observe the key bytes.
  • Single SessionHandle per SW instance. session.ts is module-scope. α assumes one vault per install (deliberate; not an oversight).
  • Sender check on every SW message. router/index.ts:39-66 builds isPopup | isSetup | isContent from sender.url and sender.tab / sender.frameId / sender.id, then dispatches:
    • popup-only types accept popup.html OR vault.html senders (commit a7dbf35 added vault.html).
    • popup-only types ALSO accept setup.html for exactly three messages: save_setup, rate_passphrase, is_unlocked (router/index.ts:23-27).
    • content-callable types require sender.tab defined, sender.frameId === 0 (top frame), AND sender.id === chrome.runtime.id (same extension — router.test.ts:373-384 covers the third clause). Subframes and other extensions are rejected.
    • everything else: unauthorized_sender.
  • Capability sets are exhaustive. Every message must appear in exactly one of POPUP_ONLY_TYPES or CONTENT_CALLABLE_TYPES (shared/messages.ts:144-161). A message in the union but in neither set falls through to unknown_message_type and is silently rejected. This is the easy mistake to make when adding a new message type.
  • Content scripts cannot decrypt. All paths from content end with the SW returning either an opaque manifest projection (titles, hostnames) or a fully-resolved { username, password }. There is no WASM in the content bundle and no pathway for content to obtain ciphertext.
  • Origin TOFU on autofill. Before returning credentials to a content script, the SW checks VaultSettings.autofill_origin_acks[hostname] (router/content-callable.ts:46-51). Missing → return { requires_ack: true, hostname } so the icon shows the TOFU hint and the user must open the popup to ack. The ack is recorded in vault settings (encrypted, syncs across devices), keyed by hostname, to a unix timestamp.
  • Two-stage TOCTOU close on fill_credentials. The popup snapshots (capturedTabId, capturedUrl) at popup-open (popup.ts:230-233). The SW re-fetches the tab on fill, compares hostnames against the snapshot AND against the item's own core.url hostname (router/popup-only.ts:397-410), and forwards expectedHost along with the credentials. The content script's fill listener (content/fill.ts:32-43) re-checks location.href's hostname against expectedHost before typing — covering the gap between chrome.tabs.get and chrome.tabs.sendMessage.
  • Origin binding on capture. capture_save_login derives the hostname from sender.tab.url only — never from message fields. When updating an existing entry, the SW re-checks the entry's core.url hostname against the sender's hostname; mismatch → origin_mismatch (router/content-callable.ts:113-117). Otherwise a drifted manifest icon_hint could rebind a password to the wrong origin.
  • writeFileCreateOnly cannot clobber. Setup uses it for the four init artifacts (.relicario/salt, .relicario/params.json, manifest.enc, settings.enc). If any exists, it throws — the wizard catches and tells the user to switch to attach mode (setup.ts:888-893).
  • AEAD failure surfaces as "wrong passphrase". The setup attach flow stages errors and rewrites failures during derive session handle or decrypt manifest to the deliberately-ambiguous "Could not decrypt vault — wrong passphrase or reference image." (setup.ts:396-401). The popup humanizeError does the same for vault_locked, origin_mismatch, unauthorized_sender, and URL parse errors.
  • Inactivity timer modes. inactivity resets on every popup/vault/setup message (NOT on content messages — service-worker/index.ts:76-78); fires after minutes of idle. every_time has no timer; the popup-close handler is expected to clear (handled implicitly because the popup re-checks is_unlocked on each open).
  • Manifest mutation requires both writes. Any item-changing handler (add_item, update_item, delete_item, restore_item, purge_item, capture_save_login, the attachment paths) writes BOTH items/<id>.enc AND manifest.enc (the manifest entry is derived via the local itemToManifestEntry). Forgetting the second write breaks list/search/autofill until the next sync round-trip.
  • Both manifests stay in sync. manifest.json (Chrome) and manifest.firefox.json declare the same permissions, host permissions, content scripts, and CSP. Drift is a portability bug.

Key flows

First-run setup (new vault)

setup.ts, six steps. WASM is loaded at the top of step 3.

  1. Step 0 — mode picker. state.mode{ 'new', 'attach' }.
  2. Step 1 — host type (Gitea / GitHub) + per-host instructions.
  3. Step 2 — host URL + repo path + API token. Click "test connection" → gitHost.listDir('') succeeds → probeVault(host) detects existing vault. Banner disambiguates: empty repo + new mode = OK; populated repo + new mode = warn (would clobber); empty repo + attach mode = warn (no vault to attach to).
  4. Step 3 (new branch) — carrier JPEG + passphrase + confirm. zxcvbn meter via SW rate_passphrase on a 150ms debounce (setup-helpers.ts:54-63). Submit gate requires score ≥ 3 AND passphrases match.
    1. crypto.getRandomValues(imageSecret) — fresh 32-byte secret.
    2. wasm.embed_image_secret(carrierBytes, imageSecret) → reference JPEG bytes (DCT-embedded via central-embed; see core spec).
    3. crypto.getRandomValues(salt) — fresh 32-byte vault salt.
    4. wasm.unlock(passphrase, referenceJpeg, salt, paramsJson) — Argon2id derives master key inside WASM; returns SessionHandle. Note: unlock takes JPEG bytes, not the raw 32-byte secret — the WASM side extracts internally.
    5. Encrypt empty manifest + default settings. writeFileCreateOnly pushes salt, params, manifest.enc, settings.enc — refuses to clobber.
    6. wasm.lock(handle) — release. Advance to step 4.
  5. Step 3 (attach branch) — reference JPEG + passphrase. Fetches salt + params + ciphertext, runs wasm.unlock and wasm.manifest_decrypt. AEAD failure → "wrong passphrase or reference image". Success → save handle in state.verifiedHandle, advance.
  6. Step 4 — device name (default ${browser} on ${os}).
  7. Step 5 — finish. If chrome.runtime.sendMessage reaches the extension, "register this device" pushes everything in one go (setup.ts:1039-1112):
    1. wasm.generate_device_keypair(){ public_key_hex, private_key_base64 }.
    2. chrome.storage.local.set({ device_name, device_private_key }).
    3. save_setup SW message → chrome.storage.local.set({ vaultConfig, imageBase64 }).
    4. addDevice(host, ...) → read-modify-write .relicario/devices.json.
    5. wasm.lock(verifiedHandle) — release the attach-mode handle. If the extension is NOT detected, the wizard offers to download the reference JPEG and copy a JSON config blob to paste into the extension manually.

Unlock from popup

  1. Popup opens → chrome.tabs.query snapshots active tab into state.capturedTabId / state.capturedUrl (popup.ts:231-233). Used later by fill_credentials.
  2. get_setup_state → if not configured, opens setup tab and closes popup.
  3. is_unlocked → if unlocked, list_items + get_vault_settings, navigate to list. Otherwise, navigate to locked.
  4. User types passphrase → unlock SW message (router/popup-only.ts:38-55):
    1. Load vaultConfig + imageBase64 from chrome.storage.local.
    2. createGitHost if not already present.
    3. gitHost.readFile('.relicario/salt') + params.json (cached on state.gitHost for the SW lifetime).
    4. wasm.unlock(passphrase, imageBytes, salt, paramsJson)SessionHandle.
    5. Wipe msg.passphrase (best-effort — JS strings are immutable, but we drop the reference).
    6. fetchAndDecryptManifest and cache on state.manifest.

Item create from popup

  1. Form component (components/types/login.ts etc.) collects fields and emits add_item with the full Item.
  2. router/popup-only.ts:74-83:
    1. wasm.new_item_id() — 16-char hex.
    2. wasm.item_encrypt(handle, JSON.stringify(item)) → ciphertext.
    3. gitHost.writeFile('items/<id>.enc', ciphertext, "add: <title>").
    4. Update state.manifest.items[id]; re-encrypt + write manifest.enc.
  3. Popup re-renders list with the new entry.

Autofill (content-script flow)

  1. detector.ts finds password fields, icon.ts injects an icon inside a closed Shadow DOM near each.
  2. User clicks icon → get_autofill_candidates (content-callable, no url field — router derives hostname from sender.tab.url).
  3. SW: vault.findByHostname(manifest, senderHost) matches manifest.items[i].icon_hint === hostname.toLowerCase() (note: no www-stripping, no PSL — coarse on purpose for α).
  4. One candidate → content calls get_credentials. SW resolves origin match (router/content-callable.ts:42-44) and TOFU (router/content-callable.ts:46-51).
    • First time on this hostname → { requires_ack: true, hostname }. icon.ts shows the in-page hint instructing the user to open relicario; user opens popup, picks the item, and the SW path that writes the credential calls ack_autofill_origin.
    • Acked → { username, password }. fill.ts.fillFields types directly without a SW round-trip (content script IS the page origin; no need to go through the SW just to write to its own DOM). This is the only flow where credentials reach the page, and the request was originated by the user via the icon click.
  5. Multiple candidates → picker (also closed Shadow DOM). Selection → same get_credentials path.

Capture-save-login

  1. capture.ts hooks <form> submit and any submit-shaped button.
  2. On submit: findUsernameValue(pwField) + passwordcheck_credential (content-callable). SW returns one of: skip (already match), save (no match), or update (same username, different password).
  3. If not skip, capture.ts shows a save-or-update prompt in a closed Shadow DOM. Settings (capture style: bar/toast) fetched directly from chrome.storage.local to avoid round-tripping through the SW (which would also fail the router's content→popup-only check for get_settings).
  4. "Save" → capture_save_login. SW (router/content-callable.ts:99-163):
    • Update path: existing (host, username) match → defense-in-depth check that the item's core.url hostname matches sender hostname → re-encrypt only the password + modified, push.
    • Add path: build a new Login bound to the sender's origin (title = senderHost, core.url = senderOrigin), encrypt + push, update manifest.
  5. "Never" → blacklist_site. SW pushes hostname into chrome.storage.local.captureBlacklist. Future submits on this host short-circuit at step 2.

Sync (manual, post-a7dbf35)

  1. Settings view → "Sync now" (components/settings.ts:83-92) or item-list toolbar "sync" (item-list.ts:103-117).
  2. sync SW message → vault.fetchAndDecryptManifest re-pulls manifest.enc from the host and re-decrypts. No git-side push or merge — git host is the source of truth, and writes are immediate. Sync is essentially "refresh the in-memory manifest cache".
  3. Status text on the popup updates to "synced ✓" or "sync failed: ".

Device register from popup (post-a7dbf35)

  1. Devices view detects chrome.storage.local.device_name is missing from the remote device list → shows banner.
  2. User clicks "Register this device" → inline name input (devices.ts:81-119).
  3. On confirm → register_this_device SW message (router/popup-only.ts:313-329):
    1. wasm.generate_device_keypair(){ public_key_hex, private_key_base64 }.
    2. chrome.storage.local.set({ device_name, device_private_key }).
    3. devices.addDevice(host, ...) → read-modify-write .relicario/devices.json.
  4. Devices view re-renders; banner gone.

Session lock (timer-driven)

  1. service-worker/index.ts:51-58 registers onExpired callback at SW boot.
  2. Every popup-class message resets the timer (every content-callable message does NOT — page-side traffic shouldn't keep the vault unlocked; service-worker/index.ts:76-78).
  3. After the configured idle window: callback fires → session.clearCurrent() (zeroes WASM key) → state.manifest = null → broadcast { type: 'session_expired' }.
  4. Popup and vault tab listen for that broadcast and snap back to the locked view (popup.ts:299-307, vault.ts:521-531).

Trash + purge

  1. delete_item is a soft-delete: the item gets a trashed_at and is re-encrypted; the manifest entry mirrors that. List views filter trashed_at !== undefined.
  2. list_trashed returns trashed entries sorted newest-first.
  3. restore_item clears trashed_at and bumps modified.
  4. purge_item deletes the encrypted item + every attachment blob in its attachment_summaries, removes the manifest entry, and rewrites manifest.enc.
  5. purge_all_trash purges every trashed item AND scans attachments/ for orphan blobs (not referenced by any remaining manifest entry) and deletes them. Returns { itemCount, orphanCount }.

Cross-cutting concerns

State sharing across bundles

shared/state.ts is a service-locator for the popup component layer. It defines a StateHost interface (getState, setState, navigate, sendMessage, escapeHtml, popOutToTab, isInTab, openVaultTab) and a single module-scope host slot. popup.ts and vault.ts each call registerHost({...}) at boot with their own implementations of those methods. The popup/components/* files only know the locator; they never import from popup.ts or vault.ts.

This is why every component renderer takes app: HTMLElement: the host gives the component the mount point, and the locator gives the component everything else (current state, message channel, navigation). The same renderItemDetail runs unchanged in the 360px popup and the fullscreen vault tab — the host's getState() projects different state shapes that happen to share field names.

Error surface

All SW handlers return { ok: true, data?: ... } | { ok: false, error: string }. Conventions:

  • Vault-state errors (vault_locked, item_not_found, not_a_login, no_totp, attachment_not_found) are bare snake_case strings the popup can pattern-match in humanizeError (popup.ts:135-160).
  • Origin / sender errors (origin_mismatch, tab_navigated, captured_tab_gone, unauthorized_sender, origin_changed) are also bare strings; they're the security-sensitive ones and must remain testable by handler-level tests (router.test.ts:237-285).
  • Crypto failures bubble up as Rust error strings via wasm-bindgen. AEAD authentication failures are deliberately conflated with "wrong passphrase" (no oracle for "right passphrase, wrong image").
  • Network / git-host failures bubble up as native Error instances that the SW catches in service-worker/index.ts:93-97 and flattens to { ok: false, error: err.message }.

TS ↔ Rust type sync

shared/types.ts mirrors the Rust core's serde shapes. Internally-tagged enums (ItemCore) match #[serde(tag = "type")]; adjacently-tagged enums (FieldValue) match #[serde(tag = "kind", content = "value")]. Optional fields use ? because Rust's #[serde(skip_serializing_if = "Option::is_none")] omits them and serde_wasm_bindgen produces undefined. r#type Rust → type JSON key. The mirror is hand-kept; if a Rust field changes, the TS shape must be updated explicitly. Drift = silent runtime crash on first encounter with a value the TS type says is impossible.

Storage layout

Local (chrome.storage.local):

Key Set by Holds
vaultConfig setup save_setup { hostType, hostUrl, repoPath, apiToken }
imageBase64 setup save_setup reference JPEG bytes (base64). Re-read on every unlock.
device_name setup / register This device's name (must match a remote device record)
device_private_key setup / register base64 ed25519 private key. Highest-value device-local secret.
relicarioSettings popup settings DeviceSettings (capture toggle + style)
captureBlacklist content blacklist_site / popup remove_blacklist string[] of hostnames
session_timeout popup update_session_config SessionTimeoutConfig — restored on SW boot

Remote (the git repo):

  • .relicario/salt — 32-byte vault salt (KDF input).
  • .relicario/params.json — Argon2id parameters (m, t, p).
  • .relicario/devices.json{ devices: Device[] }.
  • manifest.enc — XChaCha20-Poly1305 ciphertext of the manifest.
  • items/<id>.enc — per-item ciphertext.
  • attachments/<aid>.bin — content-addressed encrypted attachment blobs.
  • settings.enc — vault settings (retention + caps + generator defaults + autofill_origin_acks) ciphertext.

The remote is end-to-end encrypted; the host (Gitea/GitHub) sees only opaque ciphertext. chrome.storage.local is NOT encrypted, so device_private_key is the user's "this device" credential — losing the local profile means revoking the device server-side and creating a new keypair, but a non-zero local-attacker model. Documented in the design spec.

Two GitHosts

gitea.ts and github.ts implement the GitHost interface (git-host.ts:7-44). They diverge on:

  • Auth header (token X vs Bearer X).
  • Read response shape (both base64-content; GitHub adds \n line breaks the Gitea endpoint sometimes also adds — both implementations strip).
  • Update semantics (Gitea has separate POST-create / PUT-update; GitHub's PUT is create-or-update, so the SHA presence is what decides).
  • Large-blob path. Both switch from Contents API to Git Data API above BLOB_THRESHOLD_BYTES; the API shapes differ but both produce a commit on the default branch.

Adding a third host (Codeberg, Gitlab) = implement GitHost, add a case to createGitHost (git-host.ts:74-84), and surface the option in setup.ts step 1.

Test architecture

Tests run under vitest with happy-dom (extension/vitest.config.ts). There is no real browser in CI; the tests cover logic that is browser-API-shaped but doesn't actually touch a real Chrome.

Patterns:

  • globalThis.chrome shim at the top of each test (router/__tests__/router.test.ts:36-45). Stubs only what the test needs: chrome.runtime.id, chrome.runtime.getURL, chrome.storage.local.{get,set}, chrome.tabs.{get,sendMessage}.
  • Module mocks via vi.mock for the SW's vault and session modules (router/__tests__/router.test.ts:10-27) so router tests don't pull in WASM. The vi.mock(..., importOriginal) form keeps the real findByHostname/listItems while overriding the encrypt/decrypt boundary.
  • Component tests (popup/components/__tests__/*.test.ts) mock shared/state so sendMessage / navigate / etc. become spies, and assert that the rendered DOM has the right shape and that user actions emit the right SW messages.

Coverage highlights:

  • service-worker/router/__tests__/router.test.ts — exhaustive sender matrix: each popup-only and content-callable type tested from popup, vault tab, setup tab, top-frame content, and an "external"/wrong-extension-id sender. The vault-tab-as-popup acceptance was added in commit a7dbf35. Setup-tab exception scope (save_setup, rate_passphrase, is_unlocked allowed; unlock, fill_credentials rejected) verified explicitly. Also covers the fill_credentials TOCTOU verification, capture add/update/origin-mismatch paths, get_totp on both Login.totp and standalone Totp.config, and vault-settings get/set.
  • service-worker/__tests__/devices.test.ts — devices.json read/modify/write semantics (add/revoke).
  • service-worker/__tests__/git-host*.test.ts — Contents API vs Git Data API switching, SHA-on-update behavior.
  • service-worker/__tests__/session-timer.test.tsinactivity vs every_time modes; reset/stop semantics.
  • service-worker/__tests__/trash.test.ts — soft-delete, restore, purge, orphan-blob cleanup.
  • popup/components/__tests__/devices.test.ts — devices view including the new register-this-device inline flow.
  • popup/components/__tests__/settings.test.ts — sync button + feedback (added in commit a7dbf35).
  • popup/components/__tests__/{attachments-disclosure,field-history, fields,generator-panel,sections-{editor,render},settings-vault,trash}.test.ts — per-component coverage.
  • popup/components/types/__tests__/*.save.test.ts — each item type's form-to-Item serialization.
  • setup/__tests__/probe.test.ts — vault-detection probe.
  • shared/__tests__/base32.test.ts — RFC 4648 vectors.

Test-vs-build gap: tests run with happy-dom and stub crypto. Browser-API semantics that depend on a real engine — service-worker restart behavior, real chrome.tabs.sendMessage delivery timing, chrome.runtime.lastError paths, MV3 cold-start bundle execution — are NOT exercised. Treat tests as a logic-bug net, not a browser-bug net; manual smoke-testing in both Chrome and Firefox is still required before shipping.

Gotchas & non-obvious decisions

  • Why the popup never loads WASM directly. Crypto in one place (the SW) means one set of bundle-size and CSP concerns. The popup message round-trips are cheap enough; the architectural win is worth more than the latency.
  • Why setup loads WASM directly anyway. Setup needs to derive a master key, encrypt an empty manifest, and push it to the remote BEFORE chrome.storage.local.vaultConfig exists for the SW to read. There's no SessionHandle to pass to the SW yet, and the SW's unlock handler reads config from local storage — chicken-and-egg. Setup's WASM module is independent of the SW's; both share the same bytes but each has its own linear memory.
  • Why vault.html is treated as popup-class. The audit flagged that fullscreen workflows (settings-vault editor, future backup/restore, future LastPass importer, devices) need more space than the popup gives. Rather than introducing a third class of sender, the router was extended to accept vault.html as a popup-equivalent — the message vocabulary is identical, just the surface is bigger. Commit a7dbf35.
  • Why setup.ts is huge but not split per-step. A previous audit recommended one-module-per-step; that risked introducing flow bugs in a hand-tested wizard. Instead, only the pure helpers (no wizard state) were extracted (setup-helpers.ts, commit f79a67b). The step renderers and their event handlers stay inline because they share state heavily and re-render on almost every input.
  • Why every "view" is just a render-into-#app function. No framework. The popup is small enough that a 50-line state machine in popup.ts plus per-view render functions is shorter and faster than React. The StateHost indirection lets the same components render in the vault tab without changes — the price of "no framework" is paid by shared/state.ts, which is 62 lines.
  • Why the SW caches manifest and gitHost in module memory. Service workers in MV3 are restartable but persistent during activity; caching avoids re-decrypting the manifest on every popup-open (which is constant) and re-fetching salt + params on every unlock would be wasteful. On lock, state.manifest is cleared (router/popup-only.ts:60) and on session_expired too (service-worker/index.ts:55-56).
  • Why content scripts have direct chrome.storage.local access. The storage permission applies to all extension contexts. Content uses it for capture style settings (capture.ts:101-103) because routing through the SW would fail the router's content→popup-only check for get_settings, and adding a content-callable variant would expand the attack surface.
  • Why device_private_key lives in chrome.storage.local even though it's a long-term secret. The "device" IS the local machine; the user is implicitly trusting whatever can read chrome.storage.local (the same threat model as the SW's session state). Promoting the key into the SW's WASM linear memory wouldn't help — a local attacker capable of reading chrome.storage.local is also capable of attaching a debugger to the SW. The correct mitigation is OS-level (full-disk encryption) and remote-side (revoke on loss).
  • Why capture_save_login is a single message with internal add-vs-update branching. Two messages (capture_add / capture_update) would let a malicious page guess which one was expected and craft a request to mutate an existing entry's password on a sibling host. Funneling through one handler that derives origin server-side and chooses the path itself eliminates that class of bug.
  • Why findByHostname is intentionally coarse. No www.-stripping, no public-suffix matching: in α, github.com and www.github.com saved logins are independent. Smarter matching has UX failure modes (filling subdomain credentials cross-site) that need design before code; tracked for 1C-β/γ. See service-worker/vault.ts:127-142.
  • Why the inactivity timer ignores content-callable messages. A page making periodic background fetches (e.g. SSE, polling) shouldn't keep the vault unlocked indefinitely. Only popup/vault tab activity counts as "user is at the keyboard" (service-worker/index.ts:76-78).
  • Why is_unlocked is in the setup-tab allowlist. Setup's step-5 detects whether the extension is reachable; pinging is_unlocked is the cheapest available probe, and the response is non-sensitive (a boolean). The two other allowed messages (save_setup, rate_passphrase) are unavoidable.
  • Why fill goes through the SW for the credential resolution but the actual DOM write happens in content. The SW knows which hostname the active tab is on and can match the right item; but once the credentials are resolved and bound to expectedHost, the content script is the only context with DOM access. The SW could chrome.tabs.executeScript to inject a one-shot writer, but that doubles the attack surface for no benefit — the content script already has DOM access by the time the page is loaded.
  • Why setup uses webpackIgnore to load WASM. Webpack would otherwise try to chunk-split or inline relicario_wasm.js, breaking the wasm-pack runtime expectation that it lives at a stable URL next to relicario_wasm_bg.wasm. The runtime calls WebAssembly.instantiateStreaming(fetch(URL)) against a hardcoded path; we just hand it that path.

End of tour. For roadmap and in-flight work see ../STATUS.md and ../ROADMAP.md.