docs(arch): per-codebase ARCHITECTURE.md + cross-codebase overview
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>
This commit is contained in:
831
extension/ARCHITECTURE.md
Normal file
831
extension/ARCHITECTURE.md
Normal file
@@ -0,0 +1,831 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user