Strategic-depth architecture documentation, the kind that's hard to
recover by reading code: invariants, multi-file flows, design rationale,
gotchas. Goal is to cut the token cost for future Claude sessions.
Four new docs (2091 lines total):
- crates/relicario-core/ARCHITECTURE.md (514 lines) — bytes-in/bytes-out
boundary, 24 verified invariants (VERSION_BYTE=0x02, length-prefixed
KDF input, NFC normalization, content-addressed AttachmentId, history-
tracked field kinds, 60% imgsecret confidence floor, MAX_DIMENSION=
10000, etc.), 7 multi-module flows, 16 non-obvious gotchas (QUANT_STEP=
50, central-70%-embed, BIP39-128bit-then-truncate, Steam alphabet
rationale).
- crates/relicario-cli/ARCHITECTURE.md (539 lines) — module map for the
three source files; the cmd_add/cmd_edit per-type helper pattern (post-
2026-04-27 refactor); the hardened-git invariant (Command::new("git")
is gated to helpers.rs:46); the five history synthetic keys; the env-
var escape-hatch policy; cmd_generate's two-mode design (no-unlock
outside vault, unlock-and-read-defaults inside).
- extension/ARCHITECTURE.md (831 lines) — five-bundle structure (popup,
vault, setup, content, service-worker); SW-as-crypto-fortress model;
capability-set-or-silent-rejection contract; vault-tab-as-popup-class
router parity (commit a7dbf35); origin TOFU flow; setup state machine;
test-vs-build gap.
- docs/architecture/overview.md (207 lines) — cross-codebase entry point.
How the three codebases fit together, the four versioned wire formats
between them (core→WASM ABI, SW chrome.runtime protocol, vault on-disk
layout, GitHost API), per-codebase secret residency table, build
matrix, conventions that span all three.
Specs in docs/superpowers/specs/ remain as historical decision artifacts
("why we chose this") — the new arch docs are the source of truth for
"what is" current invariants and flows.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
44 KiB
Architecture: relicario extension
Strategic-depth doc for the
extension/codebase. Pairs with/CLAUDE.mdat the repo root (project-level summary) and the typed-items design spec underdocs/superpowers/specs/. Things that are easy to recover from reading code are deliberately omitted; things that are not — invariants, multi-file control flow, design rationale — go here.
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) | yes — direct dynamic import (predates SW handle) |
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
SessionHandleand decryptedManifestever 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 bothGitHosts, owns the inactivity timer (session-timer.ts), and reads/writeschrome.storage.localfor device-local state. - popup — small MV3 popup at
popup.html. Locked-or-list state machine, search/sort/edit, attachments + TOTP. Cannot accessSessionHandledirectly — every operation is achrome.runtime.sendMessageto the SW. - vault — fullscreen "desktop-like" sidebar+pane shell. Imports the
same component renderers as the popup via the
StateHostservice locator (see Cross-cutting). The vault tab is Chrome-only because Firefox MV3 still treatschrome.tabs.createto 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>) atdocument_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:
- The SW initializes via
initSync(new WebAssembly.Module(bytes))when running as a real service worker (no top-level await), and the default asyncinitDefault(url)path otherwise (jest-style harness or fallback). Seeservice-worker/index.ts:24-35. - 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 (Viewenum: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 sharedStateHost.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. CallsunlockSW message; on success, fetcheslist_itemsand navigates tolist.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 ofcomponents/types/{login,secure-note,identity,card,key,document,totp}.ts.components/types/*.ts— per-item-type detail+form pairs. Each exportsrenderDetail,renderForm, andteardown. Uses the sharedfields.tsprimitives (concealed rows, signature blocks, sections editor) and theattachments-disclosure.tswidget.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.generator-panel.ts— inline password / passphrase generator. Mounts inside any host element; round-trips knob changes through the SW'sgenerate_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 — seerouter/popup-only.ts:223-228).settings.ts— device-local UX settings (capture toggle, prompt style), trash/devices/sync-now buttons, blacklist editor.settings-vault.ts— vault-wide settings (retention, generator defaults, autofill origin acks). Reads/writes via the SW'sget_vault_settings/update_vault_settings.trash.ts— soft-delete listing with restore + purge buttons.devices.ts— device list with revoke. Inline "register this device" flow lives here (banner shown when current device is not in the list); see commita7dbf35.field-history.ts— audit-log of value changes on a single item; driven by the SW'sget_field_historywhich calls into WASMget_field_history(item_json).
src/vault/
vault.ts— fullscreen tab entry. Hash-based router (#detail/<id>,#add/<type>,#trash,#devices,#settings,#settings-vault,#field-history). Registers itself as the StateHost so allpopup/components/*renderers run unchanged. Maintains its ownselectedItemcache so hash navigation between already-loaded items doesn't refetch.vault.html/vault.css— sidebar + pane layout.
src/setup/
setup.ts(1137 lines) — the wizard state machine. Six steps (0..5): mode picker (new vault / attach this device), host type (Gitea/GitHub), host config + connection test + repo probe, the forking step 3 (create-vault vs attach-this-device), device name, finish. Loads WASM directly. State-coupledupdateStrengthUistays here because it walks the live wizard state.setup-helpers.ts(84 lines, extracted in commitf79a67b) — pure helpers:escapeHtml,ratePassphrase,scheduleRate(150ms debounced zxcvbn round-trip),STRENGTH_LABELS,entropyText, theStrengthinterface.probe.ts— best-effort detection of an existing vault on the remote (any of.relicario/salt,.relicario/params.json, ormanifest.enc→exists: 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 theid-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-forwardedfill_credentialsmessage. Re-checkslocation.href's hostname against the SW-providedexpectedHost(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. Runscheck_credentialto 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 issuescapture_save_login(content-callable); the SW figures out add-vs-update and binds the new item to the sender's origin.shadow.ts— closed-modeattachShadowhost helper. Comments here enforce the "never innerHTML, never insertAdjacentHTML" rule — page-supplied strings (hostname, username) only ever land viatextContent.
src/service-worker/
index.ts— thin entry. Wires the WASM init, owns the sharedRouterState, plumbschrome.runtime.onMessageandchrome.commands.onCommand(theopen-vaultkeyboard command), resets the inactivity timer on every popup-class message, and broadcasts asession_expirednotification 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 topopup-only.tsorcontent-callable.ts; rejects everything else withunauthorized_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 everyPOPUP_ONLY_TYPESmessage. The mutation-heavy ones (add_item,update_item,delete_item) pullSessionHandlefromsession.getCurrent(), load viavault.fetchAndDecrypt*, mutate, re-encrypt, andgitHost.writeFile.fill_credentialslives here with its own captured-tab verification (see Key flows). New in commita7dbf35:register_this_device.router/content-callable.ts— handler match arms for everyCONTENT_CALLABLE_TYPESmessage. Origin always derived fromsender.tab.url, never from message fields.capture_save_loginhas a defense-in-depth check that the existing item'score.urlhostname matches the sender's hostname before mutating, in case manifesticon_hinthas drifted from the underlying URL.vault.ts— typed-item vault operations. Crypto goes through the ambientwasmmodule set at SW init bysetWasm; nothing here touches the master key directly. IncludesfindByHostname(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).session.ts— single module-scopeSessionHandle | null. α assumes one vault per install. Multi-vault would replace this with aMapkeyed by vault id.session-timer.ts— inactivity timer. Modes:inactivity(N minutes since last popup-class message) andevery_time(no timer; rely on popup-close to clear). The router resets the timer for every message that is NOT inCONTENT_CALLABLE_TYPES(service-worker/index.ts:76-78).git-host.ts— abstract interface (readFile,writeFile,writeFileCreateOnly,deleteFile,listDir,lastCommit,putBlob,getBlob,deleteBlob) and thecreateGitHostfactory.BLOB_THRESHOLD_BYTES = 900*1024is the cutover point at which attachment writes switch from the Contents API to the Git Data API.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;writeFileCreateOnlyrefuses to clobber.devices.ts— read-modify-write helpers around.relicario/devices.json.addDevicerejects duplicates by name;revokeDevicerejects unknown names.
src/shared/
messages.ts— everyRequestandResponseshape, plus the capability setsPOPUP_ONLY_TYPESandCONTENT_CALLABLE_TYPESthe router consults. Adding a new SW message requires (a) adding it to thePopupMessageorContentMessageunion, 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.ts—StateHostinterface + module-scope singleton. Bothpopup.tsandvault.tsregister themselves on boot. Allpopup/components/*import from here, never from popup.ts directly, so the same render code runs in both bundles.types.ts— TypeScript mirrors of the Rust core's serde shapes:Item,ItemCore(internally-tagged ontype),FieldandFieldValue(adjacently-tagged onkind/value),Manifest,ManifestEntry,VaultSettings,GeneratorRequest, etc. Hand-kept in sync withcrates/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 viabase32Decode.)
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 opaqueSessionHandle(au32index).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.tsis module-scope. α assumes one vault per install (deliberate; not an oversight). - Sender check on every SW message.
router/index.ts:39-66buildsisPopup | isSetup | isContentfromsender.urlandsender.tab/sender.frameId/sender.id, then dispatches:- popup-only types accept
popup.htmlORvault.htmlsenders (commita7dbf35addedvault.html). - popup-only types ALSO accept
setup.htmlfor exactly three messages:save_setup,rate_passphrase,is_unlocked(router/index.ts:23-27). - content-callable types require
sender.tabdefined,sender.frameId === 0(top frame), ANDsender.id === chrome.runtime.id(same extension —router.test.ts:373-384covers the third clause). Subframes and other extensions are rejected. - everything else:
unauthorized_sender.
- popup-only types accept
- Capability sets are exhaustive. Every message must appear in
exactly one of
POPUP_ONLY_TYPESorCONTENT_CALLABLE_TYPES(shared/messages.ts:144-161). A message in the union but in neither set falls through tounknown_message_typeand 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 owncore.urlhostname (router/popup-only.ts:397-410), and forwardsexpectedHostalong with the credentials. The content script's fill listener (content/fill.ts:32-43) re-checkslocation.href's hostname againstexpectedHostbefore typing — covering the gap betweenchrome.tabs.getandchrome.tabs.sendMessage. - Origin binding on capture.
capture_save_loginderives the hostname fromsender.tab.urlonly — never from message fields. When updating an existing entry, the SW re-checks the entry'score.urlhostname against the sender's hostname; mismatch →origin_mismatch(router/content-callable.ts:113-117). Otherwise a drifted manifesticon_hintcould rebind a password to the wrong origin. writeFileCreateOnlycannot 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 handleordecrypt manifestto the deliberately-ambiguous "Could not decrypt vault — wrong passphrase or reference image." (setup.ts:396-401). The popuphumanizeErrordoes the same forvault_locked,origin_mismatch,unauthorized_sender, and URL parse errors. - Inactivity timer modes.
inactivityresets on every popup/vault/setup message (NOT on content messages —service-worker/index.ts:76-78); fires afterminutesof idle.every_timehas no timer; the popup-close handler is expected to clear (handled implicitly because the popup re-checksis_unlockedon 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 BOTHitems/<id>.encANDmanifest.enc(the manifest entry is derived via the localitemToManifestEntry). Forgetting the second write breaks list/search/autofill until the next sync round-trip. - Both manifests stay in sync.
manifest.json(Chrome) andmanifest.firefox.jsondeclare 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.
- Step 0 — mode picker.
state.mode∈{ 'new', 'attach' }. - Step 1 — host type (Gitea / GitHub) + per-host instructions.
- 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). - Step 3 (new branch) — carrier JPEG + passphrase + confirm.
zxcvbn meter via SW
rate_passphraseon a 150ms debounce (setup-helpers.ts:54-63). Submit gate requires score ≥ 3 AND passphrases match.crypto.getRandomValues(imageSecret)— fresh 32-byte secret.wasm.embed_image_secret(carrierBytes, imageSecret)→ reference JPEG bytes (DCT-embedded via central-embed; see core spec).crypto.getRandomValues(salt)— fresh 32-byte vault salt.wasm.unlock(passphrase, referenceJpeg, salt, paramsJson)— Argon2id derives master key inside WASM; returnsSessionHandle. Note:unlocktakes JPEG bytes, not the raw 32-byte secret — the WASM side extracts internally.- Encrypt empty manifest + default settings.
writeFileCreateOnlypushes salt, params, manifest.enc, settings.enc — refuses to clobber. wasm.lock(handle)— release. Advance to step 4.
- Step 3 (attach branch) — reference JPEG + passphrase. Fetches
salt + params + ciphertext, runs
wasm.unlockandwasm.manifest_decrypt. AEAD failure → "wrong passphrase or reference image". Success → save handle instate.verifiedHandle, advance. - Step 4 — device name (default
${browser} on ${os}). - Step 5 — finish. If
chrome.runtime.sendMessagereaches the extension, "register this device" pushes everything in one go (setup.ts:1039-1112):wasm.generate_device_keypair()→{ public_key_hex, private_key_base64 }.chrome.storage.local.set({ device_name, device_private_key }).save_setupSW message →chrome.storage.local.set({ vaultConfig, imageBase64 }).addDevice(host, ...)→ read-modify-write.relicario/devices.json.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
- Popup opens →
chrome.tabs.querysnapshots active tab intostate.capturedTabId/state.capturedUrl(popup.ts:231-233). Used later byfill_credentials. get_setup_state→ if not configured, opens setup tab and closes popup.is_unlocked→ if unlocked,list_items+get_vault_settings, navigate tolist. Otherwise, navigate tolocked.- User types passphrase →
unlockSW message (router/popup-only.ts:38-55):- Load
vaultConfig+imageBase64fromchrome.storage.local. createGitHostif not already present.gitHost.readFile('.relicario/salt')+params.json(cached onstate.gitHostfor the SW lifetime).wasm.unlock(passphrase, imageBytes, salt, paramsJson)→SessionHandle.- Wipe
msg.passphrase(best-effort — JS strings are immutable, but we drop the reference). fetchAndDecryptManifestand cache onstate.manifest.
- Load
Item create from popup
- Form component (
components/types/login.tsetc.) collects fields and emitsadd_itemwith the full Item. router/popup-only.ts:74-83:wasm.new_item_id()— 16-char hex.wasm.item_encrypt(handle, JSON.stringify(item))→ ciphertext.gitHost.writeFile('items/<id>.enc', ciphertext, "add: <title>").- Update
state.manifest.items[id]; re-encrypt + writemanifest.enc.
- Popup re-renders list with the new entry.
Autofill (content-script flow)
detector.tsfinds password fields,icon.tsinjects an icon inside a closed Shadow DOM near each.- User clicks icon →
get_autofill_candidates(content-callable, nourlfield — router derives hostname fromsender.tab.url). - SW:
vault.findByHostname(manifest, senderHost)matchesmanifest.items[i].icon_hint === hostname.toLowerCase()(note: no www-stripping, no PSL — coarse on purpose for α). - 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.tsshows the in-page hint instructing the user to open relicario; user opens popup, picks the item, and the SW path that writes the credential callsack_autofill_origin. - Acked →
{ username, password }.fill.ts.fillFieldstypes 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.
- First time on this hostname →
- Multiple candidates → picker (also closed Shadow DOM).
Selection → same
get_credentialspath.
Capture-save-login
capture.tshooks<form>submit and any submit-shaped button.- On submit:
findUsernameValue(pwField)+password→check_credential(content-callable). SW returns one of:skip(already match),save(no match), orupdate(same username, different password). - If not skip,
capture.tsshows a save-or-update prompt in a closed Shadow DOM. Settings (capture style: bar/toast) fetched directly fromchrome.storage.localto avoid round-tripping through the SW (which would also fail the router's content→popup-only check forget_settings). - "Save" →
capture_save_login. SW (router/content-callable.ts:99-163):- Update path: existing
(host, username)match → defense-in-depth check that the item'score.urlhostname 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.
- Update path: existing
- "Never" →
blacklist_site. SW pushes hostname intochrome.storage.local.captureBlacklist. Future submits on this host short-circuit at step 2.
Sync (manual, post-a7dbf35)
- Settings view → "Sync now" (
components/settings.ts:83-92) or item-list toolbar "sync" (item-list.ts:103-117). syncSW message →vault.fetchAndDecryptManifestre-pullsmanifest.encfrom 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".- Status text on the popup updates to "synced ✓" or "sync failed: ".
Device register from popup (post-a7dbf35)
- Devices view detects
chrome.storage.local.device_nameis missing from the remote device list → shows banner. - User clicks "Register this device" → inline name input
(
devices.ts:81-119). - On confirm →
register_this_deviceSW message (router/popup-only.ts:313-329):wasm.generate_device_keypair()→{ public_key_hex, private_key_base64 }.chrome.storage.local.set({ device_name, device_private_key }).devices.addDevice(host, ...)→ read-modify-write.relicario/devices.json.
- Devices view re-renders; banner gone.
Session lock (timer-driven)
service-worker/index.ts:51-58registersonExpiredcallback at SW boot.- 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). - After the configured idle window: callback fires →
session.clearCurrent()(zeroes WASM key) →state.manifest = null→ broadcast{ type: 'session_expired' }. - 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
delete_itemis a soft-delete: the item gets atrashed_atand is re-encrypted; the manifest entry mirrors that. List views filtertrashed_at !== undefined.list_trashedreturns trashed entries sorted newest-first.restore_itemclearstrashed_atand bumpsmodified.purge_itemdeletes the encrypted item + every attachment blob in itsattachment_summaries, removes the manifest entry, and rewritesmanifest.enc.purge_all_trashpurges every trashed item AND scansattachments/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 inhumanizeError(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
Errorinstances that the SW catches inservice-worker/index.ts:93-97and 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 XvsBearer X). - Read response shape (both base64-content; GitHub adds
\nline 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.chromeshim 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.mockfor the SW'svaultandsessionmodules (router/__tests__/router.test.ts:10-27) so router tests don't pull in WASM. Thevi.mock(..., importOriginal)form keeps the realfindByHostname/listItemswhile overriding the encrypt/decrypt boundary. - Component tests (
popup/components/__tests__/*.test.ts) mockshared/statesosendMessage/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 commita7dbf35. Setup-tab exception scope (save_setup,rate_passphrase,is_unlockedallowed;unlock,fill_credentialsrejected) verified explicitly. Also covers thefill_credentialsTOCTOU 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.ts—inactivityvsevery_timemodes; 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 commita7dbf35).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.vaultConfigexists for the SW to read. There's noSessionHandleto pass to the SW yet, and the SW'sunlockhandler 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.htmlis 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 acceptvault.htmlas a popup-equivalent — the message vocabulary is identical, just the surface is bigger. Commita7dbf35. - 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, commitf79a67b). The step renderers and their event handlers stay inline because they sharestateheavily and re-render on almost every input. - Why every "view" is just a render-into-
#appfunction. No framework. The popup is small enough that a 50-line state machine inpopup.tsplus per-view render functions is shorter and faster than React. TheStateHostindirection lets the same components render in the vault tab without changes — the price of "no framework" is paid byshared/state.ts, which is 62 lines. - Why the SW caches
manifestandgitHostin 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-fetchingsalt+paramson every unlock would be wasteful. Onlock,state.manifestis cleared (router/popup-only.ts:60) and onsession_expiredtoo (service-worker/index.ts:55-56). - Why content scripts have direct
chrome.storage.localaccess. Thestoragepermission 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 forget_settings, and adding a content-callable variant would expand the attack surface. - Why
device_private_keylives inchrome.storage.localeven though it's a long-term secret. The "device" IS the local machine; the user is implicitly trusting whatever can readchrome.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 readingchrome.storage.localis 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_loginis 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
findByHostnameis intentionally coarse. No www.-stripping, no public-suffix matching: in α,github.comandwww.github.comsaved logins are independent. Smarter matching has UX failure modes (filling subdomain credentials cross-site) that need design before code; tracked for 1C-β/γ. Seeservice-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_unlockedis in the setup-tab allowlist. Setup's step-5 detects whether the extension is reachable; pingingis_unlockedis 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 couldchrome.tabs.executeScriptto 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
webpackIgnoreto load WASM. Webpack would otherwise try to chunk-split or inlinerelicario_wasm.js, breaking the wasm-pack runtime expectation that it lives at a stable URL next torelicario_wasm_bg.wasm. The runtime callsWebAssembly.instantiateStreaming(fetch(URL))against a hardcoded path; we just hand it that path.