Brand name uses capital R in user-facing text — extension UI strings, CLI clap help / descriptions / error prose, markdown docs. Lowercase preserved for the binary command, crate names, npm package, file paths, env vars, and code identifiers. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
585 lines
31 KiB
Markdown
585 lines
31 KiB
Markdown
# 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:
|
||
|
||
```ts
|
||
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:
|
||
|
||
```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 '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:
|
||
|
||
```ts
|
||
{ 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.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.
|