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>
This commit is contained in:
@@ -0,0 +1,540 @@
|
||||
# relicario — Extension Plan 1C-α (Foundation) Design
|
||||
|
||||
First of three sub-plans that port the browser extension from the v1 single-`Entry` data model to the typed-item model landed in Plans 1A + 1B. 1C-α is the **foundation slice**: rebuild the WASM artifact, migrate shared types, rewrite the service worker against the opaque `SessionHandle` surface, split the message router with sender checks, wire the full security architecture from the typed-items spec, and achieve Login-parity on the new stack. Other six item types show "Coming in 1C-β" placeholders.
|
||||
|
||||
This spec references the broader design at `docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md` — read that for the cryptographic envelope, data-model rationale, and threat model. This document is the extension-side implementation design for the first of the three 1C sub-plans.
|
||||
|
||||
## Plan 1C decomposition
|
||||
|
||||
| Sub-plan | Scope |
|
||||
|---|---|
|
||||
| **1C-α (this spec)** | WASM rebuild, shared types, service-worker rewrite, router split, security architecture, Login-parity popup, setup-wizard zxcvbn, Firefox parity |
|
||||
| 1C-β | Per-type forms for the other six types, sections + custom fields, full vault-settings UI, generator-request UI |
|
||||
| 1C-γ | Attachments (with `putBlob` Git-Data-API fallback), trash view, field-history view, device management |
|
||||
|
||||
Each sub-plan gets its own spec → plan → implementation cycle.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
Captured during brainstorming:
|
||||
|
||||
| Question | Decision | Why |
|
||||
|---|---|---|
|
||||
| How many sub-plans? | Three (α/β/γ) | Single plan is too large to review or execute without drift; three sub-plans give natural checkpoints |
|
||||
| What's "done" for 1C-α? | **Login-parity on the new stack** — all existing single-`Entry` flows re-expressed as `Item::Login`; other six types show "coming soon" | Validates the full pipeline end-to-end (item + manifest + git commit) before β's UI sprawl; keeps extension usable during β |
|
||||
| Firefox in scope for α? | Yes, concurrent with Chrome | Shared TS source; marginal extra cost; avoids mid-β surprises from silent rot |
|
||||
| Where do capture UX prefs + blacklist live? | `chrome.storage.local` (device-local) | TOFU origin-ack is a security posture (vault-level); capture prompt style is a UX preference that genuinely differs per device; blacklist churn pollutes git log |
|
||||
| zxcvbn strength gate in α or β? | **α** | Audit H3 (security); leaving a weak-passphrase window open during β is the same shape of mistake as leaving autofill origin-unbound during α |
|
||||
| Sequencing | **Bottom-up six-slice** (WASM artifact → types → SW vault/session → router → security → popup rewire + zxcvbn) | Matches Plan 1B's small-task cadence; gives the plan-executor clean checkpoints |
|
||||
|
||||
## Scope
|
||||
|
||||
### In
|
||||
|
||||
- **WASM artifact** rebuilt from `relicario-wasm` crate (replacing stale `idfoto_wasm*` files).
|
||||
- **Shared TypeScript types / messages** migrated to the typed-item surface (`Item`, `ItemCore`, `Manifest` v2, `AttachmentRef`, plus the minimal `VaultSettings` subset needed for origin-ack).
|
||||
- **Service-worker rewrite**: `SessionHandle`-based `session.ts`, rewritten `vault.ts`, split `router/{index,popup-only,content-callable}.ts` with sender checks.
|
||||
- **Security architecture**:
|
||||
- WAR cleanup — `setup.html`, `setup.js`, wasm artifacts dropped.
|
||||
- Setup opened via `chrome.tabs.create`.
|
||||
- Origin-bound autofill (`sender.tab.url` only, hostname equality, top-frame only).
|
||||
- TOFU origin-ack via `VaultSettings.autofill_origin_acks`.
|
||||
- Popup captured-tab verification for `fill_credentials` (audit M5).
|
||||
- Closed Shadow DOM for all content-script UI.
|
||||
- `textContent`-only DOM construction; randomized-per-prompt element references; bounded page-derived strings.
|
||||
- **Login-parity popup**: view, add, edit, delete, autofill, capture for `Item::Login`. Other six types appear in the "New…" menu but open a "Coming in 1C-β" placeholder.
|
||||
- **Setup wizard**: zxcvbn strength meter + `score >= 3` gate (via new `rate_passphrase` message).
|
||||
- **Firefox build** re-verified with the new manifest + webpack config.
|
||||
|
||||
### Out (→ 1C-β / 1C-γ)
|
||||
|
||||
- Per-type forms for SecureNote / Identity / Card / Key / Document / Totp (β).
|
||||
- Sections + custom fields UI (β).
|
||||
- Full vault-settings view (retention policies, generator defaults, attachment caps) — α touches `settings.enc` only for origin-ack (β).
|
||||
- Attachments and `putBlob` Git-Data-API fallback — Login items fit Contents API (γ).
|
||||
- Trash view, field-history view (γ).
|
||||
- Device management UI — CLI already handles it (γ).
|
||||
- BIP39 / advanced generator-request UI — α uses a default `Random { length: 20, classes: lower+upper+digits+symbols, symbol_charset: SafeOnly }` for the "gen" button (β).
|
||||
|
||||
## File Map
|
||||
|
||||
### New files
|
||||
|
||||
```
|
||||
extension/src/service-worker/session.ts # SessionHandle lifecycle
|
||||
extension/src/service-worker/router/index.ts # single onMessage entry + sender dispatch
|
||||
extension/src/service-worker/router/popup-only.ts # popup-callable handlers
|
||||
extension/src/service-worker/router/content-callable.ts # content-script-callable handlers
|
||||
extension/src/service-worker/router/__tests__/router.test.ts # sender-check + origin-bound autofill tests
|
||||
extension/src/content/shadow.ts # closed Shadow DOM host helper
|
||||
extension/src/shared/base32.ts # base32 encode/decode for TOTP field parse
|
||||
```
|
||||
|
||||
### Rewritten
|
||||
|
||||
```
|
||||
extension/src/shared/types.ts # Item, ItemCore, FieldKind, VaultSettings, ManifestEntry v2
|
||||
extension/src/shared/messages.ts # PopupMessage + ContentMessage unions
|
||||
extension/src/service-worker/vault.ts # typed-item ops via SessionHandle
|
||||
extension/src/service-worker/index.ts # thin init + WASM load, delegates to router
|
||||
extension/src/content/capture.ts # closed Shadow DOM, textContent
|
||||
extension/src/content/icon.ts # closed Shadow DOM, textContent
|
||||
extension/src/popup/popup.ts # item dispatch, captured-tab snapshot on init
|
||||
extension/src/popup/components/entry-list.ts # → item-list.ts
|
||||
extension/src/popup/components/entry-detail.ts # → item-detail.ts (Login dispatcher + "coming soon")
|
||||
extension/src/popup/components/entry-form.ts # → item-form.ts (Login dispatcher + "coming soon")
|
||||
extension/src/popup/components/setup-wizard.ts # zxcvbn meter + gate
|
||||
extension/src/setup/setup.ts # zxcvbn meter + gate (mirror)
|
||||
extension/src/wasm.d.ts # mirror crates/relicario-wasm/src/lib.rs
|
||||
extension/manifest.json # WAR cleanup
|
||||
extension/manifest.firefox.json # WAR cleanup
|
||||
```
|
||||
|
||||
### Deleted
|
||||
|
||||
```
|
||||
extension/wasm/idfoto_wasm* # stale pre-rename artifact
|
||||
```
|
||||
|
||||
## WASM Artifact
|
||||
|
||||
`npm run build:wasm` already targets `../crates/relicario-wasm --out-dir ../../extension/wasm`. Running it produces `relicario_wasm.js`, `relicario_wasm_bg.wasm`, and `relicario_wasm.d.ts` in the output directory. Delete the stale `idfoto_wasm*` files. Both webpack configs already import from `../../wasm/relicario_wasm.js` — no config edits required.
|
||||
|
||||
`wasm.d.ts` currently mirrors the Plan 1B surface; skim it for drift against `crates/relicario-wasm/src/lib.rs` after the rebuild (particularly the `attachment_encrypt` third argument `max_bytes: bigint`).
|
||||
|
||||
## Shared Types (`shared/types.ts`)
|
||||
|
||||
Mirror the Rust core verbatim through serde serialization. The Rust-side `ItemCore` enum is externally-tagged by default, producing `{ "Login": {...} }` style JSON:
|
||||
|
||||
```ts
|
||||
export type ItemId = string; // 16-char hex
|
||||
export type FieldId = string;
|
||||
export type AttachmentId = string;
|
||||
|
||||
export type ItemType = 'Login' | 'SecureNote' | 'Identity' | 'Card' | 'Key' | 'Document' | 'Totp';
|
||||
|
||||
export interface Item {
|
||||
id: ItemId;
|
||||
title: string;
|
||||
type: ItemType; // Rust's `r#type` serializes as `type`
|
||||
tags: string[];
|
||||
favorite: boolean;
|
||||
group: string | null;
|
||||
notes: string | null;
|
||||
created: number;
|
||||
modified: number;
|
||||
trashed_at: number | null;
|
||||
core: ItemCore;
|
||||
sections: Section[];
|
||||
attachments: AttachmentRef[];
|
||||
field_history: Record<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.
|
||||
Reference in New Issue
Block a user