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>
832 lines
44 KiB
Markdown
832 lines
44 KiB
Markdown
# Architecture: relicario extension
|
||
|
||
> Strategic-depth doc for the `extension/` codebase. Pairs with `/CLAUDE.md`
|
||
> at the repo root (project-level summary) and the typed-items design spec
|
||
> under `docs/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 `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 `GitHost`s, 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.
|
||
- `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.
|
||
- `settings-vault.ts` — vault-wide settings (retention, generator
|
||
defaults, autofill origin acks). Reads/writes via the SW's
|
||
`get_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 commit `a7dbf35`.
|
||
- `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)`.
|
||
|
||
### `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 all
|
||
`popup/components/*` renderers run unchanged. Maintains its own
|
||
`selectedItem` cache 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-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.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 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`.
|
||
- `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).
|
||
- `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.
|
||
- `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.ts` — `StateHost` 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.
|
||
- `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)` + `password` →
|
||
`check_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: <error>".
|
||
|
||
### 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.ts` — `inactivity`
|
||
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.
|