# relicario — Extension Plan 1C-α (Foundation) Design First of three sub-plans that port the browser extension from the v1 single-`Entry` data model to the typed-item model landed in Plans 1A + 1B. 1C-α is the **foundation slice**: rebuild the WASM artifact, migrate shared types, rewrite the service worker against the opaque `SessionHandle` surface, split the message router with sender checks, wire the full security architecture from the typed-items spec, and achieve Login-parity on the new stack. Other six item types show "Coming in 1C-β" placeholders. This spec references the broader design at `docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md` — read that for the cryptographic envelope, data-model rationale, and threat model. This document is the extension-side implementation design for the first of the three 1C sub-plans. ## Plan 1C decomposition | Sub-plan | Scope | |---|---| | **1C-α (this spec)** | WASM rebuild, shared types, service-worker rewrite, router split, security architecture, Login-parity popup, setup-wizard zxcvbn, Firefox parity | | 1C-β | Per-type forms for the other six types, sections + custom fields, full vault-settings UI, generator-request UI | | 1C-γ | Attachments (with `putBlob` Git-Data-API fallback), trash view, field-history view, device management | Each sub-plan gets its own spec → plan → implementation cycle. ## Design Decisions Captured during brainstorming: | Question | Decision | Why | |---|---|---| | How many sub-plans? | Three (α/β/γ) | Single plan is too large to review or execute without drift; three sub-plans give natural checkpoints | | What's "done" for 1C-α? | **Login-parity on the new stack** — all existing single-`Entry` flows re-expressed as `Item::Login`; other six types show "coming soon" | Validates the full pipeline end-to-end (item + manifest + git commit) before β's UI sprawl; keeps extension usable during β | | Firefox in scope for α? | Yes, concurrent with Chrome | Shared TS source; marginal extra cost; avoids mid-β surprises from silent rot | | Where do capture UX prefs + blacklist live? | `chrome.storage.local` (device-local) | TOFU origin-ack is a security posture (vault-level); capture prompt style is a UX preference that genuinely differs per device; blacklist churn pollutes git log | | zxcvbn strength gate in α or β? | **α** | Audit H3 (security); leaving a weak-passphrase window open during β is the same shape of mistake as leaving autofill origin-unbound during α | | Sequencing | **Bottom-up six-slice** (WASM artifact → types → SW vault/session → router → security → popup rewire + zxcvbn) | Matches Plan 1B's small-task cadence; gives the plan-executor clean checkpoints | ## Scope ### In - **WASM artifact** rebuilt from `relicario-wasm` crate (replacing stale `idfoto_wasm*` files). - **Shared TypeScript types / messages** migrated to the typed-item surface (`Item`, `ItemCore`, `Manifest` v2, `AttachmentRef`, plus the minimal `VaultSettings` subset needed for origin-ack). - **Service-worker rewrite**: `SessionHandle`-based `session.ts`, rewritten `vault.ts`, split `router/{index,popup-only,content-callable}.ts` with sender checks. - **Security architecture**: - WAR cleanup — `setup.html`, `setup.js`, wasm artifacts dropped. - Setup opened via `chrome.tabs.create`. - Origin-bound autofill (`sender.tab.url` only, hostname equality, top-frame only). - TOFU origin-ack via `VaultSettings.autofill_origin_acks`. - Popup captured-tab verification for `fill_credentials` (audit M5). - Closed Shadow DOM for all content-script UI. - `textContent`-only DOM construction; randomized-per-prompt element references; bounded page-derived strings. - **Login-parity popup**: view, add, edit, delete, autofill, capture for `Item::Login`. Other six types appear in the "New…" menu but open a "Coming in 1C-β" placeholder. - **Setup wizard**: zxcvbn strength meter + `score >= 3` gate (via new `rate_passphrase` message). - **Firefox build** re-verified with the new manifest + webpack config. ### Out (→ 1C-β / 1C-γ) - Per-type forms for SecureNote / Identity / Card / Key / Document / Totp (β). - Sections + custom fields UI (β). - Full vault-settings view (retention policies, generator defaults, attachment caps) — α touches `settings.enc` only for origin-ack (β). - Attachments and `putBlob` Git-Data-API fallback — Login items fit Contents API (γ). - Trash view, field-history view (γ). - Device management UI — CLI already handles it (γ). - BIP39 / advanced generator-request UI — α uses a default `Random { length: 20, classes: lower+upper+digits+symbols, symbol_charset: SafeOnly }` for the "gen" button (β). ## File Map ### New files ``` extension/src/service-worker/session.ts # SessionHandle lifecycle extension/src/service-worker/router/index.ts # single onMessage entry + sender dispatch extension/src/service-worker/router/popup-only.ts # popup-callable handlers extension/src/service-worker/router/content-callable.ts # content-script-callable handlers extension/src/service-worker/router/__tests__/router.test.ts # sender-check + origin-bound autofill tests extension/src/content/shadow.ts # closed Shadow DOM host helper extension/src/shared/base32.ts # base32 encode/decode for TOTP field parse ``` ### Rewritten ``` extension/src/shared/types.ts # Item, ItemCore, FieldKind, VaultSettings, ManifestEntry v2 extension/src/shared/messages.ts # PopupMessage + ContentMessage unions extension/src/service-worker/vault.ts # typed-item ops via SessionHandle extension/src/service-worker/index.ts # thin init + WASM load, delegates to router extension/src/content/capture.ts # closed Shadow DOM, textContent extension/src/content/icon.ts # closed Shadow DOM, textContent extension/src/popup/popup.ts # item dispatch, captured-tab snapshot on init extension/src/popup/components/entry-list.ts # → item-list.ts extension/src/popup/components/entry-detail.ts # → item-detail.ts (Login dispatcher + "coming soon") extension/src/popup/components/entry-form.ts # → item-form.ts (Login dispatcher + "coming soon") extension/src/popup/components/setup-wizard.ts # zxcvbn meter + gate extension/src/setup/setup.ts # zxcvbn meter + gate (mirror) extension/src/wasm.d.ts # mirror crates/relicario-wasm/src/lib.rs extension/manifest.json # WAR cleanup extension/manifest.firefox.json # WAR cleanup ``` ### Deleted ``` extension/wasm/idfoto_wasm* # stale pre-rename artifact ``` ## WASM Artifact `npm run build:wasm` already targets `../crates/relicario-wasm --out-dir ../../extension/wasm`. Running it produces `relicario_wasm.js`, `relicario_wasm_bg.wasm`, and `relicario_wasm.d.ts` in the output directory. Delete the stale `idfoto_wasm*` files. Both webpack configs already import from `../../wasm/relicario_wasm.js` — no config edits required. `wasm.d.ts` currently mirrors the Plan 1B surface; skim it for drift against `crates/relicario-wasm/src/lib.rs` after the rebuild (particularly the `attachment_encrypt` third argument `max_bytes: bigint`). ## Shared Types (`shared/types.ts`) Mirror the Rust core verbatim through serde serialization. The Rust-side `ItemCore` enum is externally-tagged by default, producing `{ "Login": {...} }` style JSON: ```ts export type ItemId = string; // 16-char hex export type FieldId = string; export type AttachmentId = string; export type ItemType = 'Login' | 'SecureNote' | 'Identity' | 'Card' | 'Key' | 'Document' | 'Totp'; export interface Item { id: ItemId; title: string; type: ItemType; // Rust's `r#type` serializes as `type` tags: string[]; favorite: boolean; group: string | null; notes: string | null; created: number; modified: number; trashed_at: number | null; core: ItemCore; sections: Section[]; attachments: AttachmentRef[]; field_history: Record; } export type ItemCore = | { Login: LoginCore } | { SecureNote: SecureNoteCore } | { Identity: IdentityCore } | { Card: CardCore } | { Key: KeyCore } | { Document: DocumentCore } | { Totp: TotpCore }; export interface LoginCore { username: string | null; password: string | null; url: string | null; totp: TotpConfig | null; } export interface TotpConfig { secret: number[]; // raw bytes, serde → JSON array algorithm: 'Sha1' | 'Sha256' | 'Sha512'; digits: number; period_seconds: number; kind: { Totp: null } | { Hotp: number } | { Steam: null }; } // Populated minimally for α (structural shape only, no UI); β fills in: export interface SecureNoteCore { body: string; } export interface IdentityCore { /* ... */ } export interface CardCore { /* ... */ } export interface KeyCore { /* ... */ } export interface DocumentCore { filename: string; mime_type: string; primary_attachment: AttachmentId; } export interface TotpCore { config: TotpConfig; issuer: string | null; label: string | null; } export interface Manifest { schema_version: number; items: Record; } export interface ManifestEntry { id: ItemId; type: ItemType; title: string; tags: string[]; favorite: boolean; group: string | null; icon_hint: string | null; modified: number; trashed_at: number | null; attachment_summaries: AttachmentSummary[]; } export interface VaultSettings { trash_retention: unknown; // opaque in α; full shape in β field_history_retention: unknown; generator_defaults: unknown; attachment_caps: unknown; autofill_origin_acks: Record; } export interface DeviceSettings { // chrome.storage.local shape (was RelicarioSettings) captureEnabled: boolean; captureStyle: 'bar' | 'toast'; } export type GeneratorRequest = | { Bip39: { word_count: number; separator: string; capitalization: Capitalization } } | { Random: { length: number; classes: CharClasses; symbol_charset: SymbolCharset } }; export type Capitalization = 'Lower' | 'Upper' | 'FirstOfEach' | 'Title' | 'Mixed'; export interface CharClasses { lower: boolean; upper: boolean; digits: boolean; symbols: boolean; } export type SymbolCharset = 'SafeOnly' | 'Extended' | { Custom: string }; ``` Plus `Section`, `Field`, `FieldKind`, `FieldValue`, `AttachmentRef`, `AttachmentSummary`, `FieldHistoryEntry` as declared. Most are unused by α's UI but present so the type-check catches drift with the Rust side. The ItemCore external-tagging assumption gets verified in the first SW slice's smoke test: `item_encrypt(handle, JSON.stringify(loginItem))` round-trips through `item_decrypt` with structural equality. ## Messages (`shared/messages.ts`) Two unions, so TypeScript itself enforces the router boundary: ```ts export type PopupMessage = | { type: 'is_unlocked' } | { type: 'unlock'; passphrase: string } | { type: 'lock' } | { type: 'list_items'; group?: string } | { type: 'get_item'; id: ItemId } | { type: 'add_item'; item: Item } | { type: 'update_item'; id: ItemId; item: Item } | { type: 'delete_item'; id: ItemId } // soft-delete (sets trashed_at) | { type: 'get_totp'; id: ItemId } | { type: 'sync' } | { type: 'get_setup_state' } | { type: 'save_setup'; config: VaultConfig; imageBase64: string } | { type: 'rate_passphrase'; passphrase: string } | { type: 'generate_password'; request: GeneratorRequest } | { type: 'fill_credentials'; id: ItemId; capturedTabId: number; capturedUrl: string } | { type: 'ack_autofill_origin'; hostname: string } | { type: 'get_settings' } // DeviceSettings (local) | { type: 'update_settings'; settings: Partial } | { type: 'get_blacklist' } | { type: 'remove_blacklist'; hostname: string }; export type ContentMessage = | { type: 'get_autofill_candidates' } // url comes from sender.tab.url | { type: 'get_credentials'; id: ItemId } // origin-checked against sender.tab.url | { type: 'check_credential'; username: string; password: string } // url from sender | { type: 'blacklist_site' }; // hostname from sender export type Request = PopupMessage | ContentMessage; ``` Deliberate omissions: `get_autofill_candidates`, `check_credential`, `blacklist_site` no longer carry a `url` — the SW derives it from `sender.tab.url`. `fill_credentials` now takes an item id + captured tab state instead of raw credentials, so the SW can re-verify origin on the forwarding hop. ## Service Worker ### `service-worker/session.ts` Single module-scope "current" `SessionHandle` (single-vault assumption per the broader spec). ```ts import type { SessionHandle } from '../../wasm/relicario_wasm'; let current: SessionHandle | null = null; export function setCurrent(h: SessionHandle): void { current = h; } export function getCurrent(): SessionHandle | null { return current; } export function clearCurrent(): void { if (!current) return; try { current.free(); } catch { /* already freed */ } current = null; } export function requireCurrent(): SessionHandle { if (!current) throw new Error('vault_locked'); return current; } ``` SW idle-suspend (Chrome) or explicit `lock` message clears the handle. Firefox's persistent background script retains it until explicit lock — consistent with the spec. ### `service-worker/vault.ts` Public surface, all handle-keyed: ```ts fetchVaultMeta(git): Promise<{ salt: Uint8Array; paramsJson: string }> fetchAndDecryptManifest(git, handle): Promise encryptAndWriteManifest(git, handle, manifest, message): Promise fetchAndDecryptItem(git, handle, id): Promise encryptAndWriteItem(git, handle, id, item, message): Promise fetchAndDecryptSettings(git, handle): Promise encryptAndWriteSettings(git, handle, settings, message): Promise listItems(manifest, filter?): Array<[ItemId, ManifestEntry]> searchItems(manifest, query): Array<[ItemId, ManifestEntry]> findByHostname(manifest, hostname): Array<[ItemId, ManifestEntry]> // hostname from caller ``` No `masterKey: Uint8Array` anywhere in the module surface. ### `service-worker/router/index.ts` Single `chrome.runtime.onMessage.addListener`. Sender predicates: ```ts const popupUrl = chrome.runtime.getURL('popup.html'); const setupUrl = chrome.runtime.getURL('setup.html'); const senderUrl = sender.url ?? ''; const isPopup = senderUrl === popupUrl; const isSetup = senderUrl.startsWith(setupUrl); const isContent = sender.tab !== undefined && sender.frameId === 0 && sender.id === chrome.runtime.id; ``` `POPUP_ONLY` capability set = every `PopupMessage` type. `CONTENT_CALLABLE` = every `ContentMessage` type. `save_setup` is the one exception: accepted from `isPopup || isSetup`. Unauthorized sender → `{ ok: false, error: 'unauthorized_sender' }` synchronously. Unknown type → `{ ok: false, error: 'unknown_message_type' }`. The two handler files stay thin — pure `switch (msg.type)` with no sender logic (the router already verified). ## Security Architecture ### WAR cleanup (audit C1) Both manifests drop `setup.html`, `setup.js`, `relicario_wasm.js`, `relicario_wasm_bg.wasm` from `web_accessible_resources`. The WAR array becomes `[{ resources: ["styles.css"], matches: [""] }]` if styles are still needed, else disappears. The popup opens setup via: ```ts chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') }); ``` Own-origin extension tabs work without WAR. The SW loads WASM via `chrome.runtime.getURL(...)` from the extension origin — no WAR either. ### Sender dispatch (audit C1, C2) Implemented in `router/index.ts` as described above. The `save_setup` exception is the one place this deviates from "pure set-membership" — it accepts either `isPopup` or `isSetup`. ### Origin-bound autofill (audit C4) Content-callable handlers derive origin exclusively from `sender.tab.url`. Flow: - **`get_autofill_candidates`**: parse `sender.tab.url` → hostname. Return manifest entries whose `LoginCore.url` hostname equals page hostname. Top-frame only (`sender.frameId === 0` already enforced at router). - **`get_credentials(id)`**: fetch item. Parse `LoginCore.url` hostname. Compare to `sender.tab.url` hostname. Mismatch → `{ ok: false, error: 'origin_mismatch' }`. No item data leaked on mismatch. - **`check_credential`**: same origin derivation; `username`/`password` compared against manifest + decrypted item. - **`blacklist_site`**: hostname derived from `sender.tab.url`. ### TOFU origin-ack When `get_credentials` succeeds on an origin not present in `VaultSettings.autofill_origin_acks`: 1. SW returns `{ ok: true, data: { requires_ack: true, hostname } }` — no credentials. 2. Content script surfaces a "confirm autofill" dialog inside its closed Shadow DOM (lightweight — just a re-use of the capture prompt layout). 3. User clicks "Confirm in relicario" → focuses the extension popup (the content-script dialog shows a prompt to open the popup; `chrome.action.openPopup()` is Chrome-only and unreliable, so α uses an instructional "open relicario to confirm" message instead). 4. User opens popup → popup detects a pending ack via `VaultSettings.autofill_origin_acks` diff, shows "Confirm autofill on ``?" → user acks → popup sends `ack_autofill_origin { hostname }` (popup-only, writes `settings.enc`). 5. Next autofill attempt on the same origin succeeds. The ack is popup-only because it's a vault write; the content-script dialog is purely instructional. Full in-page ack UI (including a tighter retry loop) is β-polish territory. ### Popup captured-tab verification (audit M5) On popup open: ```ts const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); const captured = { tabId: tab.id!, url: tab.url ?? '' }; ``` Stashed on `PopupState`. `fill_credentials` messages carry `capturedTabId` + `capturedUrl`. SW handler: 1. Look up item by `id`. 2. `chrome.tabs.get(capturedTabId)` — if gone or navigated, reject. 3. Compare `new URL(tab.url).hostname` to `new URL(capturedUrl).hostname` — mismatch rejects. 4. Compare captured hostname to `LoginCore.url`'s hostname — mismatch rejects. 5. Forward via `chrome.tabs.sendMessage(capturedTabId, { type: 'fill_credentials', username, password })`. Content-script fill listener stays as-is (native-setter trick is already correct). ### Closed Shadow DOM (audit C3) `content/shadow.ts`: ```ts export function createShadowHost(): { host: HTMLElement; root: ShadowRoot; destroy: () => void } { const host = document.createElement('div'); document.body.appendChild(host); const root = host.attachShadow({ mode: 'closed' }); return { host, root, destroy: () => host.remove() }; } ``` Used by `icon.ts` (per-password-field host for the icon + picker) and `capture.ts` (submit-prompt). Strict rules enforced by review + a lint rule (`no-restricted-syntax` against `MemberExpression[property.name=/^(innerHTML|outerHTML)$/]` inside `extension/src/content/`): 1. **No `innerHTML` / `insertAdjacentHTML` / `document.write`** anywhere in `content/`. All DOM via `createElement` + `textContent` + `appendChild`. 2. **No stable element IDs or classes inside shadow trees**. Wire handlers via local references. 3. **Page-derived strings bounded**: `findUsernameValue` result capped at 256 chars, control characters stripped via `replace(/\p{Cc}/gu, '')`, assigned only via `.textContent`. 4. **Disposal**: removing the host element drops the shadow root, detaches handlers. Styles inside the shadow tree via `style.setProperty(...)` calls, or a single `