Files
relicario/docs/superpowers/specs/2026-04-20-relicario-extension-1c-alpha-design.md
adlee-was-taken a1d733ddeb docs: Plan 1C-α (extension foundation) design spec
Foundation slice of the browser-extension migration onto the typed-item
core from Plans 1A+1B. Scope: WASM artifact rebuild, typed-item shared
types, SessionHandle-based service worker, split router with sender
checks, full security architecture (origin-bound autofill, TOFU ack,
closed Shadow DOM, popup captured-tab verification), zxcvbn setup gate,
Login-parity popup. Other 6 item types land in 1C-β; attachments/trash/
history/device UI in 1C-γ.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:14:25 -04:00

541 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<FieldId, FieldHistoryEntry[]>;
}
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<ItemId, ManifestEntry>;
}
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<string, number>;
}
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<DeviceSettings> }
| { 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<Manifest>
encryptAndWriteManifest(git, handle, manifest, message): Promise<void>
fetchAndDecryptItem(git, handle, id): Promise<Item>
encryptAndWriteItem(git, handle, id, item, message): Promise<void>
fetchAndDecryptSettings(git, handle): Promise<VaultSettings>
encryptAndWriteSettings(git, handle, settings, message): Promise<void>
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: ["<all_urls>"] }]` 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 `<hostname>`?" → 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 `<style>` element whose text content is a static literal.
### JS-side passphrase hygiene
Best-effort only (JS strings are immutable). In the `unlock` handler: receive passphrase, pass directly to `wasm.unlock(...)`, then `req.passphrase = ''` and let the message object go out of scope. Never log passphrase content. WASM-side zeroization is the primary defense (already handled Rust-side).
## Popup
### Entry flow
`popup.ts` unchanged in shape. Init sequence:
1. `get_setup_state` → if `!isConfigured`, `chrome.tabs.create(setup.html)` and close the popup.
2. `is_unlocked` → if unlocked, `list_items` + render list.
3. Otherwise render `unlock`.
4. Snapshot `(activeTabId, activeTabUrl)` at init; stash on `PopupState` for later `fill_credentials` calls.
### Item list / detail / form — Login-only
Rename `entry-*.ts``item-*.ts`. List view renders from `ManifestEntry`, shows type-icon + title + group + favorite + tags. Detail and form dispatch on `item.type`:
```ts
switch (item.type) {
case 'Login': return renderLoginDetail(app, item);
case 'SecureNote':
case 'Identity':
case 'Card':
case 'Key':
case 'Document':
case 'Totp': return renderComingSoonPlaceholder(app, item.type);
}
```
Add flow: "New…" menu lists all seven types; picking Login opens the form, picking any other type shows "Coming in 1C-β".
Existing Login form (username/url/password/totp/group/notes) maps 1:1 to `LoginCore` + `Item` envelope. TOTP field takes a base32 string; `shared/base32.ts` parses it to `Uint8Array` → bytes in `TotpConfig { secret, algorithm: Sha1, digits: 6, period_seconds: 30, kind: { Totp: null } }`. Display is base32 re-encoded with a reveal toggle.
"gen" button sends:
```ts
{ type: 'generate_password',
request: { Random: { length: 20,
classes: { lower: true, upper: true, digits: true, symbols: true },
symbol_charset: 'SafeOnly' } } }
```
### Setup wizard + zxcvbn
Both `popup/components/setup-wizard.ts` and `setup/setup.ts`:
- Passphrase input with a 5-bar strength indicator (color-coded per zxcvbn `score`).
- On input (150ms debounce): `rate_passphrase { passphrase }``{ score, guesses_log10 }`.
- Submit disabled unless `score >= 3`.
- Copy: `score < 3` → "Too weak — try a longer phrase or add unpredictability."; `score >= 3` → "Strong enough."
Reference-image upload and vault-config fields stay as-is. Final step gains the spec's H8 warning copy: "factor 2 + git push token are readable from this browser profile's disk. Your remaining defense is the passphrase."
## Storage Split
| Data | Location | Rationale |
|---|---|---|
| `vaultConfig` (host/repo/token) | `chrome.storage.local` | Device-local; needed pre-unlock |
| `imageBase64` (reference JPEG) | `chrome.storage.local` | Device-local factor-2 material |
| `DeviceSettings { captureEnabled, captureStyle }` | `chrome.storage.local` (key: `relicarioSettings`) | Per-device UX preference |
| `captureBlacklist: string[]` | `chrome.storage.local` | Per-device; avoids git churn |
| `VaultSettings.autofill_origin_acks` | `settings.enc` in the repo | Per-vault security posture |
| Items / manifest / settings | git repo (AEAD'd) | Core vault state |
Existing `relicarioSettings` + `captureBlacklist` keys keep their names — no migration step.
## Firefox Parity
- `manifest.firefox.json` gets the same WAR cleanup as Chrome.
- `background.scripts: ["service-worker.js"]` stays (not an SW in Firefox — persistent background script).
- `initWasm()` in `service-worker/index.ts` already branches on `ServiceWorkerGlobalScope` presence; the branch survives the rewrite.
- Load via `about:debugging` → "Load Temporary Add-on" → `dist-firefox/manifest.json`.
- Final manual test matrix at the end of α runs on both browsers concurrently (see below).
## Testing Strategy
### Rust-side regression guard
Plan 1B's 151 tests must stay green. Every slice runs `cargo test --workspace` before commit. The WASM rebuild slice additionally runs `cargo build -p relicario-wasm --target wasm32-unknown-unknown`.
### Extension unit tests (new)
New harness in `extension/src/service-worker/router/__tests__/router.test.ts` using Vitest (runs TS natively, no webpack dependency). Tests:
- **Sender-check matrix**: for each message type, accepted from the right sender and rejected (`unauthorized_sender`) from every wrong sender. Mocks `chrome.runtime.onMessage` by calling the dispatcher directly with fabricated `sender` objects.
- **Origin-bound autofill**: `get_autofill_candidates` ignores any stray `url` field; only `sender.tab.url`'s hostname drives matching.
- **`get_credentials` origin equality**: mismatched `LoginCore.url` hostname → `origin_mismatch`, no item data in response.
- **`fill_credentials` captured-tab verification**: mismatched captured vs current tab → reject.
- **`generate_password` wiring**: calls through to WASM with the expected request shape (smoke only; generator itself is Rust-tested).
Harness scope: SW only. Popup/content-script DOM tests are β.
### Shadow DOM probe (dev-build only)
Runtime assertion in a dev-mode content-script path: after rendering the capture prompt, `console.warn` if `document.querySelector('#relicario-save-btn') !== null` or if the host's `shadowRoot` getter returns non-null from page JS. Stripped in production.
### Manual test matrix — end of slice 6, on both Chrome and Firefox
1. Fresh install → setup wizard opens in a tab (not popup-embedded); zxcvbn slider responds; weak passphrase blocks submit.
2. Unlock → list renders from manifest.
3. Add Login with TOTP → sync → item appears on a second browser profile.
4. Autofill icon in password field → click → fills (single-candidate path).
5. Multiple candidates → picker → pick → fills.
6. First autofill on a new hostname → origin-ack prompt → confirm → credentials arrive.
7. Capture prompt on submitting a new login; "Save" adds item, "Never" blacklists, "×" dismisses.
8. Edit Login → password rotates → field history captured (CLI cross-check: `relicario get --show` on second machine shows new password; item file's `field_history` populated).
9. Delete Login → moves to trash (not in list; CLI `relicario list --trashed` shows it).
10. Soft-lock via popup "lock" → list clears, re-unlock required.
11. Cross-origin autofill attempt via devtools: craft a `get_credentials` from a page whose hostname differs from item's `LoginCore.url``origin_mismatch`.
### Acceptance criteria
- `cargo test --workspace` green.
- `npm run build:all` green in `extension/` (Chrome + Firefox bundles).
- Router unit tests green.
- All 11 manual-matrix steps pass on both Chrome and Firefox.
- `git grep -n 'innerHTML\|insertAdjacentHTML' extension/src/content/` → zero hits.
- `git grep -n 'idfoto' extension/` → zero hits.
- WAR in both manifests contains no HTML/JS/WASM artifacts.
- `fetch(chrome.runtime.getURL('setup.html'))` from a content script fails (smoke test — confirms WAR removal took effect).
### Non-goals for α testing
- Automated browser integration (Playwright against a built extension) — γ.
- Heap-snapshot verification that master_key bytes aren't visible from JS — manual-only per broader spec; formalized in γ.
- Fuzz / property tests for the router — the message-type matrix is exhaustive.
## Audit Findings Addressed
| ID | Severity | How α addresses it |
|---|---|---|
| C1 | Critical | Setup wizard + WASM removed from WAR; sender check on `save_setup` |
| C2 | Critical | Split message router with sender-based dispatch |
| C3 | Critical | Closed Shadow DOM + textContent for all content-script UI |
| C4 | Critical | Origin-bound autofill (`sender.tab.url` only, hostname match) |
| H2 | High | `SessionHandle` opaque to JS; passphrase cleared from scope ASAP |
| H3 | High | zxcvbn strength gate in setup wizard |
| M5 | Medium | Popup captures `(tab.id, tab.url)` at open; verifies on `fill_credentials` |
| L11 | Low | `textContent` rule subsumes escaping concerns in the content-script surface |
Remaining audit items from the typed-items spec (retention UI, attachment caps UI, device-management UI, full vault-settings view) land in β/γ.
## Open Questions Deferred to the Plan
- Exact Vitest configuration — first router-test slice surfaces the final shape (`vitest.config.ts`, `tsconfig` overrides, jsdom vs happy-dom for the origin-parsing paths).
- Precise "pending ack" detection mechanism in the popup: poll `VaultSettings.autofill_origin_acks` on popup open vs a background-synthesized flag. Slice 6 decides based on latency feel.
- Whether the TOTP parse helper belongs in `shared/base32.ts` or gets added to the WASM surface as `totp_config_from_base32`. First approach is simpler; the second is more robust to charset edge cases. Slice 2 decides when wiring the Login form.