Files
relicario/docs/superpowers/specs/2026-04-20-relicario-extension-1c-alpha-design.md
adlee-was-taken ad6d8af2f6 docs(1c-alpha): correct TS type definitions to match actual serde shapes
Verified against the Plan 1A Rust sources:
- ItemType / ItemCore use snake_case with tag="type" internal tagging
  (not the external tagging I initially wrote)
- TotpKind is default-externally-tagged (no tag attr), so it serializes
  as bare "totp"/"steam" for unit variants and { hotp: { counter } }
- GeneratorRequest uses tag="kind" internal tagging
- FieldValue / TrashRetention / HistoryRetention / SymbolCharset use
  adjacent tagging { tag: "kind", content: "value" }
- Fix Login form TOTP parse example and "gen" button payload

No scope change — this is a bookkeeping correction so the plan
author references the correct wire shapes.

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

31 KiB
Raw Blame History

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 shapes use a mix of snake_case, internal tagging (#[serde(tag = "type")] for ItemCore, tag = "kind" for GeneratorRequest), adjacent tagging (tag = "kind", content = "value" for FieldValue, TrashRetention, HistoryRetention, SymbolCharset), and default external tagging (TotpKind). The TS types must match exactly:

export type ItemId = string;       // 16-char hex
export type FieldId = string;
export type AttachmentId = string;

// snake_case strings, matches serde rename_all = "snake_case"
export type ItemType = 'login' | 'secure_note' | '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;                  // omitted when None (#[serde(skip_serializing_if)])
  notes?: string;
  created: number;
  modified: number;
  trashed_at?: number;
  core: ItemCore;                  // internally-tagged on `"type"` — see below
  sections: Section[];
  attachments: AttachmentRef[];
  field_history: Record<FieldId, FieldHistoryEntry[]>;
}

// Internally-tagged: ItemCore variant's fields get merged with `"type"` discriminant.
// Example wire format for Login: { "type": "login", "username": "...", ... }
export type ItemCore =
  | ({ type: 'login' }       & LoginCore)
  | ({ type: 'secure_note' } & SecureNoteCore)
  | ({ type: 'identity' }    & IdentityCore)
  | ({ type: 'card' }        & CardCore)
  | ({ type: 'key' }         & KeyCore)
  | ({ type: 'document' }    & DocumentCore)
  | ({ type: 'totp' }        & TotpCore);

export interface LoginCore {
  username?: string;
  password?: string;
  url?: string;                    // Rust serializes `Url` as its string form
  totp?: TotpConfig;
}

// TotpKind is externally-tagged (default for enums without #[serde(tag)]):
//   Totp         → "totp"       (unit variant serializes as bare string)
//   Hotp{counter}→ { "hotp": { "counter": 42 } }
//   Steam        → "steam"
export type TotpKind = 'totp' | 'steam' | { hotp: { counter: number } };

export interface TotpConfig {
  secret: number[];                // Vec<u8> → JSON number array
  algorithm: 'sha1' | 'sha256' | 'sha512';
  digits: number;
  period_seconds: number;
  kind: TotpKind;
}

// 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';
}

// GeneratorRequest is internally-tagged on "kind", struct variants:
export type GeneratorRequest =
  | { kind: 'bip39';  word_count: number; separator: string; capitalization: Capitalization }
  | { kind: 'random'; length: number; classes: CharClasses; symbol_charset: SymbolCharset };

export type Capitalization = 'lower' | 'upper' | 'first_of_each' | 'title' | 'mixed';
export interface CharClasses { lower: boolean; upper: boolean; digits: boolean; symbols: boolean; }

// SymbolCharset is adjacently-tagged { tag: "kind", content: "value" }:
export type SymbolCharset =
  | { kind: 'safe_only' }
  | { kind: 'extended' }
  | { kind: 'custom'; value: string };

// TrashRetention / HistoryRetention use the same adjacent tagging:
export type TrashRetention =
  | { kind: 'forever' }
  | { kind: 'days'; value: number };

export type HistoryRetention =
  | { kind: 'forever' }
  | { kind: 'last_n'; value: number }
  | { kind: 'days';   value: number };

// FieldValue adjacently-tagged { tag: "kind", content: "value" }, snake_case:
export type FieldValue =
  | { kind: 'text';       value: string }
  | { kind: 'multiline';  value: string }
  | { kind: 'password';   value: string }
  | { kind: 'concealed';  value: string }
  | { kind: 'url';        value: string }    // Url → string
  | { kind: 'email';      value: string }
  | { kind: 'phone';      value: string }
  | { kind: 'date';       value: string }    // chrono NaiveDate → "YYYY-MM-DD"
  | { kind: 'month_year'; value: { month: number; year: number } }
  | { kind: 'totp';       value: TotpConfig }
  | { kind: 'reference';  value: AttachmentId };

export type FieldKind =
  | 'text' | 'multiline' | 'password' | 'concealed' | 'url' | 'email'
  | 'phone' | 'date' | 'month_year' | 'totp' | 'reference';

Plus Section, Field, AttachmentRef, AttachmentSummary, FieldHistoryEntry as declared. Most are unused by α's UI but present so the type-check catches drift with the Rust side.

The serialization shapes above are verified in slice 3's smoke test: item_encrypt(handle, JSON.stringify(loginItem)) round-trips through item_decrypt with structural equality against a Rust-side item written by the CLI.

Messages (shared/messages.ts)

Two unions, so TypeScript itself enforces the router boundary:

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).

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:

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:

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:

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:

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:

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-*.tsitem-*.ts. List view renders from ManifestEntry, shows type-icon + title + group + favorite + tags. Detail and form dispatch on item.type:

switch (item.type) {
  case 'login':       return renderLoginDetail(app, item);
  case 'secure_note':
  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 a number[] (the secret: Vec<u8> on the Rust side) and emits TotpConfig { secret, algorithm: 'sha1', digits: 6, period_seconds: 30, kind: 'totp' }. Display is base32 re-encoded with a reveal toggle.

"gen" button sends:

{ type: 'generate_password',
  request: { kind: 'random',
             length: 20,
             classes: { lower: true, upper: true, digits: true, symbols: true },
             symbol_charset: { kind: 'safe_only' } } }

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.urlorigin_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.