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>
3320 lines
112 KiB
Markdown
3320 lines
112 KiB
Markdown
# Relicario Extension 1C-α (Foundation) Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Port the browser extension onto the typed-item core from Plans 1A+1B. Rebuild the WASM artifact, migrate the TypeScript surface to typed items, rewrite the service worker around the opaque `SessionHandle`, split the message router with sender checks, wire the full security architecture (WAR cleanup, origin-bound autofill, TOFU origin-ack, popup captured-tab verification, closed Shadow DOM), gate the setup wizard with zxcvbn, and achieve Login-parity on the new stack. Other six item types land in 1C-β.
|
||
|
||
**Architecture:** Six-slice bottom-up sequencing. Each slice leaves the branch in a cleanly-committable state; the extension doesn't have to load-and-run until slice 6. All messaging is statically split into `PopupMessage` (popup-callable) and `ContentMessage` (content-script-callable) unions; the single `chrome.runtime.onMessage` listener dispatches by `sender.url` / `sender.tab` / `sender.frameId`. Session handles are opaque u32 wrappers with Rust-side `Zeroizing<[u8; 32]>` storage — JS never sees master-key bytes.
|
||
|
||
**Tech Stack:** Rust (`relicario-wasm` crate, built with `wasm-pack --target web`), TypeScript (webpack 5, ts-loader), Vitest (new — for router unit tests), Bun (package manager per `bun.lock`).
|
||
|
||
**Reference spec:** `docs/superpowers/specs/2026-04-20-relicario-extension-1c-alpha-design.md` (commit `ad6d8af`)
|
||
|
||
**Branch:** Create `feature/typed-items-1c-alpha` off `main` (Plan 1B already merged).
|
||
|
||
**Environment note:** `extension/` uses Bun (`bun.lock`) but `package.json` scripts invoke `webpack` directly. Use `bun install` for deps and `bun run build`/`bun run build:all`/`bun run build:firefox`/`bun run build:wasm` for scripts — they match npm semantics.
|
||
|
||
---
|
||
|
||
## Pre-flight
|
||
|
||
- [ ] **Pre-flight step 1: Verify cargo workspace green on `main`**
|
||
|
||
Run: `cd /home/alee/Sources/relicario && cargo test --workspace`
|
||
Expected: all 151 tests pass (per Plan 1B tag `plan-1b-cli-wasm-complete`).
|
||
|
||
- [ ] **Pre-flight step 2: Create feature branch**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git checkout main
|
||
git pull
|
||
git checkout -b feature/typed-items-1c-alpha
|
||
```
|
||
|
||
- [ ] **Pre-flight step 3: Install extension deps**
|
||
|
||
```bash
|
||
cd extension
|
||
bun install
|
||
```
|
||
|
||
Expected: installs cleanly (webpack, ts-loader, copy-webpack-plugin, @types/chrome).
|
||
|
||
---
|
||
|
||
## Slice 1 — WASM artifact rebuild
|
||
|
||
Goal: replace the stale `idfoto_wasm*` files in `extension/wasm/` with freshly-built `relicario_wasm*` output. Synchronize `wasm.d.ts` if there's any drift from the Plan 1B surface.
|
||
|
||
### Task 1: Delete stale WASM artifacts
|
||
|
||
**Files:**
|
||
- Delete: `extension/wasm/idfoto_wasm_bg.wasm`
|
||
- Delete: `extension/wasm/idfoto_wasm_bg.wasm.d.ts`
|
||
- Delete: `extension/wasm/idfoto_wasm.d.ts`
|
||
- Delete: `extension/wasm/idfoto_wasm.js`
|
||
- Delete: `extension/wasm/package.json`
|
||
|
||
- [ ] **Step 1: Remove stale files**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario/extension
|
||
rm -f wasm/idfoto_wasm_bg.wasm wasm/idfoto_wasm_bg.wasm.d.ts wasm/idfoto_wasm.d.ts wasm/idfoto_wasm.js wasm/package.json
|
||
```
|
||
|
||
- [ ] **Step 2: Verify directory is empty**
|
||
|
||
Run: `ls extension/wasm/`
|
||
Expected: no output (empty directory).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add -A extension/wasm/
|
||
git commit -m "chore(ext): remove stale idfoto_wasm artifact"
|
||
```
|
||
|
||
### Task 2: Build the relicario-wasm artifact for the extension
|
||
|
||
**Files:**
|
||
- Modify (generated): `extension/wasm/relicario_wasm.js`
|
||
- Modify (generated): `extension/wasm/relicario_wasm_bg.wasm`
|
||
- Modify (generated): `extension/wasm/relicario_wasm.d.ts`
|
||
- Modify (generated): `extension/wasm/relicario_wasm_bg.wasm.d.ts`
|
||
- Modify (generated): `extension/wasm/package.json`
|
||
|
||
- [ ] **Step 1: Run the WASM build script**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario/extension
|
||
bun run build:wasm
|
||
```
|
||
|
||
The script invokes `wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm`. Requires `wasm-pack` on PATH; if missing, install with `cargo install wasm-pack`.
|
||
|
||
- [ ] **Step 2: Verify expected files exist**
|
||
|
||
```bash
|
||
ls extension/wasm/
|
||
```
|
||
|
||
Expected files: `relicario_wasm.js`, `relicario_wasm_bg.wasm`, `relicario_wasm.d.ts`, `relicario_wasm_bg.wasm.d.ts`, `package.json`.
|
||
|
||
- [ ] **Step 3: Sanity-check the WASM binary is non-trivial**
|
||
|
||
```bash
|
||
stat -c '%s' extension/wasm/relicario_wasm_bg.wasm
|
||
```
|
||
|
||
Expected: a multi-hundred-KB size (typical wasm-pack output with Argon2, XChaCha20, zxcvbn, bip39 is ~500KB–1.5MB).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add extension/wasm/
|
||
git commit -m "build(ext): rebuild WASM artifact as relicario_wasm"
|
||
```
|
||
|
||
### Task 3: Sync `extension/src/wasm.d.ts` with the generated declarations
|
||
|
||
The hand-maintained `extension/src/wasm.d.ts` declares the WASM surface for the TS compiler. Verify it matches `crates/relicario-wasm/src/lib.rs` — the rebuilt artifact may reveal drift.
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/wasm.d.ts`
|
||
|
||
- [ ] **Step 1: Compare existing declarations to Rust source**
|
||
|
||
Read `extension/src/wasm.d.ts` and `crates/relicario-wasm/src/lib.rs`. Each `#[wasm_bindgen]` signature must have a corresponding TS declaration.
|
||
|
||
Current file should already match (Plan 1B landed it). If any function is missing, add it. As of commit `65e0d3c` the expected surface is:
|
||
|
||
```ts
|
||
// Thin TypeScript declarations for the relicario-wasm bindings.
|
||
// These are hand-written to mirror the #[wasm_bindgen] signatures in
|
||
// crates/relicario-wasm/src/lib.rs; keep them in sync manually.
|
||
|
||
export class SessionHandle {
|
||
readonly value: number;
|
||
free(): void;
|
||
}
|
||
|
||
export class EncryptedAttachment {
|
||
readonly aid: string;
|
||
readonly bytes: Uint8Array;
|
||
free(): void;
|
||
}
|
||
|
||
export class TotpCode {
|
||
readonly code: string;
|
||
readonly expires_at: number;
|
||
free(): void;
|
||
}
|
||
|
||
export function unlock(
|
||
passphrase: string,
|
||
image_bytes: Uint8Array,
|
||
salt: Uint8Array,
|
||
params_json: string,
|
||
): SessionHandle;
|
||
|
||
export function lock(handle: SessionHandle): boolean;
|
||
|
||
export function manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
|
||
export function manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array;
|
||
export function item_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
|
||
export function item_encrypt(handle: SessionHandle, item_json: string): Uint8Array;
|
||
export function settings_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
|
||
export function settings_encrypt(handle: SessionHandle, settings_json: string): Uint8Array;
|
||
|
||
export function attachment_encrypt(
|
||
handle: SessionHandle,
|
||
plaintext: Uint8Array,
|
||
max_bytes: bigint,
|
||
): EncryptedAttachment;
|
||
export function attachment_decrypt(handle: SessionHandle, encrypted: Uint8Array): Uint8Array;
|
||
|
||
export function new_item_id(): string;
|
||
export function new_field_id(): string;
|
||
|
||
export function generate_password(request_json: string): string;
|
||
export function generate_passphrase(request_json: string): string;
|
||
export function rate_passphrase(p: string): { score: number; guesses_log10: number };
|
||
|
||
export function extract_image_secret(image_bytes: Uint8Array): Uint8Array;
|
||
export function embed_image_secret(carrier: Uint8Array, secret: Uint8Array): Uint8Array;
|
||
|
||
export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode;
|
||
|
||
// Initializer (wasm-bindgen's default init function).
|
||
export default function init(module_or_path?: unknown): Promise<void>;
|
||
```
|
||
|
||
- [ ] **Step 2: Add `initSync` export declaration**
|
||
|
||
The service worker `index.ts` also calls `initSync` (named export) for the Chrome MV3 path. Add this after the default export:
|
||
|
||
```ts
|
||
// wasm-bindgen's sync init — Chrome MV3 service workers can't use dynamic import().
|
||
export function initSync(args: { module: WebAssembly.Module }): void;
|
||
```
|
||
|
||
- [ ] **Step 3: Verify TS compile passes**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario/extension
|
||
bun run build 2>&1 | head -40
|
||
```
|
||
|
||
Expected: compilation proceeds (may still fail on the old `idfoto_wasm`-era callers in `service-worker/`; that's fine — the types file itself should not error).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add extension/src/wasm.d.ts
|
||
git commit -m "build(ext): align wasm.d.ts with relicario-wasm surface"
|
||
```
|
||
|
||
---
|
||
|
||
## Slice 2 — Shared TS types and message unions
|
||
|
||
Goal: rewrite `shared/types.ts` and `shared/messages.ts` to mirror the Rust typed-item serialization. This slice makes downstream TS fail to compile everywhere that still references `Entry` / `ManifestEntry` with `password` / `totp_secret` — that's expected; slices 3–6 fix the callers.
|
||
|
||
### Task 4: Rewrite `shared/types.ts` with typed-item shapes
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/shared/types.ts`
|
||
|
||
- [ ] **Step 1: Replace the entire file**
|
||
|
||
```ts
|
||
/// Typed-item shared TypeScript types.
|
||
///
|
||
/// These mirror the Rust core's serde serialization. See
|
||
/// crates/relicario-core/src/item.rs, item_types/, and settings.rs
|
||
/// for the source shapes.
|
||
|
||
// --- IDs ---
|
||
|
||
export type ItemId = string; // 16-char hex
|
||
export type FieldId = string; // 16-char hex
|
||
export type AttachmentId = string; // 16-char hex (sha256 of plaintext, truncated)
|
||
|
||
// --- ItemType / ItemCore ---
|
||
|
||
// snake_case from serde rename_all
|
||
export type ItemType =
|
||
| 'login' | 'secure_note' | 'identity' | 'card' | 'key' | 'document' | 'totp';
|
||
|
||
// ItemCore is internally-tagged on "type":
|
||
// Login → { type: 'login', username, password, url, totp }
|
||
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);
|
||
|
||
// Optional fields use `?` because Rust #[serde(skip_serializing_if = "Option::is_none")]
|
||
// omits them from the JSON; serde_wasm_bindgen produces `undefined` on read.
|
||
|
||
export interface LoginCore {
|
||
username?: string;
|
||
password?: string;
|
||
url?: string;
|
||
totp?: TotpConfig;
|
||
}
|
||
|
||
export interface SecureNoteCore { body: string; }
|
||
|
||
export interface IdentityCore {
|
||
full_name?: string;
|
||
address?: string;
|
||
phone?: string;
|
||
email?: string;
|
||
date_of_birth?: string; // "YYYY-MM-DD"
|
||
}
|
||
|
||
export interface CardCore {
|
||
number?: string;
|
||
holder?: string;
|
||
expiry?: { month: number; year: number };
|
||
cvv?: string;
|
||
pin?: string;
|
||
kind: CardKind;
|
||
}
|
||
|
||
export type CardKind = 'credit' | 'debit' | 'gift' | 'loyalty' | 'other';
|
||
|
||
export interface KeyCore {
|
||
key_material: string;
|
||
label?: string;
|
||
public_key?: string;
|
||
algorithm?: string;
|
||
}
|
||
|
||
export interface DocumentCore {
|
||
filename: string;
|
||
mime_type: string;
|
||
primary_attachment: AttachmentId;
|
||
}
|
||
|
||
export interface TotpCore {
|
||
config: TotpConfig;
|
||
issuer?: string;
|
||
label?: string;
|
||
}
|
||
|
||
// --- TOTP ---
|
||
|
||
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;
|
||
}
|
||
|
||
// --- Sections + custom fields ---
|
||
|
||
export interface Section {
|
||
name?: string;
|
||
fields: Field[];
|
||
}
|
||
|
||
export interface Field {
|
||
id: FieldId;
|
||
label: string;
|
||
kind: FieldKind;
|
||
value: FieldValue;
|
||
hidden_by_default: boolean;
|
||
}
|
||
|
||
export type FieldKind =
|
||
| 'text' | 'multiline' | 'password' | 'concealed' | 'url' | 'email'
|
||
| 'phone' | 'date' | 'month_year' | 'totp' | 'reference';
|
||
|
||
// adjacently-tagged { tag: "kind", content: "value" }
|
||
export type FieldValue =
|
||
| { kind: 'text'; value: string }
|
||
| { kind: 'multiline'; value: string }
|
||
| { kind: 'password'; value: string }
|
||
| { kind: 'concealed'; value: string }
|
||
| { kind: 'url'; value: string }
|
||
| { kind: 'email'; value: string }
|
||
| { kind: 'phone'; value: string }
|
||
| { kind: 'date'; value: string }
|
||
| { kind: 'month_year'; value: { month: number; year: number } }
|
||
| { kind: 'totp'; value: TotpConfig }
|
||
| { kind: 'reference'; value: AttachmentId };
|
||
|
||
// --- Attachments + history ---
|
||
|
||
export interface AttachmentRef {
|
||
id: AttachmentId;
|
||
filename: string;
|
||
mime_type: string;
|
||
size: number;
|
||
created: number;
|
||
}
|
||
|
||
export interface FieldHistoryEntry {
|
||
value: string;
|
||
replaced_at: number;
|
||
}
|
||
|
||
export interface AttachmentSummary {
|
||
id: AttachmentId;
|
||
filename: string;
|
||
mime_type: string;
|
||
size: number;
|
||
}
|
||
|
||
// --- Item envelope ---
|
||
|
||
export interface Item {
|
||
id: ItemId;
|
||
title: string;
|
||
type: ItemType; // Rust r#type → JSON key "type"
|
||
tags: string[];
|
||
favorite: boolean;
|
||
group?: string;
|
||
notes?: string;
|
||
created: number;
|
||
modified: number;
|
||
trashed_at?: number;
|
||
core: ItemCore;
|
||
sections: Section[];
|
||
attachments: AttachmentRef[];
|
||
field_history: Record<FieldId, FieldHistoryEntry[]>;
|
||
}
|
||
|
||
// --- Manifest (schema_version 2) ---
|
||
|
||
export interface Manifest {
|
||
schema_version: number; // 2
|
||
items: Record<ItemId, ManifestEntry>;
|
||
}
|
||
|
||
export interface ManifestEntry {
|
||
id: ItemId;
|
||
type: ItemType;
|
||
title: string;
|
||
tags: string[];
|
||
favorite: boolean;
|
||
group?: string;
|
||
icon_hint?: string;
|
||
modified: number;
|
||
trashed_at?: number;
|
||
attachment_summaries: AttachmentSummary[];
|
||
}
|
||
|
||
// --- Vault settings (only the fields α touches) ---
|
||
// Full shape lives on the Rust side and in docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md
|
||
// We leave retention/generator/caps opaque to α so we don't accidentally mutate them.
|
||
|
||
export interface VaultSettings {
|
||
trash_retention: unknown;
|
||
field_history_retention: unknown;
|
||
generator_defaults: unknown;
|
||
attachment_caps: unknown;
|
||
autofill_origin_acks: Record<string, number>;
|
||
}
|
||
|
||
// --- Vault config (device-local) ---
|
||
|
||
export interface VaultConfig {
|
||
hostType: 'gitea' | 'github';
|
||
hostUrl: string;
|
||
repoPath: string;
|
||
apiToken: string;
|
||
}
|
||
|
||
export interface SetupState {
|
||
config: VaultConfig | null;
|
||
imageBase64: string | null;
|
||
isConfigured: boolean;
|
||
}
|
||
|
||
// --- Device-local UX settings (chrome.storage.local — renamed from RelicarioSettings) ---
|
||
|
||
export interface DeviceSettings {
|
||
captureEnabled: boolean;
|
||
captureStyle: 'bar' | 'toast';
|
||
}
|
||
|
||
export const DEFAULT_DEVICE_SETTINGS: DeviceSettings = {
|
||
captureEnabled: false,
|
||
captureStyle: 'bar',
|
||
};
|
||
|
||
// --- Generator request (matches Rust GeneratorRequest — tag="kind") ---
|
||
|
||
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; }
|
||
export type SymbolCharset =
|
||
| { kind: 'safe_only' }
|
||
| { kind: 'extended' }
|
||
| { kind: 'custom'; value: string };
|
||
|
||
// Default used by the α popup "gen" button:
|
||
export const DEFAULT_PASSWORD_REQUEST: GeneratorRequest = {
|
||
kind: 'random',
|
||
length: 20,
|
||
classes: { lower: true, upper: true, digits: true, symbols: true },
|
||
symbol_charset: { kind: 'safe_only' },
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/shared/types.ts
|
||
git commit -m "feat(ext): typed-item TS types mirroring relicario-core serde"
|
||
```
|
||
|
||
### Task 5: Rewrite `shared/messages.ts` with split unions
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/shared/messages.ts`
|
||
|
||
- [ ] **Step 1: Replace the entire file**
|
||
|
||
```ts
|
||
import type {
|
||
Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState,
|
||
DeviceSettings, GeneratorRequest,
|
||
} from './types';
|
||
|
||
// --- Messages a popup (or setup page) may send ---
|
||
|
||
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
|
||
| { 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' }
|
||
| { type: 'update_settings'; settings: Partial<DeviceSettings> }
|
||
| { type: 'get_blacklist' }
|
||
| { type: 'remove_blacklist'; hostname: string };
|
||
|
||
// --- Messages a content script may send ---
|
||
|
||
// Note deliberate absence of a `url` field — the SW derives origin from sender.tab.url.
|
||
|
||
export type ContentMessage =
|
||
| { type: 'get_autofill_candidates' }
|
||
| { type: 'get_credentials'; id: ItemId }
|
||
| { type: 'check_credential'; username: string; password: string }
|
||
| { type: 'blacklist_site' };
|
||
|
||
// --- Union for chrome.runtime.sendMessage call sites ---
|
||
|
||
export type Request = PopupMessage | ContentMessage;
|
||
|
||
// --- Response ---
|
||
|
||
export type Response =
|
||
| { ok: true; data?: unknown }
|
||
| { ok: false; error: string };
|
||
|
||
// --- Typed response helpers ---
|
||
|
||
export interface IsUnlockedResponse extends Extract<Response, { ok: true }> {
|
||
data: { unlocked: boolean };
|
||
}
|
||
|
||
export interface ListItemsResponse extends Extract<Response, { ok: true }> {
|
||
data: { items: Array<[ItemId, ManifestEntry]> };
|
||
}
|
||
|
||
export interface GetItemResponse extends Extract<Response, { ok: true }> {
|
||
data: { item: Item };
|
||
}
|
||
|
||
export interface TotpResponse extends Extract<Response, { ok: true }> {
|
||
data: { code: string; expires_at: number };
|
||
}
|
||
|
||
export interface AutofillCandidatesResponse extends Extract<Response, { ok: true }> {
|
||
data: { candidates: Array<[ItemId, ManifestEntry]> };
|
||
}
|
||
|
||
export interface CredentialsResponse extends Extract<Response, { ok: true }> {
|
||
data:
|
||
| { requires_ack: true; hostname: string }
|
||
| { username: string; password: string };
|
||
}
|
||
|
||
export interface SetupStateResponse extends Extract<Response, { ok: true }> {
|
||
data: SetupState;
|
||
}
|
||
|
||
export interface GeneratePasswordResponse extends Extract<Response, { ok: true }> {
|
||
data: { password: string };
|
||
}
|
||
|
||
export interface RatePassphraseResponse extends Extract<Response, { ok: true }> {
|
||
data: { score: number; guesses_log10: number };
|
||
}
|
||
|
||
// --- Capability sets (consumed by the router) ---
|
||
|
||
export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
||
'is_unlocked', 'unlock', 'lock', 'list_items', 'get_item', 'add_item',
|
||
'update_item', 'delete_item', 'get_totp', 'sync', 'get_setup_state',
|
||
'save_setup', 'rate_passphrase', 'generate_password', 'fill_credentials',
|
||
'ack_autofill_origin', 'get_settings', 'update_settings', 'get_blacklist',
|
||
'remove_blacklist',
|
||
] as PopupMessage['type'][]);
|
||
|
||
export const CONTENT_CALLABLE_TYPES: ReadonlySet<ContentMessage['type']> = new Set([
|
||
'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site',
|
||
] as ContentMessage['type'][]);
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add extension/src/shared/messages.ts
|
||
git commit -m "feat(ext): split PopupMessage / ContentMessage unions + capability sets"
|
||
```
|
||
|
||
### Task 6: Add `shared/base32.ts` with encode/decode and tests
|
||
|
||
**Files:**
|
||
- Create: `extension/src/shared/base32.ts`
|
||
- Create: `extension/src/shared/__tests__/base32.test.ts`
|
||
|
||
This module is the minimum support the Login form needs to accept a base32-encoded TOTP secret string and format the re-encoded form for display.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```ts
|
||
// extension/src/shared/__tests__/base32.test.ts
|
||
import { describe, expect, it } from 'vitest';
|
||
import { base32Decode, base32Encode } from '../base32';
|
||
|
||
describe('base32', () => {
|
||
// RFC 4648 § 10 test vectors
|
||
it('encodes empty', () => expect(base32Encode(new Uint8Array())).toBe(''));
|
||
it('encodes "f"', () => expect(base32Encode(new TextEncoder().encode('f'))).toBe('MY'));
|
||
it('encodes "fo"', () => expect(base32Encode(new TextEncoder().encode('fo'))).toBe('MZXQ'));
|
||
it('encodes "foo"', () => expect(base32Encode(new TextEncoder().encode('foo'))).toBe('MZXW6'));
|
||
it('encodes "foob"', () => expect(base32Encode(new TextEncoder().encode('foob'))).toBe('MZXW6YQ'));
|
||
it('encodes "fooba"', () => expect(base32Encode(new TextEncoder().encode('fooba'))).toBe('MZXW6YTB'));
|
||
it('encodes "foobar"',() => expect(base32Encode(new TextEncoder().encode('foobar'))).toBe('MZXW6YTBOI'));
|
||
|
||
it('decodes round-trip', () => {
|
||
const bytes = new Uint8Array([0x12, 0x34, 0x56, 0x78, 0x9a]);
|
||
expect(base32Decode(base32Encode(bytes))).toEqual(bytes);
|
||
});
|
||
|
||
it('decodes case-insensitively', () => {
|
||
expect(base32Decode('mzxw6')).toEqual(new TextEncoder().encode('foo'));
|
||
});
|
||
|
||
it('decodes ignoring whitespace and padding', () => {
|
||
expect(base32Decode('JBSW Y3DP EHPK 3PXP==')).toEqual(
|
||
base32Decode('JBSWY3DPEHPK3PXP'),
|
||
);
|
||
});
|
||
|
||
it('throws on invalid characters', () => {
|
||
expect(() => base32Decode('MZ!W6')).toThrow();
|
||
});
|
||
});
|
||
```
|
||
|
||
Note: Vitest is not installed yet; this test file will be run in slice 4 Task 15 where Vitest is wired up. For this task, just create the file so slice 2 stays compilation-clean.
|
||
|
||
- [ ] **Step 2: Write `base32.ts`**
|
||
|
||
```ts
|
||
/// Minimal RFC 4648 base32 encode/decode for TOTP secret parsing.
|
||
///
|
||
/// Mirrors the encoder in crates/relicario-core/src/item.rs:base32_encode.
|
||
/// Decode is case-insensitive, tolerates whitespace and `=` padding.
|
||
|
||
const ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||
|
||
export function base32Encode(bytes: Uint8Array): string {
|
||
let out = '';
|
||
let buffer = 0;
|
||
let bits = 0;
|
||
for (const b of bytes) {
|
||
buffer = (buffer << 8) | b;
|
||
bits += 8;
|
||
while (bits >= 5) {
|
||
const idx = (buffer >> (bits - 5)) & 0x1f;
|
||
out += ALPHA[idx];
|
||
bits -= 5;
|
||
}
|
||
}
|
||
if (bits > 0) {
|
||
const idx = (buffer << (5 - bits)) & 0x1f;
|
||
out += ALPHA[idx];
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function base32Decode(input: string): Uint8Array {
|
||
const cleaned = input.replace(/\s+/g, '').replace(/=+$/g, '').toUpperCase();
|
||
const out: number[] = [];
|
||
let buffer = 0;
|
||
let bits = 0;
|
||
for (const ch of cleaned) {
|
||
const idx = ALPHA.indexOf(ch);
|
||
if (idx === -1) throw new Error(`base32: invalid character "${ch}"`);
|
||
buffer = (buffer << 5) | idx;
|
||
bits += 5;
|
||
if (bits >= 8) {
|
||
out.push((buffer >> (bits - 8)) & 0xff);
|
||
bits -= 8;
|
||
}
|
||
}
|
||
return new Uint8Array(out);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add extension/src/shared/base32.ts extension/src/shared/__tests__/base32.test.ts
|
||
git commit -m "feat(ext): base32 encode/decode for TOTP secret parse"
|
||
```
|
||
|
||
---
|
||
|
||
## Slice 3 — Service-worker session, vault, index
|
||
|
||
Goal: rewrite the SW vault layer around the typed-item WASM surface using the opaque `SessionHandle`. `index.ts` temporarily keeps its single-function message handler (still flat, no router split yet — that's slice 4) but the handler's internals now call the new vault module. Login-parity is *not* yet in place; slice 6 wires that. This slice's success criterion is: `bun run build` succeeds.
|
||
|
||
### Task 7: Create `service-worker/session.ts`
|
||
|
||
**Files:**
|
||
- Create: `extension/src/service-worker/session.ts`
|
||
|
||
- [ ] **Step 1: Write the file**
|
||
|
||
```ts
|
||
/// Single module-scope "current" SessionHandle.
|
||
///
|
||
/// α assumes one vault per extension install. The master key lives only
|
||
/// inside WASM linear memory (wrapped in Zeroizing<[u8;32]>); this module
|
||
/// just holds the opaque handle that names it.
|
||
|
||
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 requireCurrent(): SessionHandle {
|
||
if (!current) throw new Error('vault_locked');
|
||
return current;
|
||
}
|
||
|
||
export function clearCurrent(): void {
|
||
if (!current) return;
|
||
try { current.free(); } catch { /* already freed */ }
|
||
current = null;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add extension/src/service-worker/session.ts
|
||
git commit -m "feat(ext/sw): SessionHandle lifecycle module"
|
||
```
|
||
|
||
### Task 8: Rewrite `service-worker/vault.ts` for typed items
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/service-worker/vault.ts`
|
||
|
||
- [ ] **Step 1: Replace the entire file**
|
||
|
||
```ts
|
||
/// Typed-item vault operations. All calls are handle-keyed — the master key
|
||
/// never crosses the WASM boundary.
|
||
|
||
import type { SessionHandle } from '../../wasm/relicario_wasm';
|
||
import type { GitHost } from './git-host';
|
||
import type { Item, ItemId, Manifest, ManifestEntry, VaultSettings } from '../shared/types';
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
let wasm: any = null;
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
export function setWasm(w: any): void { wasm = w; }
|
||
|
||
function requireWasm(): any {
|
||
if (!wasm) throw new Error('WASM module not initialized');
|
||
return wasm;
|
||
}
|
||
|
||
export interface VaultMeta {
|
||
salt: Uint8Array;
|
||
paramsJson: string;
|
||
}
|
||
|
||
export async function fetchVaultMeta(git: GitHost): Promise<VaultMeta> {
|
||
const saltBytes = await git.readFile('.relicario/salt');
|
||
const paramsRaw = await git.readFile('.relicario/params.json');
|
||
const paramsJson = new TextDecoder().decode(paramsRaw);
|
||
return { salt: saltBytes, paramsJson };
|
||
}
|
||
|
||
// --- Manifest ---
|
||
|
||
export async function fetchAndDecryptManifest(
|
||
git: GitHost,
|
||
handle: SessionHandle,
|
||
): Promise<Manifest> {
|
||
const w = requireWasm();
|
||
const ciphertext = await git.readFile('manifest.enc');
|
||
return w.manifest_decrypt(handle, ciphertext) as Manifest;
|
||
}
|
||
|
||
export async function encryptAndWriteManifest(
|
||
git: GitHost,
|
||
handle: SessionHandle,
|
||
manifest: Manifest,
|
||
message: string,
|
||
): Promise<void> {
|
||
const w = requireWasm();
|
||
const ciphertext = w.manifest_encrypt(handle, JSON.stringify(manifest));
|
||
await git.writeFile('manifest.enc', ciphertext, message);
|
||
}
|
||
|
||
// --- Items ---
|
||
|
||
export async function fetchAndDecryptItem(
|
||
git: GitHost,
|
||
handle: SessionHandle,
|
||
id: ItemId,
|
||
): Promise<Item> {
|
||
const w = requireWasm();
|
||
const ciphertext = await git.readFile(`items/${id}.enc`);
|
||
return w.item_decrypt(handle, ciphertext) as Item;
|
||
}
|
||
|
||
export async function encryptAndWriteItem(
|
||
git: GitHost,
|
||
handle: SessionHandle,
|
||
id: ItemId,
|
||
item: Item,
|
||
message: string,
|
||
): Promise<void> {
|
||
const w = requireWasm();
|
||
const ciphertext = w.item_encrypt(handle, JSON.stringify(item));
|
||
await git.writeFile(`items/${id}.enc`, ciphertext, message);
|
||
}
|
||
|
||
// --- Settings (the α subset the SW reads/writes is autofill_origin_acks) ---
|
||
|
||
export async function fetchAndDecryptSettings(
|
||
git: GitHost,
|
||
handle: SessionHandle,
|
||
): Promise<VaultSettings> {
|
||
const w = requireWasm();
|
||
const ciphertext = await git.readFile('settings.enc');
|
||
return w.settings_decrypt(handle, ciphertext) as VaultSettings;
|
||
}
|
||
|
||
export async function encryptAndWriteSettings(
|
||
git: GitHost,
|
||
handle: SessionHandle,
|
||
settings: VaultSettings,
|
||
message: string,
|
||
): Promise<void> {
|
||
const w = requireWasm();
|
||
const ciphertext = w.settings_encrypt(handle, JSON.stringify(settings));
|
||
await git.writeFile('settings.enc', ciphertext, message);
|
||
}
|
||
|
||
// --- In-memory manifest helpers ---
|
||
|
||
export function listItems(
|
||
manifest: Manifest,
|
||
group?: string,
|
||
): Array<[ItemId, ManifestEntry]> {
|
||
const entries = Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>;
|
||
// Hide trashed items from the default list view.
|
||
const live = entries.filter(([, e]) => e.trashed_at === undefined);
|
||
if (!group) return live;
|
||
const g = group.toLowerCase();
|
||
return live.filter(([, e]) => e.group?.toLowerCase() === g);
|
||
}
|
||
|
||
export function searchItems(
|
||
manifest: Manifest,
|
||
query: string,
|
||
): Array<[ItemId, ManifestEntry]> {
|
||
const q = query.toLowerCase();
|
||
return (Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>)
|
||
.filter(([, e]) => e.trashed_at === undefined)
|
||
.filter(([, e]) => {
|
||
if (e.title.toLowerCase().includes(q)) return true;
|
||
if (e.tags.some((t) => t.toLowerCase().includes(q))) return true;
|
||
return false;
|
||
});
|
||
}
|
||
|
||
export function findByHostname(
|
||
manifest: Manifest,
|
||
hostname: string,
|
||
): Array<[ItemId, ManifestEntry]> {
|
||
const h = hostname.toLowerCase();
|
||
return (Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>)
|
||
.filter(([, e]) => e.trashed_at === undefined)
|
||
.filter(([, e]) => (e.icon_hint ?? '').toLowerCase() === h);
|
||
// icon_hint is derived by Rust core from LoginCore.url's hostname,
|
||
// so hostname equality on icon_hint is the cheapest match.
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify TS compile passes for vault.ts**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario/extension
|
||
bunx tsc --noEmit src/service-worker/vault.ts 2>&1 | head -20
|
||
```
|
||
|
||
Expected: no errors originating inside `vault.ts`. Errors from consumers (like `index.ts`) are fine for now.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add extension/src/service-worker/vault.ts
|
||
git commit -m "feat(ext/sw): typed-item vault ops via SessionHandle"
|
||
```
|
||
|
||
### Task 9: Rewire `service-worker/index.ts` for the new vault surface
|
||
|
||
This is a transitional state — we keep the flat handler from today but replumb it against the new types. The router split lands in slice 4.
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/service-worker/index.ts`
|
||
|
||
- [ ] **Step 1: Replace the file with the transitional (non-router) shape**
|
||
|
||
```ts
|
||
/// Background script entry point for the relicario browser extension.
|
||
///
|
||
/// Transitional slice-3 shape: keeps the flat onMessage listener but uses
|
||
/// the new typed-item vault + SessionHandle. The router split lands in
|
||
/// slice 4.
|
||
|
||
import type { Request, Response } from '../shared/messages';
|
||
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings } from '../shared/types';
|
||
import { DEFAULT_DEVICE_SETTINGS } from '../shared/types';
|
||
import type { GitHost } from './git-host';
|
||
import { createGitHost, base64ToUint8Array } from './git-host';
|
||
import * as vault from './vault';
|
||
import * as session from './session';
|
||
|
||
// --- WASM module load ---
|
||
|
||
// @ts-ignore TS2307 — resolved by webpack alias / copy
|
||
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
|
||
// @ts-ignore TS2307
|
||
import * as wasmBindings from '../../wasm/relicario_wasm.js';
|
||
|
||
type WasmModule = typeof wasmBindings;
|
||
let wasm: WasmModule | null = null;
|
||
|
||
async function initWasm(): Promise<WasmModule> {
|
||
if (wasm) return wasm;
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const SWGlobalScope = (globalThis as any).ServiceWorkerGlobalScope as (new () => ServiceWorker) | undefined;
|
||
const isServiceWorker = typeof SWGlobalScope !== 'undefined'
|
||
&& self instanceof (SWGlobalScope as unknown as typeof EventTarget);
|
||
|
||
if (isServiceWorker) {
|
||
const wasmResponse = await fetch(chrome.runtime.getURL('relicario_wasm_bg.wasm'));
|
||
const wasmBytes = await wasmResponse.arrayBuffer();
|
||
initSync({ module: new WebAssembly.Module(wasmBytes) });
|
||
} else {
|
||
const wasmUrl = chrome.runtime.getURL('relicario_wasm_bg.wasm');
|
||
await initDefault(wasmUrl);
|
||
}
|
||
|
||
vault.setWasm(wasmBindings);
|
||
wasm = wasmBindings;
|
||
return wasm;
|
||
}
|
||
|
||
// --- In-memory vault state (cleared on lock or SW restart) ---
|
||
|
||
let manifest: Manifest | null = null;
|
||
let gitHost: GitHost | null = null;
|
||
const totpConfigCache: Map<ItemId, unknown> = new Map();
|
||
|
||
// --- chrome.storage.local helpers ---
|
||
|
||
async function loadConfig(): Promise<VaultConfig | null> {
|
||
const result = await chrome.storage.local.get('vaultConfig');
|
||
return (result.vaultConfig as VaultConfig) ?? null;
|
||
}
|
||
|
||
async function loadImageBase64(): Promise<string | null> {
|
||
const result = await chrome.storage.local.get('imageBase64');
|
||
return (result.imageBase64 as string) ?? null;
|
||
}
|
||
|
||
async function loadSetupState(): Promise<SetupState> {
|
||
const config = await loadConfig();
|
||
const imageBase64 = await loadImageBase64();
|
||
return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null };
|
||
}
|
||
|
||
async function loadSettings(): Promise<DeviceSettings> {
|
||
const result = await chrome.storage.local.get('relicarioSettings');
|
||
return (result.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS };
|
||
}
|
||
|
||
async function saveSettings(settings: DeviceSettings): Promise<void> {
|
||
await chrome.storage.local.set({ relicarioSettings: settings });
|
||
}
|
||
|
||
async function loadBlacklist(): Promise<string[]> {
|
||
const result = await chrome.storage.local.get('captureBlacklist');
|
||
return (result.captureBlacklist as string[]) ?? [];
|
||
}
|
||
|
||
async function saveBlacklist(list: string[]): Promise<void> {
|
||
await chrome.storage.local.set({ captureBlacklist: list });
|
||
}
|
||
|
||
function ensureGitHost(config: VaultConfig): GitHost {
|
||
if (!gitHost) {
|
||
gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
|
||
}
|
||
return gitHost;
|
||
}
|
||
|
||
// --- Message handler (flat; router split in slice 4) ---
|
||
|
||
chrome.runtime.onMessage.addListener(
|
||
(request: Request, _sender: chrome.runtime.MessageSender, sendResponse: (response: Response) => void) => {
|
||
handleMessage(request)
|
||
.then(sendResponse)
|
||
.catch((err: Error) => sendResponse({ ok: false, error: err.message }));
|
||
return true;
|
||
},
|
||
);
|
||
|
||
async function handleMessage(req: Request): Promise<Response> {
|
||
switch (req.type) {
|
||
case 'is_unlocked':
|
||
return { ok: true, data: { unlocked: session.getCurrent() !== null } };
|
||
|
||
case 'unlock': {
|
||
const w = await initWasm();
|
||
const config = await loadConfig();
|
||
if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' };
|
||
const imageB64 = await loadImageBase64();
|
||
if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' };
|
||
|
||
const imageBytes = base64ToUint8Array(imageB64);
|
||
const git = ensureGitHost(config);
|
||
const meta = await vault.fetchVaultMeta(git);
|
||
|
||
const handle = w.unlock(req.passphrase, imageBytes, meta.salt, meta.paramsJson);
|
||
session.setCurrent(handle);
|
||
// Clear passphrase from scope best-effort.
|
||
// (JS strings are immutable; the message object goes out of scope after return.)
|
||
(req as { passphrase: string }).passphrase = '';
|
||
|
||
manifest = await vault.fetchAndDecryptManifest(git, handle);
|
||
return { ok: true };
|
||
}
|
||
|
||
case 'lock':
|
||
session.clearCurrent();
|
||
manifest = null;
|
||
totpConfigCache.clear();
|
||
return { ok: true };
|
||
|
||
case 'list_items': {
|
||
if (!manifest) return { ok: false, error: 'vault_locked' };
|
||
const items = vault.listItems(manifest, req.group);
|
||
return { ok: true, data: { items } };
|
||
}
|
||
|
||
case 'get_item': {
|
||
const handle = session.getCurrent();
|
||
if (!handle || !gitHost) return { ok: false, error: 'vault_locked' };
|
||
const item = await vault.fetchAndDecryptItem(gitHost, handle, req.id);
|
||
return { ok: true, data: { item } };
|
||
}
|
||
|
||
case 'add_item': {
|
||
const handle = session.getCurrent();
|
||
if (!handle || !gitHost || !manifest) return { ok: false, error: 'vault_locked' };
|
||
const w = await initWasm();
|
||
const id = w.new_item_id();
|
||
const item: Item = { ...req.item, id };
|
||
|
||
await vault.encryptAndWriteItem(gitHost, handle, id, item, `add: ${item.title}`);
|
||
manifest.items[id] = itemToManifestEntry(item);
|
||
await vault.encryptAndWriteManifest(gitHost, handle, manifest, `manifest: add ${item.title}`);
|
||
return { ok: true, data: { id } };
|
||
}
|
||
|
||
case 'update_item': {
|
||
const handle = session.getCurrent();
|
||
if (!handle || !gitHost || !manifest) return { ok: false, error: 'vault_locked' };
|
||
await vault.encryptAndWriteItem(gitHost, handle, req.id, req.item, `update: ${req.item.title}`);
|
||
manifest.items[req.id] = itemToManifestEntry(req.item);
|
||
await vault.encryptAndWriteManifest(gitHost, handle, manifest, `manifest: update ${req.item.title}`);
|
||
totpConfigCache.delete(req.id);
|
||
return { ok: true };
|
||
}
|
||
|
||
case 'delete_item': {
|
||
const handle = session.getCurrent();
|
||
if (!handle || !gitHost || !manifest) return { ok: false, error: 'vault_locked' };
|
||
const entry = manifest.items[req.id];
|
||
if (!entry) return { ok: false, error: 'item_not_found' };
|
||
// Soft-delete: fetch the item, set trashed_at, write it back.
|
||
const item = await vault.fetchAndDecryptItem(gitHost, handle, req.id);
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const updated: Item = { ...item, trashed_at: now, modified: now };
|
||
await vault.encryptAndWriteItem(gitHost, handle, req.id, updated, `trash: ${entry.title}`);
|
||
manifest.items[req.id] = { ...entry, trashed_at: now, modified: now };
|
||
await vault.encryptAndWriteManifest(gitHost, handle, manifest, `manifest: trash ${entry.title}`);
|
||
return { ok: true };
|
||
}
|
||
|
||
case 'sync': {
|
||
const handle = session.getCurrent();
|
||
if (!handle || !gitHost) return { ok: false, error: 'vault_locked' };
|
||
manifest = await vault.fetchAndDecryptManifest(gitHost, handle);
|
||
return { ok: true };
|
||
}
|
||
|
||
case 'get_setup_state': {
|
||
return { ok: true, data: await loadSetupState() };
|
||
}
|
||
|
||
case 'save_setup': {
|
||
await chrome.storage.local.set({
|
||
vaultConfig: req.config,
|
||
imageBase64: req.imageBase64,
|
||
});
|
||
gitHost = null;
|
||
return { ok: true };
|
||
}
|
||
|
||
case 'rate_passphrase': {
|
||
const w = await initWasm();
|
||
return { ok: true, data: w.rate_passphrase(req.passphrase) };
|
||
}
|
||
|
||
case 'generate_password': {
|
||
const w = await initWasm();
|
||
const password = w.generate_password(JSON.stringify(req.request));
|
||
return { ok: true, data: { password } };
|
||
}
|
||
|
||
case 'get_settings':
|
||
return { ok: true, data: { settings: await loadSettings() } };
|
||
|
||
case 'update_settings': {
|
||
const current = await loadSettings();
|
||
await saveSettings({ ...current, ...req.settings });
|
||
return { ok: true };
|
||
}
|
||
|
||
case 'get_blacklist':
|
||
return { ok: true, data: { blacklist: await loadBlacklist() } };
|
||
|
||
case 'remove_blacklist': {
|
||
const bl = await loadBlacklist();
|
||
await saveBlacklist(bl.filter((h) => h !== req.hostname));
|
||
return { ok: true };
|
||
}
|
||
|
||
// Slice 4 / 5 will wire these up properly (currently placeholders so the build passes):
|
||
case 'get_totp':
|
||
case 'fill_credentials':
|
||
case 'ack_autofill_origin':
|
||
case 'get_autofill_candidates':
|
||
case 'get_credentials':
|
||
case 'check_credential':
|
||
case 'blacklist_site':
|
||
return { ok: false, error: 'not_implemented_yet' };
|
||
|
||
default: {
|
||
const exhaustive: never = req;
|
||
return { ok: false, error: `unknown_message_type: ${(exhaustive as { type: string }).type}` };
|
||
}
|
||
}
|
||
}
|
||
|
||
function itemToManifestEntry(item: Item) {
|
||
return {
|
||
id: item.id,
|
||
type: item.type,
|
||
title: item.title,
|
||
tags: item.tags,
|
||
favorite: item.favorite,
|
||
group: item.group,
|
||
icon_hint: (item.core.type === 'login' && item.core.url)
|
||
? safeHostname(item.core.url) : undefined,
|
||
modified: item.modified,
|
||
trashed_at: item.trashed_at,
|
||
attachment_summaries: item.attachments.map((a) => ({
|
||
id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
|
||
})),
|
||
};
|
||
}
|
||
|
||
function safeHostname(url: string): string | undefined {
|
||
try { return new URL(url).hostname; } catch { return undefined; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify the extension compiles**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario/extension
|
||
bun run build 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: webpack build succeeds. If there are TS errors in `popup/` or `content/`, they're pre-existing and will be resolved in slices 5–6.
|
||
|
||
**If `bun run build` fails because popup/content still references the old `Entry` type**, that's expected — slice 3 intentionally breaks those callers. Workaround: add `// @ts-nocheck` at the top of the files that fail, in a separate commit labelled `chore(ext): silence popup/content errors until slice 6`. Remove the `@ts-nocheck` comments in slice 6 Task 22.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add extension/src/service-worker/index.ts
|
||
git commit -m "feat(ext/sw): rewire flat handler onto typed-item vault + SessionHandle"
|
||
```
|
||
|
||
### Task 10: Item round-trip smoke test via CLI
|
||
|
||
Goal: prove the TS types round-trip cleanly through the WASM `item_encrypt` / `item_decrypt` against a real CLI-written vault. This is a manual verification — formal automation waits on slice 4's Vitest setup.
|
||
|
||
**Files:** no code changes.
|
||
|
||
- [ ] **Step 1: Create a test vault with the CLI**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
mkdir -p /tmp/ext-test-vault && cd /tmp/ext-test-vault
|
||
git init
|
||
RELICARIO_TEST_PASSPHRASE='correct horse battery staple parapet' \
|
||
RELICARIO_TEST_ITEM_SECRET=$(head -c 32 /dev/urandom | base64) \
|
||
cargo run -p relicario-cli -- init --non-interactive --output /tmp/test-image.jpg
|
||
# Use the test image for re-open:
|
||
RELICARIO_TEST_PASSPHRASE='correct horse battery staple parapet' \
|
||
cargo run -p relicario-cli -- add login --title 'GitHub' \
|
||
--username alice --url https://github.com --password-stdin <<< 'hunter2'
|
||
ls items/ manifest.enc settings.enc
|
||
```
|
||
|
||
Expected: one item file, a manifest, and a settings file.
|
||
|
||
- [ ] **Step 2: Load the extension's service worker in isolation and decrypt the item**
|
||
|
||
Skip formal automation for α. Instead verify the wire format by reading the Rust-side JSON form and mentally comparing to `shared/types.ts`:
|
||
|
||
```bash
|
||
cd /tmp/ext-test-vault
|
||
# Rely on the fact that encrypt_item round-trips in the Rust tests;
|
||
# this step is a placeholder for slice 4's Vitest harness.
|
||
```
|
||
|
||
- [ ] **Step 3: Commit the pre-flight findings if any types had to change**
|
||
|
||
If the round-trip reveals drift (e.g. an optional field serialized differently than predicted), update `shared/types.ts` in a dedicated commit now rather than later. If nothing drifted, skip this step.
|
||
|
||
---
|
||
|
||
## Slice 4 — Split router with sender checks
|
||
|
||
Goal: split the flat handler in `index.ts` into three files (`router/index.ts`, `router/popup-only.ts`, `router/content-callable.ts`) with the sender-check dispatch from the spec. Wire up Vitest and write the router's acceptance/rejection matrix.
|
||
|
||
### Task 11: Scaffold `router/popup-only.ts` (move existing handlers)
|
||
|
||
**Files:**
|
||
- Create: `extension/src/service-worker/router/popup-only.ts`
|
||
|
||
- [ ] **Step 1: Write the file**
|
||
|
||
```ts
|
||
/// Popup-callable message handlers.
|
||
///
|
||
/// Every export here assumes the router has already verified sender identity
|
||
/// via sender.url === popup.html (or setup.html for save_setup).
|
||
|
||
import type { PopupMessage, Response } from '../../shared/messages';
|
||
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings } from '../../shared/types';
|
||
import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
|
||
import type { GitHost } from '../git-host';
|
||
import { createGitHost, base64ToUint8Array } from '../git-host';
|
||
import * as vault from '../vault';
|
||
import * as session from '../session';
|
||
|
||
// --- Shared ambient state owned by the SW module ---
|
||
//
|
||
// The router keeps these on a single `state` object and injects it into the
|
||
// handler so testing can mock them without reaching for globals.
|
||
|
||
export interface PopupState {
|
||
manifest: Manifest | null;
|
||
gitHost: GitHost | null;
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
wasm: any;
|
||
}
|
||
|
||
export async function handle(
|
||
msg: PopupMessage,
|
||
state: PopupState,
|
||
sender: chrome.runtime.MessageSender,
|
||
): Promise<Response> {
|
||
void sender; // unused in most branches; retained for symmetry with content-callable
|
||
switch (msg.type) {
|
||
case 'is_unlocked':
|
||
return { ok: true, data: { unlocked: session.getCurrent() !== null } };
|
||
|
||
case 'unlock': {
|
||
const w = state.wasm;
|
||
const config = await loadConfig();
|
||
if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' };
|
||
const imageB64 = await loadImageBase64();
|
||
if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' };
|
||
|
||
const imageBytes = base64ToUint8Array(imageB64);
|
||
if (!state.gitHost) state.gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
|
||
const meta = await vault.fetchVaultMeta(state.gitHost);
|
||
|
||
const handle = w.unlock(msg.passphrase, imageBytes, meta.salt, meta.paramsJson);
|
||
session.setCurrent(handle);
|
||
(msg as { passphrase: string }).passphrase = '';
|
||
|
||
state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle);
|
||
return { ok: true };
|
||
}
|
||
|
||
case 'lock':
|
||
session.clearCurrent();
|
||
state.manifest = null;
|
||
return { ok: true };
|
||
|
||
case 'list_items': {
|
||
if (!state.manifest) return { ok: false, error: 'vault_locked' };
|
||
return { ok: true, data: { items: vault.listItems(state.manifest, msg.group) } };
|
||
}
|
||
|
||
case 'get_item': {
|
||
const handle = session.getCurrent();
|
||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
||
return { ok: true, data: { item } };
|
||
}
|
||
|
||
case 'add_item': {
|
||
const handle = session.getCurrent();
|
||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||
const id = state.wasm.new_item_id();
|
||
const item: Item = { ...msg.item, id };
|
||
await vault.encryptAndWriteItem(state.gitHost, handle, id, item, `add: ${item.title}`);
|
||
state.manifest.items[id] = itemToManifestEntry(item);
|
||
await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: add ${item.title}`);
|
||
return { ok: true, data: { id } };
|
||
}
|
||
|
||
case 'update_item': {
|
||
const handle = session.getCurrent();
|
||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||
await vault.encryptAndWriteItem(state.gitHost, handle, msg.id, msg.item, `update: ${msg.item.title}`);
|
||
state.manifest.items[msg.id] = itemToManifestEntry(msg.item);
|
||
await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: update ${msg.item.title}`);
|
||
return { ok: true };
|
||
}
|
||
|
||
case 'delete_item': {
|
||
const handle = session.getCurrent();
|
||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||
const entry = state.manifest.items[msg.id];
|
||
if (!entry) return { ok: false, error: 'item_not_found' };
|
||
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const updated: Item = { ...item, trashed_at: now, modified: now };
|
||
await vault.encryptAndWriteItem(state.gitHost, handle, msg.id, updated, `trash: ${entry.title}`);
|
||
state.manifest.items[msg.id] = { ...entry, trashed_at: now, modified: now };
|
||
await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: trash ${entry.title}`);
|
||
return { ok: true };
|
||
}
|
||
|
||
case 'get_totp': {
|
||
const handle = session.getCurrent();
|
||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
||
if (item.core.type !== 'login' || !item.core.totp) {
|
||
return { ok: false, error: 'no_totp' };
|
||
}
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const code = state.wasm.totp_compute(JSON.stringify(item.core.totp), BigInt(now));
|
||
return { ok: true, data: { code: code.code, expires_at: code.expires_at } };
|
||
}
|
||
|
||
case 'sync': {
|
||
const handle = session.getCurrent();
|
||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||
state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle);
|
||
return { ok: true };
|
||
}
|
||
|
||
case 'get_setup_state':
|
||
return { ok: true, data: await loadSetupState() };
|
||
|
||
case 'save_setup': {
|
||
await chrome.storage.local.set({
|
||
vaultConfig: msg.config,
|
||
imageBase64: msg.imageBase64,
|
||
});
|
||
state.gitHost = null;
|
||
return { ok: true };
|
||
}
|
||
|
||
case 'rate_passphrase':
|
||
return { ok: true, data: state.wasm.rate_passphrase(msg.passphrase) };
|
||
|
||
case 'generate_password': {
|
||
const password = state.wasm.generate_password(JSON.stringify(msg.request));
|
||
return { ok: true, data: { password } };
|
||
}
|
||
|
||
case 'fill_credentials':
|
||
return handleFillCredentials(msg, state);
|
||
|
||
case 'ack_autofill_origin': {
|
||
const handle = session.getCurrent();
|
||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||
const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
|
||
const acks = { ...(settings.autofill_origin_acks ?? {}), [msg.hostname]: Math.floor(Date.now() / 1000) };
|
||
const updated = { ...settings, autofill_origin_acks: acks };
|
||
await vault.encryptAndWriteSettings(state.gitHost, handle, updated, `settings: ack origin ${msg.hostname}`);
|
||
return { ok: true };
|
||
}
|
||
|
||
case 'get_settings':
|
||
return { ok: true, data: { settings: await loadDeviceSettings() } };
|
||
|
||
case 'update_settings': {
|
||
const current = await loadDeviceSettings();
|
||
await saveDeviceSettings({ ...current, ...msg.settings });
|
||
return { ok: true };
|
||
}
|
||
|
||
case 'get_blacklist':
|
||
return { ok: true, data: { blacklist: await loadBlacklist() } };
|
||
|
||
case 'remove_blacklist': {
|
||
const bl = await loadBlacklist();
|
||
await saveBlacklist(bl.filter((h) => h !== msg.hostname));
|
||
return { ok: true };
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- fill_credentials with captured-tab verification (audit M5) ---
|
||
|
||
async function handleFillCredentials(
|
||
msg: Extract<PopupMessage, { type: 'fill_credentials' }>,
|
||
state: PopupState,
|
||
): Promise<Response> {
|
||
const handle = session.getCurrent();
|
||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||
|
||
let tab: chrome.tabs.Tab;
|
||
try { tab = await chrome.tabs.get(msg.capturedTabId); }
|
||
catch { return { ok: false, error: 'captured_tab_gone' }; }
|
||
|
||
const currentHost = safeHostname(tab.url ?? '');
|
||
const capturedHost = safeHostname(msg.capturedUrl);
|
||
if (!currentHost || !capturedHost || currentHost !== capturedHost) {
|
||
return { ok: false, error: 'tab_navigated' };
|
||
}
|
||
|
||
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
||
if (item.core.type !== 'login') return { ok: false, error: 'not_a_login' };
|
||
const itemHost = safeHostname(item.core.url ?? '');
|
||
if (!itemHost || itemHost !== currentHost) return { ok: false, error: 'origin_mismatch' };
|
||
|
||
await chrome.tabs.sendMessage(msg.capturedTabId, {
|
||
type: 'fill_credentials',
|
||
username: item.core.username ?? '',
|
||
password: item.core.password ?? '',
|
||
});
|
||
return { ok: true };
|
||
}
|
||
|
||
// --- chrome.storage.local helpers (module-scoped so all handlers share) ---
|
||
|
||
async function loadConfig(): Promise<VaultConfig | null> {
|
||
const r = await chrome.storage.local.get('vaultConfig');
|
||
return (r.vaultConfig as VaultConfig) ?? null;
|
||
}
|
||
|
||
async function loadImageBase64(): Promise<string | null> {
|
||
const r = await chrome.storage.local.get('imageBase64');
|
||
return (r.imageBase64 as string) ?? null;
|
||
}
|
||
|
||
async function loadSetupState(): Promise<SetupState> {
|
||
const config = await loadConfig();
|
||
const imageBase64 = await loadImageBase64();
|
||
return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null };
|
||
}
|
||
|
||
async function loadDeviceSettings(): Promise<DeviceSettings> {
|
||
const r = await chrome.storage.local.get('relicarioSettings');
|
||
return (r.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS };
|
||
}
|
||
|
||
async function saveDeviceSettings(s: DeviceSettings): Promise<void> {
|
||
await chrome.storage.local.set({ relicarioSettings: s });
|
||
}
|
||
|
||
async function loadBlacklist(): Promise<string[]> {
|
||
const r = await chrome.storage.local.get('captureBlacklist');
|
||
return (r.captureBlacklist as string[]) ?? [];
|
||
}
|
||
|
||
async function saveBlacklist(list: string[]): Promise<void> {
|
||
await chrome.storage.local.set({ captureBlacklist: list });
|
||
}
|
||
|
||
// --- Manifest entry derivation (duplicated from index; kept here to keep handler self-contained) ---
|
||
|
||
function itemToManifestEntry(item: Item) {
|
||
return {
|
||
id: item.id,
|
||
type: item.type,
|
||
title: item.title,
|
||
tags: item.tags,
|
||
favorite: item.favorite,
|
||
group: item.group,
|
||
icon_hint: (item.core.type === 'login' && item.core.url)
|
||
? safeHostname(item.core.url) : undefined,
|
||
modified: item.modified,
|
||
trashed_at: item.trashed_at,
|
||
attachment_summaries: item.attachments.map((a) => ({
|
||
id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
|
||
})),
|
||
};
|
||
}
|
||
|
||
function safeHostname(url: string): string | undefined {
|
||
try { return new URL(url).hostname; } catch { return undefined; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/service-worker/router/popup-only.ts
|
||
git commit -m "feat(ext/sw): router/popup-only handlers"
|
||
```
|
||
|
||
### Task 12: Create `router/content-callable.ts`
|
||
|
||
**Files:**
|
||
- Create: `extension/src/service-worker/router/content-callable.ts`
|
||
|
||
- [ ] **Step 1: Write the file**
|
||
|
||
```ts
|
||
/// Content-script-callable message handlers.
|
||
///
|
||
/// Origin is always derived from sender.tab.url — never trust fields on msg.
|
||
/// Router has already verified sender.frameId === 0 (top-frame only) and
|
||
/// sender.tab !== undefined.
|
||
|
||
import type { ContentMessage, Response } from '../../shared/messages';
|
||
import type { Manifest } from '../../shared/types';
|
||
import type { GitHost } from '../git-host';
|
||
import * as vault from '../vault';
|
||
import * as session from '../session';
|
||
|
||
export interface ContentState {
|
||
manifest: Manifest | null;
|
||
gitHost: GitHost | null;
|
||
}
|
||
|
||
export async function handle(
|
||
msg: ContentMessage,
|
||
state: ContentState,
|
||
sender: chrome.runtime.MessageSender,
|
||
): Promise<Response> {
|
||
const senderHost = safeHostname(sender.tab?.url ?? '');
|
||
if (!senderHost) return { ok: false, error: 'invalid_sender_url' };
|
||
|
||
switch (msg.type) {
|
||
case 'get_autofill_candidates': {
|
||
if (!state.manifest) return { ok: false, error: 'vault_locked' };
|
||
return {
|
||
ok: true,
|
||
data: { candidates: vault.findByHostname(state.manifest, senderHost) },
|
||
};
|
||
}
|
||
|
||
case 'get_credentials': {
|
||
const handle = session.getCurrent();
|
||
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
||
|
||
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
|
||
if (item.core.type !== 'login') return { ok: false, error: 'not_a_login' };
|
||
const itemHost = safeHostname(item.core.url ?? '');
|
||
if (!itemHost || itemHost !== senderHost) return { ok: false, error: 'origin_mismatch' };
|
||
|
||
// TOFU origin-ack check (VaultSettings.autofill_origin_acks):
|
||
const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
|
||
const acks = settings.autofill_origin_acks ?? {};
|
||
if (!(senderHost in acks)) {
|
||
return { ok: true, data: { requires_ack: true, hostname: senderHost } };
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
data: {
|
||
username: item.core.username ?? '',
|
||
password: item.core.password ?? '',
|
||
},
|
||
};
|
||
}
|
||
|
||
case 'check_credential': {
|
||
const handle = session.getCurrent();
|
||
if (!handle || !state.gitHost || !state.manifest) {
|
||
return { ok: true, data: { action: 'skip' } };
|
||
}
|
||
|
||
// Settings-gating: capture off or site blacklisted → skip.
|
||
const captureSettings = await loadDeviceSettings();
|
||
if (!captureSettings.captureEnabled) return { ok: true, data: { action: 'skip' } };
|
||
|
||
const blacklist = await loadBlacklist();
|
||
if (blacklist.includes(senderHost)) return { ok: true, data: { action: 'skip' } };
|
||
|
||
const candidates = vault.findByHostname(state.manifest, senderHost);
|
||
if (candidates.length === 0) return { ok: true, data: { action: 'save' } };
|
||
|
||
for (const [itemId, entry] of candidates) {
|
||
if (entry.type !== 'login') continue;
|
||
const full = await vault.fetchAndDecryptItem(state.gitHost, handle, itemId);
|
||
if (full.core.type !== 'login') continue;
|
||
if (full.core.username === msg.username) {
|
||
if (full.core.password === msg.password) return { ok: true, data: { action: 'skip' } };
|
||
return { ok: true, data: { action: 'update', entryId: itemId, entryName: entry.title } };
|
||
}
|
||
}
|
||
return { ok: true, data: { action: 'save' } };
|
||
}
|
||
|
||
case 'blacklist_site': {
|
||
const bl = await loadBlacklist();
|
||
if (!bl.includes(senderHost)) {
|
||
bl.push(senderHost);
|
||
await saveBlacklist(bl);
|
||
}
|
||
return { ok: true };
|
||
}
|
||
}
|
||
}
|
||
|
||
async function loadDeviceSettings(): Promise<{ captureEnabled: boolean; captureStyle: 'bar' | 'toast' }> {
|
||
const r = await chrome.storage.local.get('relicarioSettings');
|
||
return (r.relicarioSettings as { captureEnabled: boolean; captureStyle: 'bar' | 'toast' })
|
||
?? { captureEnabled: false, captureStyle: 'bar' };
|
||
}
|
||
|
||
async function loadBlacklist(): Promise<string[]> {
|
||
const r = await chrome.storage.local.get('captureBlacklist');
|
||
return (r.captureBlacklist as string[]) ?? [];
|
||
}
|
||
|
||
async function saveBlacklist(list: string[]): Promise<void> {
|
||
await chrome.storage.local.set({ captureBlacklist: list });
|
||
}
|
||
|
||
function safeHostname(url: string): string | undefined {
|
||
try { return new URL(url).hostname; } catch { return undefined; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add extension/src/service-worker/router/content-callable.ts
|
||
git commit -m "feat(ext/sw): router/content-callable handlers with origin derivation"
|
||
```
|
||
|
||
### Task 13: Create `router/index.ts` dispatcher
|
||
|
||
**Files:**
|
||
- Create: `extension/src/service-worker/router/index.ts`
|
||
|
||
- [ ] **Step 1: Write the file**
|
||
|
||
```ts
|
||
/// Single chrome.runtime.onMessage entry. Classifies the sender and dispatches
|
||
/// to popup-only or content-callable handlers. Unauthorized senders are
|
||
/// rejected with { ok: false, error: 'unauthorized_sender' }.
|
||
|
||
import type { Request, Response } from '../../shared/messages';
|
||
import { POPUP_ONLY_TYPES, CONTENT_CALLABLE_TYPES } from '../../shared/messages';
|
||
import type { Manifest } from '../../shared/types';
|
||
import type { GitHost } from '../git-host';
|
||
import * as popupOnly from './popup-only';
|
||
import * as contentCallable from './content-callable';
|
||
|
||
export interface RouterState {
|
||
manifest: Manifest | null;
|
||
gitHost: GitHost | null;
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
wasm: any;
|
||
}
|
||
|
||
export async function route(
|
||
msg: Request,
|
||
state: RouterState,
|
||
sender: chrome.runtime.MessageSender,
|
||
): Promise<Response> {
|
||
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;
|
||
|
||
if (POPUP_ONLY_TYPES.has(msg.type as never)) {
|
||
// save_setup gets one exception: allowed from the setup tab too.
|
||
if (!(isPopup || (msg.type === 'save_setup' && isSetup))) {
|
||
return { ok: false, error: 'unauthorized_sender' };
|
||
}
|
||
return popupOnly.handle(msg as never, state, sender);
|
||
}
|
||
|
||
if (CONTENT_CALLABLE_TYPES.has(msg.type as never)) {
|
||
if (!isContent) return { ok: false, error: 'unauthorized_sender' };
|
||
return contentCallable.handle(msg as never, state, sender);
|
||
}
|
||
|
||
return { ok: false, error: 'unknown_message_type' };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add extension/src/service-worker/router/index.ts
|
||
git commit -m "feat(ext/sw): router index with sender-based dispatch"
|
||
```
|
||
|
||
### Task 14: Collapse `service-worker/index.ts` onto the router
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/service-worker/index.ts`
|
||
|
||
- [ ] **Step 1: Replace the file**
|
||
|
||
```ts
|
||
/// Thin service-worker entry: loads WASM, constructs the router state, and
|
||
/// forwards every message into router/index.route().
|
||
|
||
import type { Request, Response } from '../shared/messages';
|
||
import type { RouterState } from './router/index';
|
||
import { route } from './router/index';
|
||
import * as vault from './vault';
|
||
|
||
// @ts-ignore TS2307 — resolved by webpack alias / copy
|
||
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
|
||
// @ts-ignore TS2307
|
||
import * as wasmBindings from '../../wasm/relicario_wasm.js';
|
||
|
||
type WasmModule = typeof wasmBindings;
|
||
let wasm: WasmModule | null = null;
|
||
|
||
async function initWasm(): Promise<WasmModule> {
|
||
if (wasm) return wasm;
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const SWGlobalScope = (globalThis as any).ServiceWorkerGlobalScope as (new () => ServiceWorker) | undefined;
|
||
const isServiceWorker = typeof SWGlobalScope !== 'undefined'
|
||
&& self instanceof (SWGlobalScope as unknown as typeof EventTarget);
|
||
|
||
if (isServiceWorker) {
|
||
const wasmResponse = await fetch(chrome.runtime.getURL('relicario_wasm_bg.wasm'));
|
||
const wasmBytes = await wasmResponse.arrayBuffer();
|
||
initSync({ module: new WebAssembly.Module(wasmBytes) });
|
||
} else {
|
||
const wasmUrl = chrome.runtime.getURL('relicario_wasm_bg.wasm');
|
||
await initDefault(wasmUrl);
|
||
}
|
||
|
||
vault.setWasm(wasmBindings);
|
||
wasm = wasmBindings;
|
||
return wasm;
|
||
}
|
||
|
||
// Single router-state object shared by all messages for this SW instance.
|
||
const state: RouterState = {
|
||
manifest: null,
|
||
gitHost: null,
|
||
wasm: null,
|
||
};
|
||
|
||
chrome.runtime.onMessage.addListener(
|
||
(request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => {
|
||
(async () => {
|
||
if (!state.wasm) state.wasm = await initWasm();
|
||
return route(request, state, sender);
|
||
})()
|
||
.then(sendResponse)
|
||
.catch((err: Error) => sendResponse({ ok: false, error: err.message }));
|
||
return true; // async response
|
||
},
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 2: Verify both builds pass**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario/extension
|
||
bun run build 2>&1 | tail -15
|
||
bun run build:firefox 2>&1 | tail -15
|
||
```
|
||
|
||
Expected: both succeed. The popup/content callers compiled in slice 3 remain under their `@ts-nocheck` shields until slice 6.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add extension/src/service-worker/index.ts
|
||
git commit -m "feat(ext/sw): collapse flat index onto router"
|
||
```
|
||
|
||
### Task 15: Wire Vitest + router test suite
|
||
|
||
**Files:**
|
||
- Modify: `extension/package.json`
|
||
- Create: `extension/vitest.config.ts`
|
||
- Create: `extension/src/service-worker/router/__tests__/router.test.ts`
|
||
|
||
- [ ] **Step 1: Add Vitest to devDependencies**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario/extension
|
||
bun add -d vitest@^2.0 happy-dom@^15
|
||
```
|
||
|
||
- [ ] **Step 2: Add `test` script to `package.json`**
|
||
|
||
In `extension/package.json`, add under `scripts`:
|
||
|
||
```json
|
||
"test": "vitest run",
|
||
"test:watch": "vitest"
|
||
```
|
||
|
||
- [ ] **Step 3: Create `vitest.config.ts`**
|
||
|
||
```ts
|
||
import { defineConfig } from 'vitest/config';
|
||
|
||
export default defineConfig({
|
||
test: {
|
||
environment: 'happy-dom',
|
||
include: ['src/**/__tests__/**/*.test.ts'],
|
||
},
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Write `router.test.ts`**
|
||
|
||
```ts
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import { route, type RouterState } from '../index';
|
||
import type { Request } from '../../../shared/messages';
|
||
|
||
// --- chrome.* shim ---
|
||
|
||
// @ts-expect-error test harness
|
||
globalThis.chrome = {
|
||
runtime: {
|
||
id: 'relicario-test-id',
|
||
getURL: (p: string) => `chrome-extension://relicario-test-id/${p}`,
|
||
},
|
||
storage: { local: { get: vi.fn().mockResolvedValue({}), set: vi.fn().mockResolvedValue(undefined) } },
|
||
tabs: { get: vi.fn(), sendMessage: vi.fn() },
|
||
};
|
||
|
||
function makePopupSender(): chrome.runtime.MessageSender {
|
||
return { url: `chrome-extension://relicario-test-id/popup.html`, id: 'relicario-test-id' };
|
||
}
|
||
|
||
function makeSetupSender(): chrome.runtime.MessageSender {
|
||
return { url: `chrome-extension://relicario-test-id/setup.html`, id: 'relicario-test-id' };
|
||
}
|
||
|
||
function makeContentSender(pageUrl = 'https://example.com/'): chrome.runtime.MessageSender {
|
||
return {
|
||
tab: { id: 42, url: pageUrl } as chrome.tabs.Tab,
|
||
frameId: 0,
|
||
id: 'relicario-test-id',
|
||
};
|
||
}
|
||
|
||
function makeExternalSender(): chrome.runtime.MessageSender {
|
||
return { url: 'https://evil.example/', id: 'some-other-extension' };
|
||
}
|
||
|
||
function makeState(): RouterState {
|
||
return {
|
||
manifest: { schema_version: 2, items: {} },
|
||
gitHost: null,
|
||
wasm: {
|
||
// Stubs sufficient for the message types exercised by tests:
|
||
new_item_id: () => 'fakeitemid0000ab',
|
||
generate_password: () => 'PASSWORD',
|
||
rate_passphrase: () => ({ score: 4, guesses_log10: 15 }),
|
||
},
|
||
};
|
||
}
|
||
|
||
// --- Sender-check matrix ---
|
||
|
||
describe('router sender dispatch', () => {
|
||
let state: RouterState;
|
||
beforeEach(() => { state = makeState(); });
|
||
|
||
const popupOnlyMsgs: Request[] = [
|
||
{ type: 'is_unlocked' },
|
||
{ type: 'lock' },
|
||
{ type: 'list_items' },
|
||
{ type: 'generate_password', request: { kind: 'random', length: 20, classes: { lower: true, upper: true, digits: true, symbols: true }, symbol_charset: { kind: 'safe_only' } } },
|
||
{ type: 'rate_passphrase', passphrase: 'hunter2hunter2hunter2' },
|
||
{ type: 'get_blacklist' },
|
||
];
|
||
|
||
for (const msg of popupOnlyMsgs) {
|
||
it(`accepts popup-only "${msg.type}" from popup`, async () => {
|
||
const res = await route(msg, state, makePopupSender());
|
||
expect(res).toMatchObject({ ok: true });
|
||
});
|
||
it(`rejects popup-only "${msg.type}" from content`, async () => {
|
||
const res = await route(msg, state, makeContentSender());
|
||
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||
});
|
||
it(`rejects popup-only "${msg.type}" from external`, async () => {
|
||
const res = await route(msg, state, makeExternalSender());
|
||
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||
});
|
||
}
|
||
|
||
it('accepts save_setup from popup', async () => {
|
||
const msg: Request = { type: 'save_setup', config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' }, imageBase64: '' };
|
||
const res = await route(msg, state, makePopupSender());
|
||
expect(res).toMatchObject({ ok: true });
|
||
});
|
||
|
||
it('accepts save_setup from setup tab', async () => {
|
||
const msg: Request = { type: 'save_setup', config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' }, imageBase64: '' };
|
||
const res = await route(msg, state, makeSetupSender());
|
||
expect(res).toMatchObject({ ok: true });
|
||
});
|
||
|
||
it('rejects save_setup from content', async () => {
|
||
const msg: Request = { type: 'save_setup', config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' }, imageBase64: '' };
|
||
const res = await route(msg, state, makeContentSender());
|
||
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||
});
|
||
|
||
const contentMsgs: Request[] = [
|
||
{ type: 'get_autofill_candidates' },
|
||
{ type: 'blacklist_site' },
|
||
];
|
||
|
||
for (const msg of contentMsgs) {
|
||
it(`accepts content "${msg.type}" from top-frame content`, async () => {
|
||
const res = await route(msg, state, makeContentSender());
|
||
expect(res.ok).toBe(true);
|
||
});
|
||
it(`rejects content "${msg.type}" from popup`, async () => {
|
||
const res = await route(msg, state, makePopupSender());
|
||
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||
});
|
||
it(`rejects content "${msg.type}" from subframe`, async () => {
|
||
const sender: chrome.runtime.MessageSender = { ...makeContentSender(), frameId: 3 };
|
||
const res = await route(msg, state, sender);
|
||
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||
});
|
||
it(`rejects content "${msg.type}" from external`, async () => {
|
||
const res = await route(msg, state, makeExternalSender());
|
||
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||
});
|
||
}
|
||
|
||
it('rejects unknown message type', async () => {
|
||
// @ts-expect-error intentional invalid type
|
||
const res = await route({ type: 'nonsense' }, state, makePopupSender());
|
||
expect(res).toEqual({ ok: false, error: 'unknown_message_type' });
|
||
});
|
||
});
|
||
|
||
// --- Origin-bound autofill ---
|
||
|
||
describe('get_autofill_candidates uses sender.tab.url', () => {
|
||
it('derives hostname from sender, not message', async () => {
|
||
const state: RouterState = makeState();
|
||
state.manifest = {
|
||
schema_version: 2,
|
||
items: {
|
||
'aaaaaaaaaaaaaaaa': {
|
||
id: 'aaaaaaaaaaaaaaaa', type: 'login', title: 'GitHub',
|
||
tags: [], favorite: false, icon_hint: 'github.com',
|
||
modified: 0, attachment_summaries: [],
|
||
},
|
||
'bbbbbbbbbbbbbbbb': {
|
||
id: 'bbbbbbbbbbbbbbbb', type: 'login', title: 'Example',
|
||
tags: [], favorite: false, icon_hint: 'example.com',
|
||
modified: 0, attachment_summaries: [],
|
||
},
|
||
},
|
||
};
|
||
const res = await route(
|
||
{ type: 'get_autofill_candidates' },
|
||
state,
|
||
makeContentSender('https://example.com/login'),
|
||
);
|
||
expect(res.ok).toBe(true);
|
||
if (res.ok) {
|
||
const data = res.data as { candidates: Array<[string, { title: string }]> };
|
||
expect(data.candidates).toHaveLength(1);
|
||
expect(data.candidates[0][1].title).toBe('Example');
|
||
}
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: Run the router tests**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario/extension
|
||
bun run test 2>&1 | tail -30
|
||
```
|
||
|
||
Expected: all tests pass. base32 tests (Task 6) also run in this pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add extension/package.json extension/bun.lock extension/vitest.config.ts extension/src/service-worker/router/__tests__/router.test.ts
|
||
git commit -m "test(ext): vitest + router sender-check + origin-bound autofill"
|
||
```
|
||
|
||
---
|
||
|
||
## Slice 5 — Security hardening
|
||
|
||
Goal: the remaining security items — manifest WAR cleanup, setup.html opened via `chrome.tabs.create` (not WAR), closed Shadow DOM in content scripts, popup captured-tab snapshot on init.
|
||
|
||
### Task 16: Manifest WAR cleanup for Chrome + Firefox
|
||
|
||
**Files:**
|
||
- Modify: `extension/manifest.json`
|
||
- Modify: `extension/manifest.firefox.json`
|
||
|
||
- [ ] **Step 1: Edit Chrome manifest**
|
||
|
||
Replace `web_accessible_resources` with the minimal set. If `styles.css` isn't used from pages (check `content/*.ts` for any `chrome.runtime.getURL('styles.css')`), drop it entirely.
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
grep -rn "styles.css" extension/src/
|
||
```
|
||
|
||
If no hits from content scripts, target shape:
|
||
|
||
```json
|
||
"web_accessible_resources": []
|
||
```
|
||
|
||
Or omit the field. For Chrome MV3 an empty array is permitted but the field can be removed entirely. Test both — some versions of chrome prefer absence.
|
||
|
||
Edit `extension/manifest.json`: change the `web_accessible_resources` block to:
|
||
|
||
```json
|
||
"web_accessible_resources": []
|
||
```
|
||
|
||
- [ ] **Step 2: Edit Firefox manifest the same way**
|
||
|
||
In `extension/manifest.firefox.json`:
|
||
|
||
```json
|
||
"web_accessible_resources": []
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add extension/manifest.json extension/manifest.firefox.json
|
||
git commit -m "feat(ext): drop setup.html / wasm from web_accessible_resources (audit C1)"
|
||
```
|
||
|
||
### Task 17: Popup + setup open via `chrome.tabs.create`
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/popup/popup.ts`
|
||
|
||
This is only relevant if the popup currently navigates to `chrome.runtime.getURL('setup.html')`. Today it does via `setup-wizard.ts`. Change it to open a new tab instead.
|
||
|
||
- [ ] **Step 1: Locate the current setup navigation**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
grep -rn "setup.html\|setup-wizard" extension/src/popup/ | head -10
|
||
```
|
||
|
||
- [ ] **Step 2: Update popup.ts init to open setup in a tab when not configured**
|
||
|
||
Find the `init()` function in `extension/src/popup/popup.ts`. Replace the branch `if (!data.isConfigured) { navigate('setup'); return; }` with:
|
||
|
||
```ts
|
||
if (!data.isConfigured) {
|
||
await chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
|
||
window.close();
|
||
return;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Remove the `setup` view from the popup state machine**
|
||
|
||
Search for all `navigate('setup')` calls in `extension/src/popup/components/*.ts` and replace each with:
|
||
|
||
```ts
|
||
await chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
|
||
window.close();
|
||
```
|
||
|
||
Remove `'setup'` from the `View` union in `popup.ts`:
|
||
|
||
```ts
|
||
export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings';
|
||
```
|
||
|
||
Remove the `case 'setup':` branch from `render()`.
|
||
|
||
Delete the file `extension/src/popup/components/setup-wizard.ts` (it referenced the old Entry type; the standalone setup flow in `src/setup/setup.ts` handles all setup UX now).
|
||
|
||
- [ ] **Step 4: Rebuild**
|
||
|
||
```bash
|
||
cd extension
|
||
bun run build 2>&1 | tail -10
|
||
```
|
||
|
||
Expected: build passes. If setup-wizard.ts is still referenced anywhere, remove the import.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add -A extension/src/popup/
|
||
git commit -m "feat(ext/popup): open setup via chrome.tabs.create, drop setup view from popup"
|
||
```
|
||
|
||
### Task 18: Closed Shadow DOM helper + content-script rewrite (capture)
|
||
|
||
**Files:**
|
||
- Create: `extension/src/content/shadow.ts`
|
||
- Modify: `extension/src/content/capture.ts`
|
||
|
||
- [ ] **Step 1: Create the shadow host helper**
|
||
|
||
```ts
|
||
// extension/src/content/shadow.ts
|
||
/// Creates a closed Shadow DOM host attached to document.body.
|
||
/// Page JS cannot read host.shadowRoot (it's null from outside) and our
|
||
/// rendered DOM has no stable IDs.
|
||
|
||
export interface ShadowSurface {
|
||
host: HTMLElement;
|
||
root: ShadowRoot;
|
||
destroy: () => void;
|
||
}
|
||
|
||
export function createShadowHost(): ShadowSurface {
|
||
const host = document.createElement('div');
|
||
host.dataset.rel = ''; // no identifying class/id
|
||
document.body.appendChild(host);
|
||
const root = host.attachShadow({ mode: 'closed' });
|
||
return { host, root, destroy: () => host.remove() };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Rewrite `content/capture.ts` to use the shadow host + textContent**
|
||
|
||
Replace the entire file:
|
||
|
||
```ts
|
||
/// Credential capture: hook form submissions; if the submitted credentials
|
||
/// aren't already in the vault (or differ), prompt the user to save/update.
|
||
///
|
||
/// All page UI lives inside a closed Shadow DOM with textContent-only DOM
|
||
/// construction. No stable IDs, no innerHTML.
|
||
|
||
import type { Request, Response } from '../shared/messages';
|
||
import type { DeviceSettings } from '../shared/types';
|
||
import { createShadowHost } from './shadow';
|
||
|
||
const hookedForms = new WeakSet<HTMLFormElement>();
|
||
const hookedButtons = new WeakSet<HTMLElement>();
|
||
|
||
function sendMessage(request: Request): Promise<Response> {
|
||
return new Promise((resolve) => chrome.runtime.sendMessage(request, (r: Response) => resolve(r)));
|
||
}
|
||
|
||
/// Bounded, control-char-free username scrape.
|
||
function findUsernameValue(pwField: HTMLInputElement): string {
|
||
const form = pwField.closest('form');
|
||
const scope = form ?? document;
|
||
const inputs = scope.querySelectorAll<HTMLInputElement>('input');
|
||
|
||
for (const input of inputs) {
|
||
if (input === pwField) continue;
|
||
if (input.autocomplete === 'username' && input.value) return sanitize(input.value);
|
||
}
|
||
for (const input of inputs) {
|
||
if (input === pwField) continue;
|
||
if (input.autocomplete === 'email' && input.value) return sanitize(input.value);
|
||
}
|
||
for (const input of inputs) {
|
||
if (input === pwField) continue;
|
||
if (input.type === 'email' && input.value) return sanitize(input.value);
|
||
}
|
||
const pattern = /user|email|login|account/i;
|
||
for (const input of inputs) {
|
||
if (input === pwField) continue;
|
||
if (input.type === 'hidden' || input.type === 'password') continue;
|
||
if ((pattern.test(input.name) || pattern.test(input.id)) && input.value) return sanitize(input.value);
|
||
}
|
||
const allInputs = Array.from(inputs);
|
||
const pwIndex = allInputs.indexOf(pwField);
|
||
for (let i = pwIndex - 1; i >= 0; i--) {
|
||
const input = allInputs[i];
|
||
if (input.type === 'hidden' || input.type === 'password' || input.type === 'submit') continue;
|
||
if (input.offsetWidth > 0 && input.offsetHeight > 0 && input.value) return sanitize(input.value);
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function sanitize(s: string): string {
|
||
return s.replace(/\p{Cc}/gu, '').slice(0, 256);
|
||
}
|
||
|
||
let currentPrompt: ShadowSurface | null = null;
|
||
|
||
async function onFormSubmit(pwField: HTMLInputElement): Promise<void> {
|
||
const password = pwField.value;
|
||
if (!password) return;
|
||
|
||
const username = findUsernameValue(pwField);
|
||
const resp = await sendMessage({ type: 'check_credential', username, password });
|
||
if (!resp.ok) return;
|
||
|
||
const data = resp.data as { action: 'skip' | 'save' | 'update'; entryId?: string; entryName?: string };
|
||
if (data.action === 'skip') return;
|
||
|
||
const settingsResp = await sendMessage({ type: 'get_settings' });
|
||
const settings: DeviceSettings = settingsResp.ok
|
||
? (settingsResp.data as { settings: DeviceSettings }).settings
|
||
: { captureEnabled: true, captureStyle: 'bar' };
|
||
|
||
showPrompt(settings.captureStyle, data.action, username, password, data.entryId);
|
||
}
|
||
|
||
type ShadowSurface = ReturnType<typeof createShadowHost>;
|
||
|
||
function removeExistingPrompt(): void {
|
||
currentPrompt?.destroy();
|
||
currentPrompt = null;
|
||
}
|
||
|
||
function showPrompt(
|
||
style: 'bar' | 'toast',
|
||
action: 'save' | 'update',
|
||
username: string,
|
||
password: string,
|
||
entryId?: string,
|
||
): void {
|
||
removeExistingPrompt();
|
||
currentPrompt = createShadowHost();
|
||
const { root, host } = currentPrompt;
|
||
|
||
// Style the host itself (positioning) so page CSS can't override via specificity.
|
||
const positioning = style === 'bar'
|
||
? 'position:fixed;top:0;left:0;right:0;z-index:2147483647;transform:translateY(-100%);transition:transform .3s;'
|
||
: 'position:fixed;bottom:16px;right:16px;z-index:2147483647;opacity:0;transition:opacity .3s;';
|
||
host.setAttribute('style', positioning);
|
||
|
||
// Shadow root style sheet — single static literal, no injected values.
|
||
const style0 = document.createElement('style');
|
||
style0.textContent = `
|
||
.box { font-family: system-ui, sans-serif; font-size: 13px; color: #c9d1d9;
|
||
background: #161b22; padding: 10px 16px; display: flex; align-items: center;
|
||
gap: 12px; border: 1px solid #30363d; border-radius: 4px;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.4); line-height: 1.4; }
|
||
.host { color: #58a6ff; font-weight: 600; }
|
||
.btn-primary { background: #1f6feb; color: #fff; border: none; padding: 5px 14px;
|
||
border-radius: 3px; cursor: pointer; font: inherit; }
|
||
.btn-secondary { background: transparent; color: #8b949e; border: 1px solid #30363d;
|
||
padding: 5px 10px; border-radius: 3px; cursor: pointer; font: inherit; }
|
||
.btn-close { background: transparent; color: #8b949e; border: none; cursor: pointer;
|
||
padding: 2px 6px; font: inherit; }
|
||
.msg { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
`;
|
||
root.appendChild(style0);
|
||
|
||
const box = document.createElement('div');
|
||
box.className = 'box';
|
||
|
||
const msg = document.createElement('span');
|
||
msg.className = 'msg';
|
||
const actionLabel = action === 'update' ? 'Update' : 'Save';
|
||
const hostname = window.location.hostname;
|
||
msg.appendChild(document.createTextNode(`${actionLabel} login for `));
|
||
const hostSpan = document.createElement('span');
|
||
hostSpan.className = 'host';
|
||
hostSpan.textContent = hostname;
|
||
msg.appendChild(hostSpan);
|
||
if (username) msg.appendChild(document.createTextNode(` (${username})?`));
|
||
else msg.appendChild(document.createTextNode('?'));
|
||
|
||
const saveBtn = document.createElement('button');
|
||
saveBtn.className = 'btn-primary';
|
||
saveBtn.textContent = actionLabel;
|
||
|
||
const neverBtn = document.createElement('button');
|
||
neverBtn.className = 'btn-secondary';
|
||
neverBtn.textContent = 'Never';
|
||
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.className = 'btn-close';
|
||
closeBtn.textContent = '✕';
|
||
|
||
box.appendChild(msg);
|
||
box.appendChild(saveBtn);
|
||
box.appendChild(neverBtn);
|
||
box.appendChild(closeBtn);
|
||
root.appendChild(box);
|
||
|
||
// Animate in
|
||
requestAnimationFrame(() => {
|
||
if (style === 'bar') host.style.transform = 'translateY(0)';
|
||
else host.style.opacity = '1';
|
||
});
|
||
|
||
let autoDismissTimer: ReturnType<typeof setTimeout> | null = null;
|
||
if (style === 'toast') {
|
||
autoDismissTimer = setTimeout(() => removeExistingPrompt(), 15_000);
|
||
}
|
||
const clearAutoDismiss = (): void => { if (autoDismissTimer) clearTimeout(autoDismissTimer); };
|
||
|
||
saveBtn.addEventListener('click', async () => {
|
||
clearAutoDismiss();
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const item = {
|
||
id: '', title: hostname, type: 'login' as const, tags: [], favorite: false,
|
||
created: now, modified: now,
|
||
core: { type: 'login' as const, username, password, url: window.location.origin },
|
||
sections: [], attachments: [], field_history: {},
|
||
};
|
||
if (action === 'update' && entryId) {
|
||
await sendMessage({ type: 'update_item', id: entryId, item });
|
||
} else {
|
||
await sendMessage({ type: 'add_item', item });
|
||
}
|
||
msg.textContent = '✓ Saved';
|
||
saveBtn.style.display = 'none';
|
||
neverBtn.style.display = 'none';
|
||
setTimeout(() => removeExistingPrompt(), 1500);
|
||
});
|
||
|
||
neverBtn.addEventListener('click', async () => {
|
||
clearAutoDismiss();
|
||
await sendMessage({ type: 'blacklist_site' });
|
||
removeExistingPrompt();
|
||
});
|
||
|
||
closeBtn.addEventListener('click', () => {
|
||
clearAutoDismiss();
|
||
removeExistingPrompt();
|
||
});
|
||
}
|
||
|
||
export function hookForms(): void {
|
||
const passwordFields = document.querySelectorAll<HTMLInputElement>('input[type="password"]');
|
||
for (const pwField of passwordFields) {
|
||
if (pwField.offsetWidth < 20 || pwField.offsetHeight < 10) continue;
|
||
const form = pwField.closest('form');
|
||
if (form && !hookedForms.has(form)) {
|
||
hookedForms.add(form);
|
||
form.addEventListener('submit', () => { onFormSubmit(pwField); });
|
||
}
|
||
const scope = form ?? pwField.parentElement;
|
||
if (!scope) continue;
|
||
const buttons = scope.querySelectorAll<HTMLElement>('button[type="submit"], input[type="submit"], button:not([type])');
|
||
for (const btn of buttons) {
|
||
if (hookedButtons.has(btn)) continue;
|
||
hookedButtons.add(btn);
|
||
btn.addEventListener('click', () => { onFormSubmit(pwField); });
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/content/shadow.ts extension/src/content/capture.ts
|
||
git commit -m "feat(ext/content): closed Shadow DOM + textContent for capture prompt"
|
||
```
|
||
|
||
### Task 19: Closed Shadow DOM for icon + picker
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/content/icon.ts`
|
||
|
||
- [ ] **Step 1: Replace the file**
|
||
|
||
```ts
|
||
/// Inject a small "id" icon next to password fields. Clicking it queries for
|
||
/// autofill candidates and either fills or shows a picker — both rendered in
|
||
/// closed Shadow DOMs attached to document.body (not as page-DOM children).
|
||
|
||
import type { ManifestEntry } from '../shared/types';
|
||
import { createShadowHost } from './shadow';
|
||
|
||
const injected = new WeakSet<HTMLInputElement>();
|
||
|
||
export function injectFieldIcons(
|
||
passwordField: HTMLInputElement,
|
||
_usernameField: HTMLInputElement | null,
|
||
): void {
|
||
if (injected.has(passwordField)) return;
|
||
injected.add(passwordField);
|
||
|
||
const iconSurface = createShadowHost();
|
||
// Position the host over the password field.
|
||
const rect = passwordField.getBoundingClientRect();
|
||
Object.assign(iconSurface.host.style, {
|
||
position: 'fixed',
|
||
top: `${rect.top + rect.height / 2 - 10}px`,
|
||
left: `${rect.right - 28}px`,
|
||
width: '20px',
|
||
height: '20px',
|
||
zIndex: '2147483647',
|
||
});
|
||
|
||
const style = document.createElement('style');
|
||
style.textContent = `
|
||
.i { width: 20px; height: 20px; line-height: 20px; text-align: center;
|
||
font: 700 10px monospace; color: #fff; background: #1f6feb;
|
||
border-radius: 3px; cursor: pointer; user-select: none; }
|
||
`;
|
||
iconSurface.root.appendChild(style);
|
||
|
||
const icon = document.createElement('div');
|
||
icon.className = 'i';
|
||
icon.textContent = 'id';
|
||
iconSurface.root.appendChild(icon);
|
||
|
||
// Keep icon positioned across scroll/resize.
|
||
const reposition = (): void => {
|
||
const r = passwordField.getBoundingClientRect();
|
||
iconSurface.host.style.top = `${r.top + r.height / 2 - 10}px`;
|
||
iconSurface.host.style.left = `${r.right - 28}px`;
|
||
};
|
||
window.addEventListener('scroll', reposition, true);
|
||
window.addEventListener('resize', reposition);
|
||
|
||
icon.addEventListener('click', async (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const resp = await chrome.runtime.sendMessage({ type: 'get_autofill_candidates' });
|
||
if (!resp || !resp.ok) return;
|
||
const candidates = resp.data.candidates as Array<[string, ManifestEntry]>;
|
||
if (candidates.length === 0) return;
|
||
if (candidates.length === 1) {
|
||
const [id] = candidates[0];
|
||
// Popup-captured tab logic doesn't apply here — this is a direct in-page click;
|
||
// the SW enforces origin check via sender.tab.url on get_credentials.
|
||
const credResp = await chrome.runtime.sendMessage({ type: 'get_credentials', id });
|
||
if (credResp?.ok) {
|
||
const d = credResp.data as { requires_ack?: true; hostname?: string; username?: string; password?: string };
|
||
if (d.requires_ack) {
|
||
showAckMessage(iconSurface.host, d.hostname ?? window.location.hostname);
|
||
return;
|
||
}
|
||
chrome.runtime.sendMessage({
|
||
type: 'fill_credentials',
|
||
username: d.username,
|
||
password: d.password,
|
||
});
|
||
}
|
||
} else {
|
||
showPicker(iconSurface.host, candidates);
|
||
}
|
||
});
|
||
}
|
||
|
||
function showPicker(anchor: HTMLElement, candidates: Array<[string, ManifestEntry]>): void {
|
||
// Reuse the anchor's shadow host by mounting a dropdown below it.
|
||
const pickerSurface = createShadowHost();
|
||
const r = anchor.getBoundingClientRect();
|
||
Object.assign(pickerSurface.host.style, {
|
||
position: 'fixed', top: `${r.bottom + 4}px`, left: `${r.right - 200}px`,
|
||
zIndex: '2147483647',
|
||
});
|
||
|
||
const style = document.createElement('style');
|
||
style.textContent = `
|
||
.p { background:#161b22;border:1px solid #30363d;border-radius:6px;
|
||
box-shadow:0 4px 12px rgba(0,0,0,.4);min-width:200px;max-height:200px;
|
||
overflow-y:auto;font-family:system-ui,sans-serif;font-size:12px;color:#c9d1d9; }
|
||
.row { padding:8px 12px;cursor:pointer;border-bottom:1px solid #21262d; }
|
||
.row:hover { background:#21262d; }
|
||
`;
|
||
pickerSurface.root.appendChild(style);
|
||
|
||
const container = document.createElement('div');
|
||
container.className = 'p';
|
||
pickerSurface.root.appendChild(container);
|
||
|
||
for (const [id, entry] of candidates) {
|
||
const row = document.createElement('div');
|
||
row.className = 'row';
|
||
row.textContent = entry.title; // title only; could append group/tags later
|
||
row.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
pickerSurface.destroy();
|
||
const credResp = await chrome.runtime.sendMessage({ type: 'get_credentials', id });
|
||
if (credResp?.ok) {
|
||
const d = credResp.data as { requires_ack?: true; hostname?: string; username?: string; password?: string };
|
||
if (d.requires_ack) {
|
||
showAckMessage(anchor, d.hostname ?? window.location.hostname);
|
||
return;
|
||
}
|
||
chrome.runtime.sendMessage({ type: 'fill_credentials', username: d.username, password: d.password });
|
||
}
|
||
});
|
||
container.appendChild(row);
|
||
}
|
||
|
||
const closeOnOutside = (): void => {
|
||
pickerSurface.destroy();
|
||
document.removeEventListener('click', closeOnOutside, true);
|
||
};
|
||
setTimeout(() => document.addEventListener('click', closeOnOutside, true), 0);
|
||
}
|
||
|
||
function showAckMessage(anchor: HTMLElement, hostname: string): void {
|
||
const surface = createShadowHost();
|
||
const r = anchor.getBoundingClientRect();
|
||
Object.assign(surface.host.style, {
|
||
position: 'fixed', top: `${r.bottom + 4}px`, left: `${r.right - 240}px`,
|
||
zIndex: '2147483647',
|
||
});
|
||
const style = document.createElement('style');
|
||
style.textContent = `
|
||
.box { background:#161b22;border:1px solid #30363d;border-radius:6px;
|
||
padding:10px 12px;color:#c9d1d9;font-family:system-ui,sans-serif;
|
||
font-size:12px;max-width:240px;box-shadow:0 4px 12px rgba(0,0,0,.4); }
|
||
.host { color:#58a6ff; }
|
||
`;
|
||
surface.root.appendChild(style);
|
||
const box = document.createElement('div');
|
||
box.className = 'box';
|
||
const line1 = document.createElement('div');
|
||
line1.appendChild(document.createTextNode('First autofill on '));
|
||
const hostSpan = document.createElement('span');
|
||
hostSpan.className = 'host';
|
||
hostSpan.textContent = hostname;
|
||
line1.appendChild(hostSpan);
|
||
const line2 = document.createElement('div');
|
||
line2.textContent = 'Open the relicario popup to confirm, then click again.';
|
||
box.appendChild(line1);
|
||
box.appendChild(line2);
|
||
surface.root.appendChild(box);
|
||
setTimeout(() => surface.destroy(), 5000);
|
||
}
|
||
```
|
||
|
||
Note this task edits `icon.ts` to include the TOFU `requires_ack` check surface. It also removes the old dependence on `url` passed from content → SW for `get_autofill_candidates` (the SW now derives it).
|
||
|
||
- [ ] **Step 2: Rebuild**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario/extension
|
||
bun run build 2>&1 | tail -10
|
||
```
|
||
|
||
Expected: build passes. Content/detector.ts and content/fill.ts are untouched.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/content/icon.ts
|
||
git commit -m "feat(ext/content): closed Shadow DOM for autofill icon + picker + ack hint"
|
||
```
|
||
|
||
### Task 20: Popup captured-tab snapshot on init
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/popup/popup.ts`
|
||
|
||
- [ ] **Step 1: Add captured-tab fields to PopupState**
|
||
|
||
At the top of `popup.ts`, extend the state type:
|
||
|
||
```ts
|
||
export interface PopupState {
|
||
view: View;
|
||
entries: Array<[string, import('../shared/types').ManifestEntry]>;
|
||
selectedId: string | null;
|
||
selectedItem: import('../shared/types').Item | null;
|
||
selectedIndex: number;
|
||
searchQuery: string;
|
||
activeGroup: string | null;
|
||
error: string | null;
|
||
loading: boolean;
|
||
capturedTabId: number | null;
|
||
capturedUrl: string;
|
||
}
|
||
```
|
||
|
||
Update the initializer and `setState`/`navigate` to preserve these.
|
||
|
||
- [ ] **Step 2: Snapshot the active tab at init**
|
||
|
||
In `init()` after the setup-state check, before the unlock branch, add:
|
||
|
||
```ts
|
||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||
currentState.capturedTabId = tab?.id ?? null;
|
||
currentState.capturedUrl = tab?.url ?? '';
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add extension/src/popup/popup.ts
|
||
git commit -m "feat(ext/popup): snapshot activeTab at popup-open for fill_credentials (audit M5)"
|
||
```
|
||
|
||
---
|
||
|
||
## Slice 6 — Login-parity popup + zxcvbn setup + Firefox verification
|
||
|
||
Goal: delete the `@ts-nocheck` shields, rewire the popup components onto `Item::Login`, add the zxcvbn strength meter to the setup wizard, verify both Chrome and Firefox.
|
||
|
||
### Task 21: Rename popup components `entry-*` → `item-*`
|
||
|
||
**Files:**
|
||
- Move: `extension/src/popup/components/entry-list.ts` → `item-list.ts`
|
||
- Move: `extension/src/popup/components/entry-detail.ts` → `item-detail.ts`
|
||
- Move: `extension/src/popup/components/entry-form.ts` → `item-form.ts`
|
||
|
||
- [ ] **Step 1: Rename the files (preserving git history)**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git mv extension/src/popup/components/entry-list.ts extension/src/popup/components/item-list.ts
|
||
git mv extension/src/popup/components/entry-detail.ts extension/src/popup/components/item-detail.ts
|
||
git mv extension/src/popup/components/entry-form.ts extension/src/popup/components/item-form.ts
|
||
```
|
||
|
||
- [ ] **Step 2: Update imports in `popup.ts`**
|
||
|
||
Replace the three imports:
|
||
|
||
```ts
|
||
import { renderItemList } from './components/item-list';
|
||
import { renderItemDetail } from './components/item-detail';
|
||
import { renderItemForm } from './components/item-form';
|
||
```
|
||
|
||
And the three `case 'list' / 'detail' / 'add'|'edit'` render calls.
|
||
|
||
- [ ] **Step 3: Commit the rename only (no content changes yet)**
|
||
|
||
```bash
|
||
git commit -m "refactor(ext/popup): rename entry-* → item-* components"
|
||
```
|
||
|
||
### Task 22: Rewrite `item-list.ts` for ManifestEntry v2
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/popup/components/item-list.ts`
|
||
|
||
- [ ] **Step 1: Replace the file body with the v2 list renderer**
|
||
|
||
Locate the file and replace its content. The new list view should:
|
||
|
||
- Read `state.entries: Array<[ItemId, ManifestEntry]>` (already populated in `popup.ts:init()` via `list_items`).
|
||
- Render each row with: type-icon glyph, title, group (if set), tags inline, a ★ for favorites.
|
||
- Filter by `state.searchQuery` (case-insensitive against title + tags).
|
||
- Clicking a row dispatches `sendMessage({ type: 'get_item', id })` and navigates to `detail`.
|
||
- Add a "New…" button that navigates to `add` (defaulting to `login` type for α).
|
||
|
||
Target structure (abbreviated — fill in HTML/CSS matching existing `styles.css` classes):
|
||
|
||
```ts
|
||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||
import type { ManifestEntry } from '../../shared/types';
|
||
|
||
export async function renderItemList(app: HTMLElement): Promise<void> {
|
||
const state = getState();
|
||
const q = state.searchQuery.toLowerCase();
|
||
const filtered = state.entries.filter(([, e]) =>
|
||
e.title.toLowerCase().includes(q) || e.tags.some((t) => t.toLowerCase().includes(q))
|
||
);
|
||
|
||
app.innerHTML = `
|
||
<div class="pad">
|
||
<div class="toolbar">
|
||
<input id="search" class="input" placeholder="search..." value="${escapeHtml(state.searchQuery)}" />
|
||
<button id="new-btn" class="btn">+ New</button>
|
||
<button id="sync-btn" class="btn">sync</button>
|
||
<button id="lock-btn" class="btn">lock</button>
|
||
<button id="settings-btn" class="btn">⚙</button>
|
||
</div>
|
||
<ul class="entry-list">
|
||
${filtered.map(([id, e]) => row(id, e)).join('')}
|
||
</ul>
|
||
</div>
|
||
`;
|
||
|
||
document.getElementById('search')?.addEventListener('input', (ev) => {
|
||
setState({ searchQuery: (ev.target as HTMLInputElement).value });
|
||
});
|
||
document.getElementById('new-btn')?.addEventListener('click', () => navigate('add'));
|
||
document.getElementById('sync-btn')?.addEventListener('click', async () => {
|
||
await sendMessage({ type: 'sync' });
|
||
const res = await sendMessage({ type: 'list_items' });
|
||
if (res.ok) setState({ entries: (res.data as { items: Array<[string, ManifestEntry]> }).items });
|
||
});
|
||
document.getElementById('lock-btn')?.addEventListener('click', async () => {
|
||
await sendMessage({ type: 'lock' });
|
||
navigate('locked');
|
||
});
|
||
document.getElementById('settings-btn')?.addEventListener('click', () => navigate('settings'));
|
||
|
||
for (const [id] of filtered) {
|
||
document.querySelector(`[data-id="${id}"]`)?.addEventListener('click', async () => {
|
||
const res = await sendMessage({ type: 'get_item', id });
|
||
if (res.ok) {
|
||
const item = (res.data as { item: import('../../shared/types').Item }).item;
|
||
setState({ selectedId: id, selectedItem: item });
|
||
navigate('detail');
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function row(id: string, e: ManifestEntry): string {
|
||
const icon = iconFor(e.type);
|
||
const star = e.favorite ? '★' : '';
|
||
const tags = e.tags.length > 0 ? `<span class="tags">${e.tags.map(escapeHtml).join(' ')}</span>` : '';
|
||
return `
|
||
<li class="entry-row" data-id="${escapeHtml(id)}">
|
||
<span class="icon">${icon}</span>
|
||
<span class="title">${escapeHtml(e.title)}</span>
|
||
${e.group ? `<span class="group">${escapeHtml(e.group)}</span>` : ''}
|
||
${tags}
|
||
<span class="star">${star}</span>
|
||
</li>
|
||
`;
|
||
}
|
||
|
||
function iconFor(t: string): string {
|
||
switch (t) {
|
||
case 'login': return '🔑';
|
||
case 'secure_note': return '📝';
|
||
case 'identity': return '🪪';
|
||
case 'card': return '💳';
|
||
case 'key': return '🗝';
|
||
case 'document': return '📄';
|
||
case 'totp': return '⏱';
|
||
default: return '◇';
|
||
}
|
||
}
|
||
```
|
||
|
||
Remove any `@ts-nocheck` comment that was added earlier.
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add extension/src/popup/components/item-list.ts extension/src/popup/popup.ts
|
||
git commit -m "feat(ext/popup): typed-item list view"
|
||
```
|
||
|
||
### Task 23: Rewrite `item-detail.ts` — Login detail + coming-soon for others
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/popup/components/item-detail.ts`
|
||
|
||
- [ ] **Step 1: Replace the file**
|
||
|
||
```ts
|
||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||
import type { Item } from '../../shared/types';
|
||
import { base32Encode } from '../../shared/base32';
|
||
|
||
export async function renderItemDetail(app: HTMLElement): Promise<void> {
|
||
const state = getState();
|
||
const item = state.selectedItem;
|
||
if (!item) { navigate('list'); return; }
|
||
|
||
switch (item.type) {
|
||
case 'login': renderLogin(app, item); return;
|
||
case 'secure_note':
|
||
case 'identity':
|
||
case 'card':
|
||
case 'key':
|
||
case 'document':
|
||
case 'totp': renderComingSoon(app, item); return;
|
||
}
|
||
}
|
||
|
||
function renderLogin(app: HTMLElement, item: Item): void {
|
||
if (item.core.type !== 'login') return;
|
||
const { username, password, url, totp } = item.core;
|
||
|
||
app.innerHTML = `
|
||
<div class="pad">
|
||
<div class="detail-title">${escapeHtml(item.title)}</div>
|
||
<div class="detail-row"><span class="label">username</span> <span id="d-user">${escapeHtml(username ?? '')}</span> <button class="btn" id="copy-user">copy</button></div>
|
||
<div class="detail-row"><span class="label">password</span> <span id="d-pass">••••••</span> <button class="btn" id="reveal-pass">show</button> <button class="btn" id="copy-pass">copy</button></div>
|
||
${url ? `<div class="detail-row"><span class="label">url</span> <a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a></div>` : ''}
|
||
${totp ? `<div class="detail-row"><span class="label">totp</span> <span id="d-totp">…</span> <span id="d-totp-exp">…</span></div>` : ''}
|
||
${item.notes ? `<div class="detail-row"><span class="label">notes</span> <span>${escapeHtml(item.notes)}</span></div>` : ''}
|
||
<div class="toolbar">
|
||
<button class="btn" id="fill-btn">autofill</button>
|
||
<button class="btn" id="edit-btn">edit</button>
|
||
<button class="btn danger" id="del-btn">trash</button>
|
||
<button class="btn" id="back-btn">back</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
let revealed = false;
|
||
document.getElementById('reveal-pass')?.addEventListener('click', () => {
|
||
revealed = !revealed;
|
||
const d = document.getElementById('d-pass')!;
|
||
d.textContent = revealed ? (password ?? '') : '••••••';
|
||
(document.getElementById('reveal-pass') as HTMLButtonElement).textContent = revealed ? 'hide' : 'show';
|
||
});
|
||
document.getElementById('copy-user')?.addEventListener('click', () => navigator.clipboard.writeText(username ?? ''));
|
||
document.getElementById('copy-pass')?.addEventListener('click', () => navigator.clipboard.writeText(password ?? ''));
|
||
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
||
document.getElementById('edit-btn')?.addEventListener('click', () => navigate('edit'));
|
||
document.getElementById('del-btn')?.addEventListener('click', async () => {
|
||
if (!confirm(`Move "${item.title}" to trash?`)) return;
|
||
await sendMessage({ type: 'delete_item', id: item.id });
|
||
const res = await sendMessage({ type: 'list_items' });
|
||
if (res.ok) setState({ entries: (res.data as any).items, selectedItem: null, selectedId: null });
|
||
navigate('list');
|
||
});
|
||
document.getElementById('fill-btn')?.addEventListener('click', async () => {
|
||
const { capturedTabId, capturedUrl } = getState();
|
||
if (capturedTabId === null) return;
|
||
await sendMessage({ type: 'fill_credentials', id: item.id, capturedTabId, capturedUrl });
|
||
window.close();
|
||
});
|
||
|
||
if (totp) {
|
||
const tick = async (): Promise<void> => {
|
||
const r = await sendMessage({ type: 'get_totp', id: item.id });
|
||
if (!r.ok) return;
|
||
const { code, expires_at } = r.data as { code: string; expires_at: number };
|
||
const codeSpan = document.getElementById('d-totp');
|
||
const expSpan = document.getElementById('d-totp-exp');
|
||
if (codeSpan) codeSpan.textContent = code;
|
||
if (expSpan) expSpan.textContent = `(${Math.max(0, expires_at - Math.floor(Date.now() / 1000))}s)`;
|
||
};
|
||
tick();
|
||
const id = setInterval(tick, 1000);
|
||
window.addEventListener('hashchange', () => clearInterval(id));
|
||
}
|
||
void base32Encode; // silence unused — used in item-form.ts
|
||
}
|
||
|
||
function renderComingSoon(app: HTMLElement, item: Item): void {
|
||
app.innerHTML = `
|
||
<div class="pad">
|
||
<div class="detail-title">${escapeHtml(item.title)}</div>
|
||
<p class="muted">The <strong>${escapeHtml(item.type)}</strong> item type is coming in Plan 1C-β.</p>
|
||
<p class="muted">Use the CLI for now: <code>relicario get ${escapeHtml(item.id)} --show</code></p>
|
||
<button class="btn" id="back-btn">back</button>
|
||
</div>
|
||
`;
|
||
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add extension/src/popup/components/item-detail.ts
|
||
git commit -m "feat(ext/popup): Login detail view + coming-soon for other types"
|
||
```
|
||
|
||
### Task 24: Rewrite `item-form.ts` — Login add/edit
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/popup/components/item-form.ts`
|
||
|
||
- [ ] **Step 1: Replace the file**
|
||
|
||
```ts
|
||
import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup';
|
||
import type { Item, ItemType } from '../../shared/types';
|
||
import { DEFAULT_PASSWORD_REQUEST } from '../../shared/types';
|
||
import { base32Decode, base32Encode } from '../../shared/base32';
|
||
|
||
export async function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): Promise<void> {
|
||
const state = getState();
|
||
const item = mode === 'edit' ? state.selectedItem : null;
|
||
const type: ItemType = item?.type ?? 'login';
|
||
|
||
if (type !== 'login') {
|
||
app.innerHTML = `
|
||
<div class="pad">
|
||
<div class="detail-title">${mode === 'add' ? 'new item' : 'edit item'}</div>
|
||
<p class="muted">Editing <strong>${escapeHtml(type)}</strong> items is coming in 1C-β.</p>
|
||
<button class="btn" id="back-btn">back</button>
|
||
</div>
|
||
`;
|
||
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
|
||
return;
|
||
}
|
||
|
||
const existing = item && item.core.type === 'login' ? item.core : null;
|
||
const totpB32 = existing?.totp ? base32Encode(new Uint8Array(existing.totp.secret)) : '';
|
||
|
||
app.innerHTML = `
|
||
<div class="pad">
|
||
<div class="detail-title">${mode === 'add' ? 'new login' : 'edit login'}</div>
|
||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||
<div class="form-group">
|
||
<label for="f-title">title *</label>
|
||
<input id="f-title" class="input" value="${escapeHtml(item?.title ?? '')}" placeholder="GitHub" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="f-url">url</label>
|
||
<input id="f-url" class="input" value="${escapeHtml(existing?.url ?? '')}" placeholder="https://github.com/login" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="f-user">username</label>
|
||
<input id="f-user" class="input" value="${escapeHtml(existing?.username ?? '')}" placeholder="alice@example.com" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="f-pass">password</label>
|
||
<div class="inline-row">
|
||
<input id="f-pass" class="input" type="password" value="${escapeHtml(existing?.password ?? '')}" />
|
||
<button class="btn" id="gen-btn" title="generate">gen</button>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="f-totp">totp secret (base32)</label>
|
||
<input id="f-totp" class="input" value="${escapeHtml(totpB32)}" placeholder="JBSWY3DPEHPK3PXP" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="f-group">group</label>
|
||
<input id="f-group" class="input" value="${escapeHtml(item?.group ?? '')}" placeholder="work" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="f-notes">notes</label>
|
||
<textarea id="f-notes" class="input">${escapeHtml(item?.notes ?? '')}</textarea>
|
||
</div>
|
||
<div class="toolbar">
|
||
<button class="btn primary" id="save-btn">save</button>
|
||
<button class="btn" id="cancel-btn">cancel</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.getElementById('gen-btn')?.addEventListener('click', async () => {
|
||
const resp = await sendMessage({ type: 'generate_password', request: DEFAULT_PASSWORD_REQUEST });
|
||
if (resp.ok) {
|
||
const pw = (resp.data as { password: string }).password;
|
||
(document.getElementById('f-pass') as HTMLInputElement).value = pw;
|
||
}
|
||
});
|
||
|
||
document.getElementById('cancel-btn')?.addEventListener('click', () => {
|
||
setState({ error: null });
|
||
navigate(mode === 'edit' ? 'detail' : 'list');
|
||
});
|
||
|
||
document.getElementById('save-btn')?.addEventListener('click', async () => {
|
||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||
if (!title) { setState({ error: 'title is required' }); return; }
|
||
const url = (document.getElementById('f-url') as HTMLInputElement).value.trim() || undefined;
|
||
const username = (document.getElementById('f-user') as HTMLInputElement).value.trim() || undefined;
|
||
const password = (document.getElementById('f-pass') as HTMLInputElement).value || undefined;
|
||
const totpStr = (document.getElementById('f-totp') as HTMLInputElement).value.trim();
|
||
const group = (document.getElementById('f-group') as HTMLInputElement).value.trim() || undefined;
|
||
const notes = (document.getElementById('f-notes') as HTMLTextAreaElement).value.trim() || undefined;
|
||
|
||
let totpConfig: import('../../shared/types').TotpConfig | undefined;
|
||
if (totpStr) {
|
||
try {
|
||
const secretBytes = base32Decode(totpStr);
|
||
totpConfig = {
|
||
secret: Array.from(secretBytes),
|
||
algorithm: 'sha1',
|
||
digits: 6,
|
||
period_seconds: 30,
|
||
kind: 'totp',
|
||
};
|
||
} catch (e) { setState({ error: `invalid TOTP secret: ${(e as Error).message}` }); return; }
|
||
}
|
||
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const payload: Item = {
|
||
id: item?.id ?? '',
|
||
title,
|
||
type: 'login',
|
||
tags: item?.tags ?? [],
|
||
favorite: item?.favorite ?? false,
|
||
group,
|
||
notes,
|
||
created: item?.created ?? now,
|
||
modified: now,
|
||
core: { type: 'login', username, password, url, totp: totpConfig },
|
||
sections: item?.sections ?? [],
|
||
attachments: item?.attachments ?? [],
|
||
field_history: item?.field_history ?? {},
|
||
};
|
||
|
||
const res = mode === 'edit' && item
|
||
? await sendMessage({ type: 'update_item', id: item.id, item: payload })
|
||
: await sendMessage({ type: 'add_item', item: payload });
|
||
|
||
if (!res.ok) { setState({ error: res.error }); return; }
|
||
|
||
const listRes = await sendMessage({ type: 'list_items' });
|
||
if (listRes.ok) {
|
||
setState({
|
||
entries: (listRes.data as any).items,
|
||
error: null,
|
||
});
|
||
}
|
||
navigate('list');
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Rebuild and commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario/extension
|
||
bun run build 2>&1 | tail -10
|
||
cd ..
|
||
git add extension/src/popup/components/item-form.ts
|
||
git commit -m "feat(ext/popup): Login add/edit form on typed-item API"
|
||
```
|
||
|
||
### Task 25: Setup wizard zxcvbn meter
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/setup/setup.ts`
|
||
|
||
- [ ] **Step 1: Locate the passphrase input in `setup.ts`**
|
||
|
||
Read `extension/src/setup/setup.ts` to find where the passphrase `<input>` is rendered and where the submit button is wired.
|
||
|
||
- [ ] **Step 2: Wire the strength meter**
|
||
|
||
Add next to the passphrase input (in the HTML template):
|
||
|
||
```html
|
||
<div class="form-group">
|
||
<label for="passphrase">passphrase</label>
|
||
<input id="passphrase" class="input" type="password" />
|
||
<div class="strength-row">
|
||
<div class="strength-bar" id="strength-bar"></div>
|
||
<span class="strength-label" id="strength-label">—</span>
|
||
</div>
|
||
<p class="muted small" id="strength-feedback"></p>
|
||
</div>
|
||
```
|
||
|
||
Add CSS for the bar (5 segments, color-coded):
|
||
|
||
```css
|
||
.strength-bar { display:flex;gap:2px;height:6px;width:100%;margin-top:4px; }
|
||
.strength-bar > span { flex:1;background:#30363d;border-radius:1px; }
|
||
.strength-bar.s0 > span:nth-child(1) { background:#f85149; }
|
||
.strength-bar.s1 > span:nth-child(-n+2) { background:#f0883e; }
|
||
.strength-bar.s2 > span:nth-child(-n+3) { background:#d29922; }
|
||
.strength-bar.s3 > span:nth-child(-n+4) { background:#3fb950; }
|
||
.strength-bar.s4 > span { background:#3fb950; }
|
||
.strength-row { display:flex;align-items:center;gap:8px; }
|
||
```
|
||
|
||
Add the meter rendering to the setup wizard (5 segments as child spans):
|
||
|
||
```ts
|
||
function renderStrengthBar(score: number): string {
|
||
return `<div class="strength-bar s${score}" id="strength-bar">
|
||
<span></span><span></span><span></span><span></span><span></span>
|
||
</div>`;
|
||
}
|
||
```
|
||
|
||
Add the input-change handler:
|
||
|
||
```ts
|
||
let rateDebounce: ReturnType<typeof setTimeout> | null = null;
|
||
const pwInput = document.getElementById('passphrase') as HTMLInputElement;
|
||
pwInput.addEventListener('input', () => {
|
||
if (rateDebounce) clearTimeout(rateDebounce);
|
||
rateDebounce = setTimeout(async () => {
|
||
const pw = pwInput.value;
|
||
if (!pw) { setMeter(0, '—', 'type a passphrase'); return; }
|
||
const resp = await new Promise<Response>((resolve) =>
|
||
chrome.runtime.sendMessage({ type: 'rate_passphrase', passphrase: pw }, resolve),
|
||
);
|
||
if (resp.ok) {
|
||
const { score } = resp.data as { score: number };
|
||
const feedback = score >= 3
|
||
? 'Strong enough.'
|
||
: 'Too weak — try a longer phrase or add unpredictability.';
|
||
setMeter(score, `score ${score}/4`, feedback);
|
||
(document.getElementById('submit-btn') as HTMLButtonElement).disabled = score < 3;
|
||
}
|
||
}, 150);
|
||
});
|
||
|
||
function setMeter(score: number, label: string, feedback: string): void {
|
||
const bar = document.getElementById('strength-bar');
|
||
if (bar) bar.className = `strength-bar s${score}`;
|
||
const lab = document.getElementById('strength-label');
|
||
if (lab) lab.textContent = label;
|
||
const fb = document.getElementById('strength-feedback');
|
||
if (fb) fb.textContent = feedback;
|
||
}
|
||
```
|
||
|
||
Initial submit-button state: `disabled = true` until score ≥ 3.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/setup/setup.ts
|
||
git commit -m "feat(ext/setup): zxcvbn strength meter + score>=3 gate (audit H3)"
|
||
```
|
||
|
||
### Task 26: Restore popup settings view + smoke build
|
||
|
||
**Files:**
|
||
- Modify: `extension/src/popup/components/settings.ts` (remove `@ts-nocheck` if present; align to `DeviceSettings`)
|
||
|
||
- [ ] **Step 1: Update the settings view to use DeviceSettings type**
|
||
|
||
Open `extension/src/popup/components/settings.ts` and replace references to `RelicarioSettings` with `DeviceSettings`. Keep the message types the same (`get_settings`, `update_settings`, `get_blacklist`, `remove_blacklist`).
|
||
|
||
- [ ] **Step 2: Build both bundles**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario/extension
|
||
bun run build 2>&1 | tail -10
|
||
bun run build:firefox 2>&1 | tail -10
|
||
bun run test 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: all three succeed.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git add extension/src/popup/components/settings.ts
|
||
git commit -m "refactor(ext/popup): settings view on DeviceSettings type"
|
||
```
|
||
|
||
### Task 27: Firefox manual verification
|
||
|
||
**Files:** no code changes.
|
||
|
||
- [ ] **Step 1: Build both bundles**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario/extension
|
||
bun run build:all
|
||
```
|
||
|
||
- [ ] **Step 2: Load Chrome build**
|
||
|
||
1. Open `chrome://extensions`.
|
||
2. Enable Developer mode.
|
||
3. Click "Load unpacked" → `extension/dist/`.
|
||
4. Verify extension appears in toolbar.
|
||
|
||
- [ ] **Step 3: Load Firefox build**
|
||
|
||
1. Open `about:debugging#/runtime/this-firefox`.
|
||
2. Click "Load Temporary Add-on…".
|
||
3. Select `extension/dist-firefox/manifest.json`.
|
||
4. Verify extension appears in toolbar.
|
||
|
||
- [ ] **Step 4: Walk the 11-step manual matrix on both browsers**
|
||
|
||
See spec §5.4. Take notes on any anomaly. This is a long-form checklist; don't commit anything yet — the next task gathers fixes.
|
||
|
||
---
|
||
|
||
## Slice 7 — Final acceptance
|
||
|
||
### Task 28: Run all acceptance checks
|
||
|
||
**Files:** no code changes.
|
||
|
||
- [ ] **Step 1: Rust workspace regression**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
cargo test --workspace 2>&1 | tail -10
|
||
```
|
||
|
||
Expected: all tests pass (was 151 at plan-1b-cli-wasm-complete; may be a few more or fewer if anything shifted).
|
||
|
||
- [ ] **Step 2: WASM target builds**
|
||
|
||
```bash
|
||
cargo build -p relicario-wasm --target wasm32-unknown-unknown 2>&1 | tail -10
|
||
```
|
||
|
||
Expected: clean build.
|
||
|
||
- [ ] **Step 3: Extension builds + router tests**
|
||
|
||
```bash
|
||
cd extension
|
||
bun run build:all 2>&1 | tail -10
|
||
bun run test 2>&1 | tail -30
|
||
```
|
||
|
||
Expected: all green.
|
||
|
||
- [ ] **Step 4: Lint greps**
|
||
|
||
```bash
|
||
cd /home/alee/Sources/relicario
|
||
git grep -n 'innerHTML\|insertAdjacentHTML' extension/src/content/ && echo "FAIL: content uses raw HTML" || echo "PASS"
|
||
git grep -n 'idfoto' extension/ && echo "FAIL: idfoto references remain" || echo "PASS"
|
||
```
|
||
|
||
Expected: both say `PASS`.
|
||
|
||
- [ ] **Step 5: WAR check**
|
||
|
||
```bash
|
||
cat extension/manifest.json | grep -A2 web_accessible_resources
|
||
cat extension/manifest.firefox.json | grep -A2 web_accessible_resources
|
||
```
|
||
|
||
Expected: either missing or an empty `[]`.
|
||
|
||
- [ ] **Step 6: Mark the branch complete**
|
||
|
||
```bash
|
||
git tag plan-1c-alpha-complete
|
||
```
|
||
|
||
No commit needed. The tag marks the acceptance point for executors/reviewers.
|
||
|
||
---
|
||
|
||
## Self-review (writing-plans housekeeping)
|
||
|
||
Spec coverage check:
|
||
- WASM artifact rebuild — Task 1-3
|
||
- Shared types v2 — Task 4
|
||
- Messages split — Task 5
|
||
- base32 utility — Task 6
|
||
- Session handle — Task 7
|
||
- Vault rewrite — Task 8
|
||
- Transitional SW index — Task 9
|
||
- Router split — Tasks 11-13
|
||
- Collapse index onto router — Task 14
|
||
- Vitest + router tests — Task 15
|
||
- WAR cleanup — Task 16
|
||
- Setup via chrome.tabs.create — Task 17
|
||
- Shadow DOM capture — Task 18
|
||
- Shadow DOM icon + picker + ack hint — Task 19
|
||
- Popup captured-tab — Task 20
|
||
- Popup component rename — Task 21
|
||
- Login list/detail/form — Tasks 22-24
|
||
- zxcvbn setup gate — Task 25
|
||
- Settings view cleanup — Task 26
|
||
- Firefox verification — Task 27
|
||
- Final acceptance — Task 28
|
||
|
||
Known gaps (intentional — all deferred per spec):
|
||
- Per-type forms beyond Login → 1C-β
|
||
- Sections + custom fields → 1C-β
|
||
- Full VaultSettings UI → 1C-β
|
||
- Attachments → 1C-γ
|
||
- Trash view / history view → 1C-γ
|
||
- Device management UI → 1C-γ
|