diff --git a/docs/superpowers/specs/2026-04-20-relicario-extension-1c-alpha-design.md b/docs/superpowers/specs/2026-04-20-relicario-extension-1c-alpha-design.md new file mode 100644 index 0000000..3fd6c51 --- /dev/null +++ b/docs/superpowers/specs/2026-04-20-relicario-extension-1c-alpha-design.md @@ -0,0 +1,540 @@ +# 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 `