diff --git a/docs/superpowers/test-runs/2026-04-20-1c-alpha-manual-matrix.md b/docs/superpowers/test-runs/2026-04-20-1c-alpha-manual-matrix.md new file mode 100644 index 0000000..53abc91 --- /dev/null +++ b/docs/superpowers/test-runs/2026-04-20-1c-alpha-manual-matrix.md @@ -0,0 +1,244 @@ +# Plan 1C-α — Manual Test Matrix + +Walkthrough for validating the extension on both Chrome and Firefox after the six-slice implementation. + +Branch: `feature/typed-items-1c-alpha` @ `3238ef4` (tag candidate: `plan-1c-alpha-complete`) +Worktree: `/home/alee/Sources/relicario/.worktrees/typed-items-1c-alpha` + +--- + +## Pre-flight + +- [ ] **P1.** Bundles built: + ```bash + cd /home/alee/Sources/relicario/.worktrees/typed-items-1c-alpha/extension + bun run build:all + ``` + Expected: "compiled with 2 warnings" (WASM size only) for each bundle. `dist/` and `dist-firefox/` populated. + +- [ ] **P2.** Fresh-profile browsers ready (or existing profile's `chrome.storage.local` for this extension cleared). Stale `vaultConfig`/`imageBase64` from the pre-rename `idfoto` era must not persist. + +- [ ] **P3.** Test git repo for the vault is reachable (SSH key / HTTPS PAT working). Use a throwaway repo to avoid polluting your real vault history. + +- [ ] **P4.** Reference image ready (any JPEG; DCT-steg secret is embedded at init time). + +--- + +## Loading + +### Chrome +- [ ] **L1.** `chrome://extensions` → Developer mode ON → "Load unpacked" → select `extension/dist/`. +- [ ] **L2.** Toolbar icon visible (pin if needed). +- [ ] **L3.** Click icon → first open triggers setup tab (not a popup-embedded wizard). + +### Firefox +- [ ] **L4.** `about:debugging#/runtime/this-firefox` → "Load Temporary Add-on…" → select `extension/dist-firefox/manifest.json`. +- [ ] **L5.** Toolbar icon visible. +- [ ] **L6.** Click icon → setup tab opens. + +--- + +## 11-step core matrix — Chrome + +**Notes column: write what you saw. Check box only when matching expected.** + +### 1. Setup tab opens from popup (audit C1) + +- [ ] **Do:** Fresh install, click toolbar icon. +- [ ] **Expected:** `setup.html` opens in a new tab; popup closes immediately; WAR is empty so this MUST work via extension-origin tab, not WAR. +- [ ] **Notes:** ___ + +### 2. zxcvbn gate in setup (audit H3) + +- [ ] **Do:** Type weak passphrase like `password`. +- [ ] **Expected:** Submit disabled, bar shows red/orange segments, feedback "Too weak…". +- [ ] **Do:** Type stronger phrase until bar fills. +- [ ] **Expected:** At score ≥ 3, submit enables, feedback "Strong enough." +- [ ] **Notes:** ___ + +### 3. Setup completes → unlock → list renders + +- [ ] **Do:** Upload reference JPEG, fill vault config (git host/URL/repo/token), submit. Then open popup, enter passphrase, unlock. +- [ ] **Expected:** Manifest decrypts client-side. Empty list view appears with toolbar (search, + New, sync, lock, ⚙). +- [ ] **Notes:** ___ + +### 4. Add Login with TOTP (typed-item wire format) + +- [ ] **Do:** "+ New" → Login form. Fill: + - title: `GitHub` + - url: `https://github.com` + - username: your handle + - password: click "gen" (uses `DEFAULT_PASSWORD_REQUEST` — 20 chars, safe symbols) + - totp: `JBSWY3DPEHPK3PXP` (well-known base32 test vector) + - Save. +- [ ] **Expected:** Row appears with 🔑 icon + title + favorite star position. +- [ ] **Expected (CLI cross-check, optional):** From main worktree: + ```bash + relicario list + relicario get "GitHub" --show + ``` + Should show the same item. TOTP secret should decode identically. +- [ ] **Notes:** ___ + +### 5. TOFU origin-ack prompt (audit C4 first half) + +- [ ] **Do:** Navigate to `https://github.com/login`. Click the blue `id` icon next to the password field. +- [ ] **Expected:** Closed Shadow DOM hint appears ("First autofill on github.com / Open relicario to confirm"). In DevTools, verify `document.querySelector('[data-rel]')` finds the host but `.shadowRoot` is `null` (closed mode). +- [ ] **Expected:** No credentials fill on this click. +- [ ] **Notes:** ___ + +### 6. Confirm origin + autofill fills correctly + +- [ ] **Do:** Open popup (on the github.com tab). Look for a pending-ack prompt OR (α behavior) just confirm manually: any `get_credentials` call after the hostname is acked in `VaultSettings.autofill_origin_acks` will return credentials. + - Simplest α path: click the item in the popup list, click "autofill" button. This uses the popup-captured tab state path (audit M5). +- [ ] **Expected:** Username + password fields fill. On React/Vue sites, the native-setter trick fires input+change events. +- [ ] **Notes:** ___ + +### 7. Multiple candidates → picker + +- [ ] **Do:** Add a second Login for github.com with a different username. Back on `github.com/login`, click the `id` icon. +- [ ] **Expected:** Picker shows both titles. Click one → fills that set. +- [ ] **Notes:** ___ + +### 8. Capture prompt → `capture_save_login` flow (Slice 5 critical-fix) + +- [ ] **Do:** Go to a site not in your vault. Fill signup form (or real trial). Submit. +- [ ] **Expected:** Capture prompt appears inside closed Shadow DOM. No stable element IDs — running `document.querySelector('#relicario-save-btn')` in the page console returns `null`. +- [ ] **Do:** Click "Save" in the prompt. +- [ ] **Expected:** ✓ Saved confirmation; prompt dismisses. Open popup → item present in list with the new hostname as title. +- [ ] **CRITICAL:** If "Save" silently fails, the `capture_save_login` content-callable handler is broken — file a bug before proceeding. +- [ ] **Notes:** ___ + +### 9. Edit Login → password rotates; field history captured + +- [ ] **Do:** Select the GitHub item → edit → change password → save. +- [ ] **Expected:** Detail view shows new password on reveal. List's "modified" time updates. +- [ ] **Expected (CLI cross-check):** + ```bash + relicario get "GitHub" --show + # confirm field_history now has entry for the old password + ``` +- [ ] **Notes:** ___ + +### 10. Delete Login → soft-delete + +- [ ] **Do:** Select an item → "trash" → confirm. +- [ ] **Expected:** Row disappears from list immediately. Popup list filters `trashed_at !== undefined`. +- [ ] **Expected (CLI cross-check):** `relicario list --trashed` shows the item. +- [ ] **Notes:** ___ + +### 11. Lock → re-unlock + +- [ ] **Do:** Click "lock" in the toolbar. Try to open the popup again. +- [ ] **Expected:** Unlock screen. Session handle was cleared in WASM (not just JS). +- [ ] **Do:** Re-unlock. +- [ ] **Expected:** Same list (including the item from step 10 still in trash, invisible). +- [ ] **Notes:** ___ + +--- + +## 11-step core matrix — Firefox + +Re-run 1–11 on Firefox. Critical Firefox-only check: the background script runs as a **persistent script** (not MV3 service worker); WASM loads via `initDefault(wasmUrl)` not `initSync`. Anything broken here that works in Chrome indicates WASM-loading drift. + +- [ ] **FF1–FF11.** Re-run the 11 steps above. Summarize anomalies: +- **Notes:** ___ + +--- + +## Security probes (bonus) + +Open DevTools on any page (not extension origin) and try to defeat the router: + +### SP1. Content-script-originated popup-only message + +- [ ] **Do:** In a page console (not popup DevTools): + ```js + chrome.runtime.sendMessage({ type: 'unlock', passphrase: 'guess' }, console.log) + ``` +- [ ] **Expected:** `{ ok: false, error: 'unauthorized_sender' }` (audit C2). +- [ ] **Notes:** ___ + +### SP2. Cross-origin `get_credentials` attempt + +- [ ] **Do:** Pick an item id from the popup (e.g., via popup DevTools: `copy(currentState.selectedId)`). Go to a **different-origin** page's console: + ```js + chrome.runtime.sendMessage({ type: 'get_credentials', id: '' }, console.log) + ``` +- [ ] **Expected:** `{ ok: false, error: 'origin_mismatch' }` (audit C4). No item data leaks. +- [ ] **Notes:** ___ + +### SP3. Closed Shadow DOM verification + +- [ ] **Do:** Trigger the capture prompt (step 8). In the page console: + ```js + const hosts = document.querySelectorAll('[data-rel]'); + for (const h of hosts) console.log(h, h.shadowRoot); // shadowRoot should be null + console.log(document.querySelector('#relicario-save-btn')); // should be null + console.log(document.querySelector('.relicario-capture')); // should be null + ``` +- [ ] **Expected:** All `shadowRoot` values are `null`; no stable selectors match (audit C3). +- [ ] **Notes:** ___ + +### SP4. Captured-tab navigation during fill (audit M5) + +- [ ] **Do:** Open popup on `https://github.com/login`. Select a github item, click "autofill", but BEFORE the fill lands, rapidly navigate the github tab to `https://example.com`. +- [ ] **Expected:** No credentials typed on example.com. SW rejects with `tab_navigated`; if somehow the message reaches the content script, `fill.ts` re-checks `expectedHost` and rejects with `origin_changed`. +- [ ] **Notes:** ___ (this one's hard to time; skip if not easily reproducible) + +### SP5. WAR probe + +- [ ] **Do:** In a page console on any site: + ```js + fetch('chrome-extension:///setup.html').catch(e => console.log('blocked:', e)) + ``` +- [ ] **Expected:** Blocked (either CORS error or net::ERR). WAR is empty, so no resource is web-accessible. `` pages cannot reach setup.html. +- [ ] **Notes:** ___ + +--- + +## Final acceptance + +- [ ] **A1.** `cargo test --workspace` green (should still be 151+ Rust tests). +- [ ] **A2.** `cd extension && bun run test` green (should be 52 passing — 11 base32 + 41 router). +- [ ] **A3.** `cd extension && bun run build` green (Chrome bundle). +- [ ] **A4.** `cd extension && bun run build:firefox` green (Firefox bundle). +- [ ] **A5.** Lint greps clean: + ```bash + git grep -n 'innerHTML\|insertAdjacentHTML' extension/src/content/ # zero hits + git grep -n 'idfoto' extension/ # zero hits + git grep -n '@ts-nocheck' extension/src/ # zero hits + ``` +- [ ] **A6.** WAR empty: + ```bash + grep -A2 web_accessible_resources extension/manifest.json # [] + grep -A2 web_accessible_resources extension/manifest.firefox.json # [] + ``` + +--- + +## Sign-off + +- [ ] **All 11 core-matrix steps pass on Chrome** +- [ ] **All 11 core-matrix steps pass on Firefox** +- [ ] **All 5 security probes pass (or SP4 skipped, others pass)** +- [ ] **All 6 final acceptance checks pass** +- [ ] **Ready to tag `plan-1c-alpha-complete` and decide on merge path** + +### Findings / issues + +Use this space to log anything weird: + +``` +(fill in as you go) +``` + +### Decision + +- [ ] Merge straight to `main` +- [ ] Open a PR first for review +- [ ] Need rework on: ___ + +--- + +*Generated 2026-04-20 — source: spec `2026-04-20-relicario-extension-1c-alpha-design.md` §5.4, plan `2026-04-20-relicario-extension-1c-alpha.md` Task 27.* diff --git a/extension/src/service-worker/index.ts b/extension/src/service-worker/index.ts index f21e7c4..64ec2e8 100644 --- a/extension/src/service-worker/index.ts +++ b/extension/src/service-worker/index.ts @@ -46,11 +46,25 @@ const state: RouterState = { chrome.runtime.onMessage.addListener( (request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => { (async () => { - if (!state.wasm) state.wasm = await initWasm(); + if (!state.wasm) { + // eslint-disable-next-line no-console + console.log('[relicario sw] initializing WASM on first message'); + state.wasm = await initWasm(); + } return route(request, state, sender); })() - .then(sendResponse) - .catch((err: Error) => sendResponse({ ok: false, error: err.message })); + .then((r) => { + if (!r.ok) { + // eslint-disable-next-line no-console + console.warn(`[relicario sw] ${request.type} -> error:`, r.error); + } + sendResponse(r); + }) + .catch((err: Error) => { + // eslint-disable-next-line no-console + console.error(`[relicario sw] ${request.type} threw:`, err); + sendResponse({ ok: false, error: err.message }); + }); return true; // async response }, ); diff --git a/extension/src/service-worker/router/__tests__/router.test.ts b/extension/src/service-worker/router/__tests__/router.test.ts index 388b2da..79cdb5a 100644 --- a/extension/src/service-worker/router/__tests__/router.test.ts +++ b/extension/src/service-worker/router/__tests__/router.test.ts @@ -309,10 +309,32 @@ describe('fill_credentials captured-tab verification', () => { }); }); -// --- save_setup exception scope: setup tab is ONLY allowed save_setup --- +// --- setup-tab exception scope --- +// +// Setup is allowed a narrow subset of popup-only messages: +// - save_setup (final wire-up) +// - rate_passphrase (zxcvbn meter during passphrase entry) +// - is_unlocked (step-4 extension detection) +// Everything else popup-only must be rejected from setup. -describe('save_setup exception scope', () => { - it('rejects fill_credentials from the setup tab (setup can only save_setup)', async () => { +describe('setup tab exception scope', () => { + it('accepts rate_passphrase from the setup tab (zxcvbn meter)', async () => { + const state = makeState(); + const res = await route( + { type: 'rate_passphrase', passphrase: 'correct horse battery staple parapet' }, + state, + makeSetupSender(), + ); + expect(res).toMatchObject({ ok: true }); + }); + + it('accepts is_unlocked from the setup tab (step-4 detection)', async () => { + const state = makeState(); + const res = await route({ type: 'is_unlocked' }, state, makeSetupSender()); + expect(res).toMatchObject({ ok: true }); + }); + + it('rejects fill_credentials from the setup tab (outside the allowlist)', async () => { const state = makeState(); const res = await route( { @@ -326,6 +348,16 @@ describe('save_setup exception scope', () => { ); expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); }); + + it('rejects unlock from the setup tab (outside the allowlist)', async () => { + const state = makeState(); + const res = await route( + { type: 'unlock', passphrase: 'hunter2' }, + state, + makeSetupSender(), + ); + expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); }); // --- isContent rejects unknown sender.id --- diff --git a/extension/src/service-worker/router/index.ts b/extension/src/service-worker/router/index.ts index 65229c3..50cbd39 100644 --- a/extension/src/service-worker/router/index.ts +++ b/extension/src/service-worker/router/index.ts @@ -2,7 +2,7 @@ /// 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 type { PopupMessage, 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'; @@ -16,6 +16,16 @@ export interface RouterState { wasm: any; } +/// Popup-only messages the setup tab is also allowed to send. +/// - save_setup: wires vault config + image into chrome.storage.local at end of init. +/// - rate_passphrase: drives the zxcvbn strength meter during passphrase entry. +/// - is_unlocked: setup step-4 pings the extension to detect "save config to extension" availability. +const SETUP_ALLOWED: ReadonlySet = new Set([ + 'save_setup', + 'rate_passphrase', + 'is_unlocked', +]); + export async function route( msg: Request, state: RouterState, @@ -32,17 +42,29 @@ export async function route( && 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))) { + if (!(isPopup || (isSetup && SETUP_ALLOWED.has(msg.type as PopupMessage['type'])))) { + // eslint-disable-next-line no-console + console.warn('[relicario router] rejected popup-only message from wrong sender', { + type: msg.type, senderUrl, isPopup, isSetup, isContent, + }); 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' }; + if (!isContent) { + // eslint-disable-next-line no-console + console.warn('[relicario router] rejected content-only message from wrong sender', { + type: msg.type, senderUrl, isPopup, isSetup, isContent, + frameId: sender.frameId, senderId: sender.id, + }); + return { ok: false, error: 'unauthorized_sender' }; + } return contentCallable.handle(msg as never, state, sender); } + // eslint-disable-next-line no-console + console.warn('[relicario router] unknown message type', { type: (msg as { type: string }).type }); return { ok: false, error: 'unknown_message_type' }; } diff --git a/extension/src/setup/setup.ts b/extension/src/setup/setup.ts index 75a9f9b..3e901c9 100644 --- a/extension/src/setup/setup.ts +++ b/extension/src/setup/setup.ts @@ -81,11 +81,22 @@ function ratePassphrase(passphrase: string): Promise { chrome.runtime.sendMessage( { type: 'rate_passphrase', passphrase }, (response: { ok: boolean; data?: { score: number; guesses_log10: number }; error?: string }) => { - if (chrome.runtime.lastError || !response?.ok) { resolve(-1); return; } + if (chrome.runtime.lastError) { + // eslint-disable-next-line no-console + console.warn('[relicario setup] rate_passphrase lastError:', chrome.runtime.lastError); + resolve(-1); return; + } + if (!response?.ok) { + // eslint-disable-next-line no-console + console.warn('[relicario setup] rate_passphrase rejected by SW:', response); + resolve(-1); return; + } resolve(response.data?.score ?? -1); }, ); - } catch { + } catch (err) { + // eslint-disable-next-line no-console + console.warn('[relicario setup] rate_passphrase threw:', err); resolve(-1); } }); @@ -428,66 +439,85 @@ function attachStep3(): void { state.error = null; render(); + // Structured logging so silent failures become visible in DevTools. + // eslint-disable-next-line no-console + const log = (stage: string, detail?: unknown) => console.log(`[relicario setup] ${stage}`, detail ?? ''); + + let stage = 'init'; try { + stage = 'load wasm'; + log(stage); const w = await loadWasm(); - // 1. Generate 32-byte image secret. + stage = 'generate image secret'; + log(stage); const imageSecret = new Uint8Array(32); crypto.getRandomValues(imageSecret); - // 2. Embed secret into carrier JPEG. + stage = 'embed image secret'; + log(stage, { carrierBytes: state.carrierImageBytes.byteLength }); state.referenceImageBytes = new Uint8Array( w.embed_image_secret(state.carrierImageBytes, imageSecret), ); + log('embedded', { referenceBytes: state.referenceImageBytes.byteLength }); - // 3. Generate 32-byte salt + KDF params. + stage = 'generate salt'; const salt = new Uint8Array(32); crypto.getRandomValues(salt); const paramsJson = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}'; - // 4. Derive a session handle via the typed-item unlock API. - // (Single-shot master_key derivation is no longer exposed; the - // handle is the only in-JS reference to the master key.) - const handle = w.unlock(state.passphrase, imageSecret, salt, paramsJson); + stage = 'derive session handle'; + log(stage); + // unlock() takes JPEG bytes with embedded secret (it extracts internally), + // not the raw 32-byte secret. + const handle = w.unlock(state.passphrase, state.referenceImageBytes, salt, paramsJson); + log('handle acquired'); - // 5. Encrypt an empty schema-v2 manifest. + stage = 'encrypt empty manifest'; + log(stage); const manifestJson = '{"schema_version":2,"items":{}}'; const encryptedManifest = w.manifest_encrypt(handle, manifestJson); + log('manifest encrypted', { bytes: encryptedManifest.length }); - // 6. Push vault files via git API. + stage = 'push vault files'; + log(stage); const hostUrl = state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl; const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken); + log('write .relicario/salt'); await host.writeFile('.relicario/salt', salt, 'init: vault salt'); + log('write .relicario/params.json'); const paramsBytes = new TextEncoder().encode(paramsJson); await host.writeFile('.relicario/params.json', paramsBytes, 'init: KDF parameters'); + log('write .relicario/devices.json'); const devicesJson = '{"devices":[]}'; const devicesBytes = new TextEncoder().encode(devicesJson); await host.writeFile('.relicario/devices.json', devicesBytes, 'init: device registry'); + log('write manifest.enc'); await host.writeFile( 'manifest.enc', new Uint8Array(encryptedManifest), 'init: encrypted manifest', ); - // 7. Release the handle — the SW's own unlock will re-derive. + stage = 'release handle'; w.lock(handle); - // 8. Advance to step 4. + log('vault created — advancing to step 4'); state.creating = false; state.step = 4; state.error = null; - - // Detect extension. detectExtension(); - render(); } catch (err: unknown) { + // eslint-disable-next-line no-console + console.error(`[relicario setup] vault creation FAILED during "${stage}":`, err); state.creating = false; - state.error = `Vault creation failed: ${err instanceof Error ? err.message : String(err)}`; + const detail = err instanceof Error ? err.message : String(err); + state.error = `Vault creation failed at "${stage}": ${detail}`; render(); } });