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/bun.lock b/extension/bun.lock index 417507f..1e003a4 100644 --- a/extension/bun.lock +++ b/extension/bun.lock @@ -3,12 +3,14 @@ "configVersion": 1, "workspaces": { "": { - "name": "idfoto-extension", + "name": "relicario-extension", "devDependencies": { "@types/chrome": "^0.1.40", "copy-webpack-plugin": "^12.0", + "happy-dom": "^15", "ts-loader": "^9.5", "typescript": "^5.4", + "vitest": "^2.0", "webpack": "^5.90", "webpack-cli": "^5.1", }, @@ -17,6 +19,52 @@ "packages": { "@discoveryjs/json-ext": ["@discoveryjs/json-ext@0.5.7", "", {}, "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], @@ -33,6 +81,56 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.2", "", { "os": "android", "cpu": "arm" }, "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.2", "", { "os": "android", "cpu": "arm64" }, "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.2", "", { "os": "linux", "cpu": "x64" }, "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.2", "", { "os": "linux", "cpu": "x64" }, "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.2", "", { "os": "none", "cpu": "arm64" }, "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA=="], + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@2.3.0", "", {}, "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg=="], "@types/chrome": ["@types/chrome@0.1.40", "", { "dependencies": { "@types/filesystem": "*", "@types/har-format": "*" } }, "sha512-UnfyRAe8ORu9HSuTH0EqyOEUin3JrWW9Nl/gDXezNfTUrfIoxw+WRZgKOxGz0t5BnjbfXBnS2eCYfW2PxH1wcA=="], @@ -53,6 +151,20 @@ "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="], + + "@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="], + + "@vitest/runner": ["@vitest/runner@2.1.9", "", { "dependencies": { "@vitest/utils": "2.1.9", "pathe": "^1.1.2" } }, "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g=="], + + "@vitest/snapshot": ["@vitest/snapshot@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "magic-string": "^0.30.12", "pathe": "^1.1.2" } }, "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ=="], + + "@vitest/spy": ["@vitest/spy@2.1.9", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ=="], + + "@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="], + "@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="], "@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="], @@ -105,6 +217,8 @@ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -113,10 +227,16 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="], + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + "chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="], "clone-deep": ["clone-deep@4.0.1", "", { "dependencies": { "is-plain-object": "^2.0.4", "kind-of": "^6.0.2", "shallow-clone": "^3.0.0" } }, "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ=="], @@ -133,16 +253,24 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + "electron-to-chromium": ["electron-to-chromium@1.5.335", "", {}, "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q=="], "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "envinfo": ["envinfo@7.21.0", "", { "bin": { "envinfo": "dist/cli.js" } }, "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], @@ -151,8 +279,12 @@ "estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], @@ -169,6 +301,8 @@ "flat": ["flat@5.0.2", "", { "bin": { "flat": "cli.js" } }, "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -179,6 +313,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "happy-dom": ["happy-dom@15.11.7", "", { "dependencies": { "entities": "^4.5.0", "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" } }, "sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], @@ -215,6 +351,10 @@ "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], @@ -225,6 +365,10 @@ "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], @@ -245,12 +389,18 @@ "path-type": ["path-type@6.0.0", "", {}, "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ=="], + "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="], + "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], @@ -267,6 +417,8 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rollup": ["rollup@4.60.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.2", "@rollup/rollup-android-arm64": "4.60.2", "@rollup/rollup-darwin-arm64": "4.60.2", "@rollup/rollup-darwin-x64": "4.60.2", "@rollup/rollup-freebsd-arm64": "4.60.2", "@rollup/rollup-freebsd-x64": "4.60.2", "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", "@rollup/rollup-linux-arm-musleabihf": "4.60.2", "@rollup/rollup-linux-arm64-gnu": "4.60.2", "@rollup/rollup-linux-arm64-musl": "4.60.2", "@rollup/rollup-linux-loong64-gnu": "4.60.2", "@rollup/rollup-linux-loong64-musl": "4.60.2", "@rollup/rollup-linux-ppc64-gnu": "4.60.2", "@rollup/rollup-linux-ppc64-musl": "4.60.2", "@rollup/rollup-linux-riscv64-gnu": "4.60.2", "@rollup/rollup-linux-riscv64-musl": "4.60.2", "@rollup/rollup-linux-s390x-gnu": "4.60.2", "@rollup/rollup-linux-x64-gnu": "4.60.2", "@rollup/rollup-linux-x64-musl": "4.60.2", "@rollup/rollup-openbsd-x64": "4.60.2", "@rollup/rollup-openharmony-arm64": "4.60.2", "@rollup/rollup-win32-arm64-msvc": "4.60.2", "@rollup/rollup-win32-ia32-msvc": "4.60.2", "@rollup/rollup-win32-x64-gnu": "4.60.2", "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -283,12 +435,20 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -299,6 +459,16 @@ "terser-webpack-plugin": ["terser-webpack-plugin@5.4.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="], + + "tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "ts-loader": ["ts-loader@9.5.7", "", { "dependencies": { "chalk": "^4.1.0", "enhanced-resolve": "^5.0.0", "micromatch": "^4.0.0", "semver": "^7.3.4", "source-map": "^0.7.4" }, "peerDependencies": { "typescript": "*", "webpack": "^5.0.0" } }, "sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg=="], @@ -311,8 +481,16 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], + + "vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="], + + "vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="], + "watchpack": ["watchpack@2.5.1", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg=="], + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + "webpack": ["webpack@5.106.1", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.16.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.20.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.17", "watchpack": "^2.5.1", "webpack-sources": "^3.3.4" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-EW8af29ak8Oaf4T8k8YsajjrDBDYgnKZ5er6ljWFJsXABfTNowQfvHLftwcepVgdz+IoLSdEAbBiM9DFXoll9w=="], "webpack-cli": ["webpack-cli@5.1.4", "", { "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", "@webpack-cli/info": "^2.0.2", "@webpack-cli/serve": "^2.0.5", "colorette": "^2.0.14", "commander": "^10.0.1", "cross-spawn": "^7.0.3", "envinfo": "^7.7.3", "fastest-levenshtein": "^1.0.12", "import-local": "^3.0.2", "interpret": "^3.1.1", "rechoir": "^0.8.0", "webpack-merge": "^5.7.3" }, "peerDependencies": { "webpack": "5.x.x" }, "bin": { "webpack-cli": "bin/cli.js" } }, "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg=="], @@ -321,8 +499,12 @@ "webpack-sources": ["webpack-sources@3.3.4", "", {}, "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "wildcard": ["wildcard@2.0.1", "", {}, "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ=="], "esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], @@ -334,5 +516,7 @@ "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "vite-node/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], } } diff --git a/extension/icons/icon-128.png b/extension/icons/icon-128.png index 57780a4..105f74e 100644 Binary files a/extension/icons/icon-128.png and b/extension/icons/icon-128.png differ diff --git a/extension/icons/icon-16.png b/extension/icons/icon-16.png index c01bac2..361bfaa 100644 Binary files a/extension/icons/icon-16.png and b/extension/icons/icon-16.png differ diff --git a/extension/icons/icon-48.png b/extension/icons/icon-48.png index f0d18c8..95b6f50 100644 Binary files a/extension/icons/icon-48.png and b/extension/icons/icon-48.png differ diff --git a/extension/icons/relicario-logo-16.svg b/extension/icons/relicario-logo-16.svg new file mode 100644 index 0000000..8fe1738 --- /dev/null +++ b/extension/icons/relicario-logo-16.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + diff --git a/extension/icons/relicario-logo.svg b/extension/icons/relicario-logo.svg index 1119423..8509adf 100644 --- a/extension/icons/relicario-logo.svg +++ b/extension/icons/relicario-logo.svg @@ -1,30 +1,38 @@ - - + - - + + + - - + + - - - - - - - - - + + + + + + + + + + + + + + diff --git a/extension/manifest.firefox.json b/extension/manifest.firefox.json index a80f427..14937a6 100644 --- a/extension/manifest.firefox.json +++ b/extension/manifest.firefox.json @@ -35,7 +35,5 @@ "content_security_policy": { "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'" }, - "web_accessible_resources": [{ - "resources": ["setup.html", "setup.js", "styles.css", "relicario_wasm_bg.wasm", "relicario_wasm.js"] - }] + "web_accessible_resources": [] } diff --git a/extension/manifest.json b/extension/manifest.json index db02853..7eb1630 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -30,8 +30,5 @@ "content_security_policy": { "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'" }, - "web_accessible_resources": [{ - "resources": ["setup.html", "setup.js", "styles.css", "relicario_wasm_bg.wasm", "relicario_wasm.js"], - "matches": [""] - }] + "web_accessible_resources": [] } diff --git a/extension/package.json b/extension/package.json index d56fe4b..c702388 100644 --- a/extension/package.json +++ b/extension/package.json @@ -8,13 +8,17 @@ "build:all": "npm run build:wasm && npm run build && npm run build:firefox", "dev": "webpack --mode development --watch", "dev:firefox": "webpack --config webpack.firefox.config.js --mode development --watch", - "build:wasm": "wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm" + "build:wasm": "wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { "@types/chrome": "^0.1.40", "copy-webpack-plugin": "^12.0", + "happy-dom": "^15", "ts-loader": "^9.5", "typescript": "^5.4", + "vitest": "^2.0", "webpack": "^5.90", "webpack-cli": "^5.1" } diff --git a/extension/setup.html b/extension/setup.html index 5a2dd7a..e7b0b08 100644 --- a/extension/setup.html +++ b/extension/setup.html @@ -49,23 +49,125 @@ } .strength-bar { - height: 4px; + display: flex; + gap: 3px; + margin-top: 8px; + } + + .strength-bar .seg { + flex: 1; + height: 5px; background: #21262d; - border-radius: 2px; - margin-top: 6px; - overflow: hidden; + border-radius: 3px; + transition: background 0.25s ease, box-shadow 0.25s ease; } - .strength-bar-fill { - height: 100%; - border-radius: 2px; - transition: width 0.2s, background 0.2s; + /* zxcvbn score-driven colors. Higher-scored bars light up earlier bars too. */ + .strength-bar.s0 .seg.i0 { background: #f85149; } + .strength-bar.s1 .seg.i0, + .strength-bar.s1 .seg.i1 { background: #f08d49; } + .strength-bar.s2 .seg.i0, + .strength-bar.s2 .seg.i1, + .strength-bar.s2 .seg.i2 { background: #d29922; } + .strength-bar.s3 .seg.i0, + .strength-bar.s3 .seg.i1, + .strength-bar.s3 .seg.i2, + .strength-bar.s3 .seg.i3 { background: #3fb950; } + .strength-bar.s4 .seg { + background: #56d364; + box-shadow: 0 0 4px rgba(86, 211, 100, 0.4); } - .strength-bar-fill.weak { background: #f85149; width: 25%; } - .strength-bar-fill.fair { background: #d29922; width: 50%; } - .strength-bar-fill.good { background: #3fb950; width: 75%; } - .strength-bar-fill.strong { background: #58a6ff; width: 100%; } + .strength-row { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-top: 5px; + } + .strength-label { + font-size: 11px; + margin: 0; + text-transform: lowercase; + letter-spacing: 0.03em; + transition: color 0.2s ease; + } + .strength-label.s-very-weak { color: #f85149; } + .strength-label.s-weak { color: #f08d49; } + .strength-label.s-fair { color: #d29922; } + .strength-label.s-good { color: #3fb950; } + .strength-label.s-strong { color: #56d364; font-weight: 600; } + + .char-counter { + font-size: 10px; + color: #6e7681; + margin: 0; + font-variant-numeric: tabular-nums; + } + + .entropy-line { + font-size: 10px; + color: #8b949e; + margin-top: 2px; + font-family: "SF Mono", "JetBrains Mono", monospace; + min-height: 1em; + } + + .pass-help { + background: #0d1117; + border: 1px solid #21262d; + border-left: 2px solid #1f6feb; + border-radius: 4px; + padding: 8px 12px; + font-size: 11px; + color: #8b949e; + line-height: 1.55; + margin: 10px 0; + } + .pass-help strong { color: #c9d1d9; } + + .passphrase-field { + position: relative; + } + .passphrase-field input { + padding-right: 76px; /* room for match indicator + eye button */ + } + .eye-btn { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + height: 24px; + padding: 0 8px; + background: transparent; + border: 1px solid #30363d; + border-radius: 3px; + color: #8b949e; + cursor: pointer; + font-size: 10px; + font-family: inherit; + text-transform: lowercase; + letter-spacing: 0.03em; + } + .eye-btn:hover { color: #c9d1d9; border-color: #484f58; } + + .match-indicator { + position: absolute; + right: 50px; + top: 50%; + transform: translateY(-50%); + font-size: 16px; + line-height: 1; + pointer-events: none; + transition: color 0.15s ease, opacity 0.15s ease; + } + .match-indicator.ok { color: #3fb950; } + .match-indicator.bad { color: #f85149; } + + /* Primary button explicitly dims when disabled so the gate is obvious. */ + .btn-primary:disabled { + opacity: 0.45; + cursor: not-allowed; + } .success-box { background: #0d1b0e; diff --git a/extension/src/content/capture.ts b/extension/src/content/capture.ts index 5c36b61..3a3addc 100644 --- a/extension/src/content/capture.ts +++ b/extension/src/content/capture.ts @@ -2,14 +2,21 @@ /// /// Detects login form submissions and prompts the user to save or update /// credentials in the vault. Supports bar and toast prompt styles. +/// +/// The prompt renders inside a closed Shadow DOM so the host page cannot +/// read overlay contents via document.querySelector or rewrite them via +/// insertAdjacentHTML. All caller-supplied strings (hostname, username) +/// are applied via textContent, never innerHTML. import type { Request, Response } from '../shared/messages'; -import type { RelicarioSettings } from '../shared/types'; +import type { DeviceSettings } from '../shared/types'; +import { createShadowHost, type ShadowSurface } from './shadow'; // --- State --- const hookedForms = new WeakSet(); const hookedButtons = new WeakSet(); +let currentPrompt: ShadowSurface | null = null; // --- Messaging --- @@ -73,11 +80,10 @@ async function onFormSubmit(pwField: HTMLInputElement): Promise { if (!password) return; const username = findUsernameValue(pwField); - const url = window.location.href; + // Note: `url` is NOT sent — router derives origin from sender.tab.url. const resp = await sendMessage({ type: 'check_credential', - url, username, password, }); @@ -87,60 +93,65 @@ async function onFormSubmit(pwField: HTMLInputElement): Promise { const data = resp.data as { action: string; entryId?: string; entryName?: string }; if (data.action === 'skip') return; - // Fetch settings for prompt style - const settingsResp = await sendMessage({ type: 'get_settings' }); - const settings: RelicarioSettings = settingsResp.ok - ? (settingsResp.data as { settings: RelicarioSettings }).settings - : { captureEnabled: true, captureStyle: 'bar' }; + // Fetch settings for prompt style. Content scripts have direct + // chrome.storage.local access (manifest grants "storage"), so we don't + // need to round-trip through the SW for this — which also avoids the + // router's content→popup-only rejection for 'get_settings'. + const stored = await chrome.storage.local.get('relicarioSettings'); + const settings: DeviceSettings = (stored.relicarioSettings as DeviceSettings) + ?? { captureEnabled: true, captureStyle: 'bar' }; - showPrompt(settings.captureStyle, data.action, url, username, password, data.entryId); + showPrompt(settings.captureStyle, data.action, username, password); } // --- Prompt UI --- function removeExistingPrompt(): void { - const existing = document.getElementById('relicario-capture-prompt'); - if (existing) existing.remove(); + if (currentPrompt) { + currentPrompt.destroy(); + currentPrompt = null; + } } function showPrompt( style: 'bar' | 'toast', action: string, - url: string, username: string, password: string, - entryId?: string, ): void { removeExistingPrompt(); - let hostname: string; - try { - hostname = new URL(url).hostname; - } catch { - hostname = url; + const hostname = (() => { + try { return new URL(window.location.href).hostname; } catch { return window.location.href; } + })(); + + const surface = createShadowHost(); + currentPrompt = surface; + const { host, root } = surface; + + // Position the host on the page; all further styling lives inside the + // shadow root so the page's CSS can't reach us. + const baseHostStyles = 'z-index: 2147483647; position: fixed;'; + if (style === 'bar') { + host.style.cssText = `${baseHostStyles} top:0; left:0; right:0;`; + } else { + host.style.cssText = `${baseHostStyles} bottom:16px; right:16px;`; } - const container = document.createElement('div'); - container.id = 'relicario-capture-prompt'; + // --- Build prompt DOM via createElement / textContent only --- - // Common styles - const baseStyles = [ + const container = document.createElement('div'); + const containerBase = [ 'font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace', 'font-size: 13px', 'color: #c9d1d9', 'background: #161b22', - 'z-index: 2147483647', 'box-sizing: border-box', 'line-height: 1.4', ]; - if (style === 'bar') { container.style.cssText = [ - ...baseStyles, - 'position: fixed', - 'top: 0', - 'left: 0', - 'right: 0', + ...containerBase, 'padding: 10px 16px', 'display: flex', 'align-items: center', @@ -152,10 +163,7 @@ function showPrompt( ].join('; '); } else { container.style.cssText = [ - ...baseStyles, - 'position: fixed', - 'bottom: 16px', - 'right: 16px', + ...containerBase, 'padding: 12px 16px', 'border-radius: 4px', 'border: 1px solid #30363d', @@ -167,30 +175,46 @@ function showPrompt( } const actionLabel = action === 'update' ? 'Update' : 'Save'; - const displayUser = username ? ` (${username})` : ''; - container.innerHTML = ` - - ${actionLabel} login for ${escapeForHtml(hostname)}${escapeForHtml(displayUser)}? - - - - - `; + // Message span: " login for ()?" + const msgSpan = document.createElement('span'); + msgSpan.style.cssText = 'flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;'; + msgSpan.appendChild(document.createTextNode(`${actionLabel} login for `)); + const hostStrong = document.createElement('strong'); + hostStrong.style.color = '#58a6ff'; + hostStrong.textContent = hostname; + msgSpan.appendChild(hostStrong); + if (username) { + msgSpan.appendChild(document.createTextNode(` (${username})`)); + } + msgSpan.appendChild(document.createTextNode('?')); - document.body.appendChild(container); + const saveBtn = document.createElement('button'); + saveBtn.textContent = actionLabel; + saveBtn.style.cssText = [ + 'background:#1f6feb', 'color:#fff', 'border:none', 'padding:5px 14px', + 'border-radius:3px', 'cursor:pointer', 'font-family:inherit', 'font-size:12px', + 'white-space:nowrap', + ].join('; '); + + const neverBtn = document.createElement('button'); + neverBtn.textContent = 'Never'; + neverBtn.style.cssText = [ + 'background:transparent', 'color:#8b949e', 'border:1px solid #30363d', + 'padding:5px 10px', 'border-radius:3px', 'cursor:pointer', + 'font-family:inherit', 'font-size:12px', 'white-space:nowrap', + ].join('; '); + + const closeBtn = document.createElement('button'); + closeBtn.textContent = '✕'; + closeBtn.style.cssText = [ + 'background:transparent', 'color:#8b949e', 'border:none', + 'cursor:pointer', 'font-size:16px', 'padding:2px 6px', + 'font-family:inherit', 'line-height:1', + ].join('; '); + + container.append(msgSpan, saveBtn, neverBtn, closeBtn); + root.appendChild(container); // Animate in requestAnimationFrame(() => { @@ -211,68 +235,35 @@ function showPrompt( if (autoDismissTimer) clearTimeout(autoDismissTimer); }; - // Save button - container.querySelector('#relicario-save-btn')?.addEventListener('click', async () => { + // Save button — single content-callable message; the SW figures out + // whether this is an add or an update (and enforces origin-binding). + saveBtn.addEventListener('click', async () => { clearAutoDismiss(); - - const now = new Date().toISOString(); - if (action === 'update' && entryId) { - await sendMessage({ - type: 'update_entry', - id: entryId, - entry: { - name: hostname, - url, - username, - password, - created_at: now, - updated_at: now, - }, - }); - } else { - await sendMessage({ - type: 'add_entry', - entry: { - name: hostname, - url, - username, - password, - created_at: now, - updated_at: now, - }, - }); + const resp = await sendMessage({ type: 'capture_save_login', username, password }); + if (!resp.ok) { + msgSpan.textContent = `✗ ${resp.error}`; + return; } - - // Show confirmation - const span = container.querySelector('span'); - if (span) span.textContent = '\u2713 Saved'; - const saveBtn = container.querySelector('#relicario-save-btn') as HTMLElement | null; - const neverBtn = container.querySelector('#relicario-never-btn') as HTMLElement | null; - if (saveBtn) saveBtn.style.display = 'none'; - if (neverBtn) neverBtn.style.display = 'none'; + msgSpan.textContent = '✓ Saved'; + saveBtn.style.display = 'none'; + neverBtn.style.display = 'none'; setTimeout(() => removeExistingPrompt(), 1500); }); - // Never button - container.querySelector('#relicario-never-btn')?.addEventListener('click', async () => { + // Never button: router derives hostname from sender.tab.url (no `hostname` field) + neverBtn.addEventListener('click', async () => { clearAutoDismiss(); - await sendMessage({ type: 'blacklist_site', hostname }); + await sendMessage({ type: 'blacklist_site' }); removeExistingPrompt(); }); // Close button - container.querySelector('#relicario-close-btn')?.addEventListener('click', () => { + closeBtn.addEventListener('click', () => { clearAutoDismiss(); removeExistingPrompt(); }); } -function escapeForHtml(str: string): string { - const div = document.createElement('div'); - div.textContent = str; - return div.innerHTML; -} - // --- Form hooking --- export function hookForms(): void { diff --git a/extension/src/content/fill.ts b/extension/src/content/fill.ts index 88e00c8..ebc663c 100644 --- a/extension/src/content/fill.ts +++ b/extension/src/content/fill.ts @@ -1,13 +1,41 @@ -/// Fill listener — receives credentials from the service worker and fills form fields. +/// Fill listener — receives credentials from the service worker popup flow, +/// verifies origin, and fills page fields. /// -/// Uses the native value setter trick to work with React/Vue controlled inputs -/// that override the value property. +/// TOCTOU mitigation: the popup captures its active tab at open time and +/// passes {capturedTabId, capturedUrl, expectedHost} to the SW. The SW +/// re-fetches the tab and checks the hostname against `capturedUrl` before +/// forwarding, but between the SW's chrome.tabs.sendMessage and our receipt +/// the page could navigate. We re-check `location.href.hostname === +/// expectedHost` before typing credentials. If the page has navigated +/// (different origin now running the content script), reply with +/// `origin_changed` and do nothing. + +/// Message shape forwarded by router/popup-only.ts#handleFillCredentials. +export interface FillMessage { + type: 'fill_credentials'; + username: string; + password: string; + /// The hostname the SW validated the captured tab was on. The content + /// script rejects delivery if the page has since navigated away. + expectedHost: string; +} /// Set up a listener for fill_credentials messages from the service worker. export function setupFillListener(): void { chrome.runtime.onMessage.addListener( - (message: { type: string; username: string; password: string }, _sender: chrome.runtime.MessageSender, sendResponse: (response: { ok: boolean }) => void) => { + ( + message: FillMessage, + _sender: chrome.runtime.MessageSender, + sendResponse: (response: { ok: boolean; error?: string }) => void, + ) => { if (message.type !== 'fill_credentials') return false; + const currentHost = (() => { + try { return new URL(location.href).hostname; } catch { return ''; } + })(); + if (!currentHost || currentHost !== message.expectedHost) { + sendResponse({ ok: false, error: 'origin_changed' }); + return false; + } fillFields(message.username, message.password); sendResponse({ ok: true }); return false; diff --git a/extension/src/content/icon.ts b/extension/src/content/icon.ts index 5d830e0..a637559 100644 --- a/extension/src/content/icon.ts +++ b/extension/src/content/icon.ts @@ -1,15 +1,40 @@ /// Inject a small "id" icon into password fields for quick autofill access. /// -/// Uses a WeakSet to avoid double-injection on re-scans (MutationObserver). +/// Each injected icon and picker renders inside a closed Shadow DOM so +/// the host page cannot read or manipulate our UI. +/// +/// Flow: +/// 1. Icon click → chrome.runtime.sendMessage({ type: 'get_autofill_candidates' }) +/// (router derives origin from sender.tab.url; no url on message). +/// 2. Single candidate → get_credentials; if response is a +/// requires_ack variant, show an in-page TOFU hint instructing the +/// user to open the popup for ack. Otherwise, call fillFields() +/// directly — the content script IS the page origin, so no SW +/// round-trip for the fill itself. +/// 3. Multiple candidates → show the picker inside a shadow root. +/// +/// Note: fill_credentials is popup-only in the router. The icon click path +/// cannot and MUST NOT issue fill_credentials from content. -import type { ManifestEntry } from '../shared/types'; +import type { AutofillCandidatesResponse, CredentialsResponse, Response } from '../shared/messages'; +import type { ManifestEntry, ItemId } from '../shared/types'; +import { createShadowHost, type ShadowSurface } from './shadow'; +import { fillFields } from './fill'; /// Track which fields already have an injected icon. const injected = new WeakSet(); +/// The currently-open picker / TOFU hint, if any. +let currentOverlay: ShadowSurface | null = null; + +function closeOverlay(): void { + if (currentOverlay) { + currentOverlay.destroy(); + currentOverlay = null; + } +} + /// Inject a small blue "id" icon at the right edge of a password field. -/// Clicking it queries for autofill candidates and either fills immediately -/// (single match) or shows an inline picker (multiple matches). export function injectFieldIcons( passwordField: HTMLInputElement, _usernameField: HTMLInputElement | null, @@ -17,145 +42,190 @@ export function injectFieldIcons( if (injected.has(passwordField)) return; injected.add(passwordField); - // Create the icon element. + // Each icon gets its own shadow host so page CSS cannot reach it. + const surface = createShadowHost(); + const { host, root } = surface; + + // Compute initial position from the password field's bounding rect and + // reposition on scroll/resize. We keep things lightweight — exact + // pixel-perfect tracking during layout churn is not required. + function positionHost(): void { + const rect = passwordField.getBoundingClientRect(); + host.style.cssText = [ + 'position: fixed', + `top: ${rect.top + rect.height / 2 - 10}px`, + `left: ${rect.right - 28}px`, + 'z-index: 2147483646', + 'pointer-events: auto', + ].join('; '); + } + positionHost(); + window.addEventListener('scroll', positionHost, true); + window.addEventListener('resize', positionHost); + const icon = document.createElement('div'); icon.textContent = 'id'; icon.setAttribute('role', 'button'); icon.setAttribute('aria-label', 'relicario autofill'); + icon.style.cssText = [ + 'width: 20px', 'height: 20px', 'line-height: 20px', + 'text-align: center', 'font-size: 10px', 'font-weight: 700', + 'font-family: monospace', 'color: #fff', 'background: #1f6feb', + 'border-radius: 3px', 'cursor: pointer', 'user-select: none', + 'box-sizing: border-box', + ].join('; '); + root.appendChild(icon); - Object.assign(icon.style, { - position: 'absolute', - right: '8px', - top: '50%', - transform: 'translateY(-50%)', - width: '20px', - height: '20px', - lineHeight: '20px', - textAlign: 'center', - fontSize: '10px', - fontWeight: '700', - fontFamily: 'monospace', - color: '#fff', - background: '#1f6feb', - borderRadius: '3px', - cursor: 'pointer', - zIndex: '999999', - userSelect: 'none', - }); - - // Ensure the password field's parent is positioned so the icon can be absolute. - const parent = passwordField.parentElement; - if (parent) { - const parentPosition = getComputedStyle(parent).position; - if (parentPosition === 'static') { - parent.style.position = 'relative'; - } - } - - // Insert the icon after the password field. - passwordField.insertAdjacentElement('afterend', icon); - - // Click handler: query for autofill candidates. icon.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); - const url = window.location.href; + // Note: no `url` on message — router derives from sender.tab.url. const resp = await chrome.runtime.sendMessage({ type: 'get_autofill_candidates', - url, - }); + }) as Response; if (!resp || !resp.ok) return; - const candidates = resp.data.candidates as Array<[string, ManifestEntry]>; - + const candidates = (resp as AutofillCandidatesResponse).data.candidates; if (candidates.length === 0) return; if (candidates.length === 1) { - // Single match — fill immediately. - const [id] = candidates[0]; - const credResp = await chrome.runtime.sendMessage({ - type: 'get_credentials', - id, - }); - if (credResp?.ok) { - chrome.runtime.sendMessage({ - type: 'fill_credentials', - username: credResp.data.username, - password: credResp.data.password, - }); - } + await handleSingleCandidate(candidates[0][0]); } else { - // Multiple matches — show inline picker. - showPicker(icon, candidates); + showPicker(passwordField, candidates); } }); } -/// Show a small dropdown picker below the icon for selecting among multiple candidates. +/// Fetch credentials for a single item and either fill immediately or +/// display the TOFU ack hint. +async function handleSingleCandidate(id: ItemId): Promise { + const credResp = await chrome.runtime.sendMessage({ + type: 'get_credentials', + id, + }) as Response; + if (!credResp?.ok) return; + + const data = (credResp as CredentialsResponse).data; + if ('requires_ack' in data && data.requires_ack) { + showAckHint(data.hostname); + return; + } + // Discriminated union: must be the {username, password} variant here. + if ('username' in data && 'password' in data) { + fillFields(data.username, data.password); + } +} + +/// Render a dropdown picker below the password field for selecting among +/// multiple candidates. The picker lives in its own closed Shadow DOM. function showPicker( - anchor: HTMLElement, - candidates: Array<[string, ManifestEntry]>, + anchor: HTMLInputElement, + candidates: Array<[ItemId, ManifestEntry]>, ): void { - // Remove any existing picker. - document.querySelectorAll('.relicario-picker').forEach(el => el.remove()); + closeOverlay(); + const surface = createShadowHost(); + currentOverlay = surface; + const { host, root } = surface; + + const rect = anchor.getBoundingClientRect(); + host.style.cssText = [ + 'position: fixed', + `top: ${rect.bottom + 4}px`, + `left: ${rect.right - 180}px`, + 'z-index: 2147483647', + ].join('; '); const picker = document.createElement('div'); - picker.className = 'relicario-picker'; - Object.assign(picker.style, { - position: 'absolute', - right: '0', - top: '100%', - marginTop: '4px', - background: '#161b22', - border: '1px solid #30363d', - borderRadius: '6px', - boxShadow: '0 4px 12px rgba(0,0,0,0.4)', - zIndex: '9999999', - minWidth: '180px', - maxHeight: '200px', - overflowY: 'auto', - fontFamily: "'JetBrains Mono', monospace", - fontSize: '12px', - }); + picker.style.cssText = [ + 'background: #161b22', 'border: 1px solid #30363d', + 'border-radius: 6px', 'box-shadow: 0 4px 12px rgba(0,0,0,0.4)', + 'min-width: 180px', 'max-height: 200px', 'overflow-y: auto', + "font-family: 'JetBrains Mono', monospace", 'font-size: 12px', + ].join('; '); for (const [id, entry] of candidates) { const row = document.createElement('div'); - row.textContent = `${entry.name}${entry.username ? ` (${entry.username})` : ''}`; - Object.assign(row.style, { - padding: '8px 12px', - cursor: 'pointer', - color: '#c9d1d9', - borderBottom: '1px solid #21262d', - }); + const label = entry.title + (/* user hint */ ''); + row.textContent = label; + row.style.cssText = [ + 'padding: 8px 12px', 'cursor: pointer', 'color: #c9d1d9', + 'border-bottom: 1px solid #21262d', + ].join('; '); row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; }); row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; }); row.addEventListener('click', async (e) => { e.stopPropagation(); - picker.remove(); - const credResp = await chrome.runtime.sendMessage({ - type: 'get_credentials', - id, - }); - if (credResp?.ok) { - chrome.runtime.sendMessage({ - type: 'fill_credentials', - username: credResp.data.username, - password: credResp.data.password, - }); - } + closeOverlay(); + await handleSingleCandidate(id); }); picker.appendChild(row); } - anchor.parentElement?.appendChild(picker); + root.appendChild(picker); - // Close picker on outside click. - const closeHandler = (e: MouseEvent) => { - if (!picker.contains(e.target as Node) && e.target !== anchor) { - picker.remove(); + // Close picker on outside click (scoped to document; shadow root blocks + // composedPath for closed mode but the host element still shows up). + const closeHandler = (e: MouseEvent): void => { + if (e.target !== host) { + closeOverlay(); document.removeEventListener('click', closeHandler); } }; setTimeout(() => document.addEventListener('click', closeHandler), 0); } + +/// TOFU origin-ack hint: credentials exist for this host but the user has +/// never explicitly acknowledged autofill here. Instruct them to open +/// relicario to confirm — we do not (and cannot) fill until ack-autofill +/// has been called from the popup. +function showAckHint(hostname: string): void { + closeOverlay(); + const surface = createShadowHost(); + currentOverlay = surface; + const { host, root } = surface; + + host.style.cssText = [ + 'position: fixed', 'top: 16px', 'right: 16px', + 'z-index: 2147483647', + ].join('; '); + + const hint = document.createElement('div'); + hint.style.cssText = [ + 'font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace', + 'font-size: 12px', 'color: #c9d1d9', 'background: #161b22', + 'border: 1px solid #30363d', 'border-radius: 6px', + 'padding: 10px 14px', 'box-shadow: 0 4px 12px rgba(0,0,0,0.4)', + 'max-width: 320px', 'line-height: 1.5', + ].join('; '); + + const title = document.createElement('div'); + title.style.cssText = 'font-weight: 700; margin-bottom: 4px; color: #58a6ff;'; + title.textContent = 'relicario'; + hint.appendChild(title); + + const body = document.createElement('div'); + body.appendChild(document.createTextNode('First autofill on ')); + const hostSpan = document.createElement('strong'); + hostSpan.textContent = hostname; + body.appendChild(hostSpan); + body.appendChild(document.createTextNode(' — open relicario to confirm.')); + hint.appendChild(body); + + const close = document.createElement('div'); + close.textContent = '✕'; + close.style.cssText = [ + 'position: absolute', 'top: 6px', 'right: 8px', + 'cursor: pointer', 'color: #8b949e', 'font-size: 14px', + ].join('; '); + close.addEventListener('click', closeOverlay); + hint.style.position = 'relative'; + hint.appendChild(close); + + root.appendChild(hint); + + // Auto-dismiss after 8 seconds + setTimeout(() => { + if (currentOverlay === surface) closeOverlay(); + }, 8000); +} diff --git a/extension/src/content/shadow.ts b/extension/src/content/shadow.ts new file mode 100644 index 0000000..d76aad2 --- /dev/null +++ b/extension/src/content/shadow.ts @@ -0,0 +1,37 @@ +/// Closed Shadow DOM host helper. +/// +/// All in-page UI (capture prompt, autofill icon, candidate picker, TOFU +/// banner) mounts into a closed-mode ShadowRoot so the host page cannot +/// read or mutate the overlay via document.querySelector / DOM APIs. The +/// returned ShadowSurface provides {host, root, destroy} for callers that +/// want to populate the root, position the host, and tear everything down. + +export interface ShadowSurface { + /// The host
that's appended to document.body. Style/position this + /// from the caller (position: fixed, z-index, transform, etc.). + host: HTMLDivElement; + /// Closed-mode ShadowRoot. Populate via textContent / appendChild — + /// NEVER innerHTML, NEVER insertAdjacentHTML. Treat any caller-supplied + /// string (hostname, username) as untrusted. + root: ShadowRoot; + /// Remove the host from the DOM and drop all references. + destroy: () => void; +} + +/// Create a closed Shadow DOM host attached to document.body. +/// +/// Callers are responsible for positioning `host` and filling `root`. +export function createShadowHost(): ShadowSurface { + const host = document.createElement('div'); + // Reset host-side styling so page CSS cannot leak in/out via inheritance. + host.style.all = 'initial'; + const root = host.attachShadow({ mode: 'closed' }); + document.body.appendChild(host); + return { + host, + root, + destroy: () => { + host.remove(); + }, + }; +} diff --git a/extension/src/popup/components/entry-detail.ts b/extension/src/popup/components/entry-detail.ts deleted file mode 100644 index be06bdf..0000000 --- a/extension/src/popup/components/entry-detail.ts +++ /dev/null @@ -1,255 +0,0 @@ -/// Entry detail view — shows fields, TOTP countdown, copy/fill shortcuts. - -import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; -import type { ManifestEntry } from '../../shared/types'; - -let totpInterval: ReturnType | null = null; - -function stopTotpTimer(): void { - if (totpInterval !== null) { - clearInterval(totpInterval); - totpInterval = null; - } -} - -async function copyToClipboard(text: string): Promise { - try { - await navigator.clipboard.writeText(text); - } catch { - // Fallback for older browsers. - const ta = document.createElement('textarea'); - ta.value = text; - ta.style.position = 'fixed'; - ta.style.left = '-9999px'; - document.body.appendChild(ta); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); - } -} - -export function renderEntryDetail(app: HTMLElement): void { - const state = getState(); - const entry = state.selectedEntry; - const id = state.selectedId; - if (!entry || !id) { - navigate('list'); - return; - } - - stopTotpTimer(); - - let html = ` -
- ${escapeHtml(entry.name)} - -
- `; - - // URL - if (entry.url) { - html += ` -
-
url
-
${escapeHtml(entry.url)}
-
- `; - } - - // Username - if (entry.username) { - html += ` -
-
username
-
${escapeHtml(entry.username)}
-
- `; - } - - // Password (masked by default) - html += ` -
-
password
-
- ******** -
-
- `; - - // TOTP - if (entry.totp_secret) { - html += ` -
-
totp
-
------
-
-
- `; - } - - // Notes - if (entry.notes) { - html += ` -
-
notes
-
${escapeHtml(entry.notes)}
-
- `; - } - - // Group - if (entry.group) { - html += ` -
-
group
-
${escapeHtml(entry.group)}
-
- `; - } - - // Metadata - html += ` -
-
updated ${escapeHtml(entry.updated_at)}
-
- `; - - // Key hints - html += ` -
- c copy user - p copy pass - ${entry.totp_secret ? 't copy totp' : ''} - f autofill - e edit - d delete -
- `; - - app.innerHTML = html; - - // --- Password toggle --- - let passwordVisible = false; - const passwordDisplay = document.getElementById('password-display')!; - const passwordVal = document.getElementById('password-val')!; - passwordVal?.addEventListener('click', () => { - passwordVisible = !passwordVisible; - passwordDisplay.textContent = passwordVisible ? entry.password : '********'; - }); - - // --- Back button --- - document.getElementById('back-btn')?.addEventListener('click', goBack); - - // --- TOTP timer --- - if (entry.totp_secret) { - refreshTotp(id); - totpInterval = setInterval(() => refreshTotp(id), 1000); - } - - // --- Keyboard shortcuts --- - const handler = async (e: KeyboardEvent) => { - // Ignore if typing in an input. - if ((e.target as HTMLElement).tagName === 'INPUT') return; - - switch (e.key) { - case 'Escape': - document.removeEventListener('keydown', handler); - goBack(); - break; - - case 'c': - if (entry.username) await copyToClipboard(entry.username); - break; - - case 'p': - await copyToClipboard(entry.password); - break; - - case 't': - if (entry.totp_secret) { - const codeEl = document.getElementById('totp-code'); - if (codeEl) await copyToClipboard(codeEl.textContent ?? ''); - } - break; - - case 'f': { - const resp = await sendMessage({ - type: 'fill_credentials', - username: entry.username ?? '', - password: entry.password, - }); - if (!resp.ok) setState({ error: resp.error }); - break; - } - - case 'e': - document.removeEventListener('keydown', handler); - stopTotpTimer(); - navigate('edit'); - break; - - case 'd': - e.preventDefault(); - showDeleteConfirm(id, entry.name, handler); - break; - } - }; - - document.addEventListener('keydown', handler); -} - -async function refreshTotp(id: string): Promise { - const resp = await sendMessage({ type: 'get_totp', id }); - if (resp.ok) { - const data = resp.data as { code: string; remaining_seconds: number }; - const codeEl = document.getElementById('totp-code'); - const barEl = document.getElementById('totp-bar-fill'); - if (codeEl) codeEl.textContent = data.code; - if (barEl) barEl.style.width = `${(data.remaining_seconds / 30) * 100}%`; - } -} - -function goBack(): void { - stopTotpTimer(); - // Reload the entry list. - sendMessage({ type: 'list_entries' }).then(resp => { - if (resp.ok) { - const data = resp.data as { entries: Array<[string, ManifestEntry]> }; - navigate('list', { - entries: data.entries, - selectedId: null, - selectedEntry: null, - }); - } - }); -} - -function showDeleteConfirm(id: string, name: string, parentHandler: (e: KeyboardEvent) => void): void { - const overlay = document.createElement('div'); - overlay.className = 'confirm-overlay'; - overlay.innerHTML = ` -
-

Delete ${escapeHtml(name)}?

- - -
- `; - document.body.appendChild(overlay); - - document.getElementById('cancel-delete')?.addEventListener('click', () => { - overlay.remove(); - }); - - document.getElementById('confirm-delete')?.addEventListener('click', async () => { - overlay.remove(); - setState({ loading: true }); - const resp = await sendMessage({ type: 'delete_entry', id }); - if (resp.ok) { - document.removeEventListener('keydown', parentHandler); - stopTotpTimer(); - goBack(); - } else { - setState({ loading: false, error: resp.error }); - } - }); -} diff --git a/extension/src/popup/components/entry-form.ts b/extension/src/popup/components/entry-form.ts deleted file mode 100644 index 812da91..0000000 --- a/extension/src/popup/components/entry-form.ts +++ /dev/null @@ -1,142 +0,0 @@ -/// Entry form — add or edit an entry. - -import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; -import type { Entry, ManifestEntry } from '../../shared/types'; - -export function renderEntryForm(app: HTMLElement, mode: 'add' | 'edit'): void { - const state = getState(); - const existing = mode === 'edit' ? state.selectedEntry : null; - - app.innerHTML = ` -
-
${mode === 'add' ? 'new entry' : 'edit entry'}
- ${state.error ? `
${escapeHtml(state.error)}
` : ''} -
- - -
-
- - -
-
- - -
-
- -
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- `; - - // --- Generate password --- - document.getElementById('gen-btn')?.addEventListener('click', async () => { - const resp = await sendMessage({ type: 'generate_password', length: 24 }); - if (resp.ok) { - const data = resp.data as { password: string }; - const pwInput = document.getElementById('f-password') as HTMLInputElement; - pwInput.value = data.password; - pwInput.type = 'text'; // Show generated password. - } - }); - - // --- Cancel --- - document.getElementById('cancel-btn')?.addEventListener('click', () => { - if (mode === 'edit' && state.selectedId && state.selectedEntry) { - navigate('detail'); - } else { - navigate('list'); - } - }); - - // --- Save --- - document.getElementById('save-btn')?.addEventListener('click', async () => { - const name = (document.getElementById('f-name') as HTMLInputElement).value.trim(); - const url = (document.getElementById('f-url') as HTMLInputElement).value.trim() || undefined; - const username = (document.getElementById('f-username') as HTMLInputElement).value.trim() || undefined; - const password = (document.getElementById('f-password') as HTMLInputElement).value; - const totp_secret = (document.getElementById('f-totp') as HTMLInputElement).value.trim() || undefined; - const group = (document.getElementById('f-group') as HTMLInputElement).value.trim() || undefined; - const notes = (document.getElementById('f-notes') as HTMLTextAreaElement).value.trim() || undefined; - - if (!name) { - setState({ error: 'Name is required' }); - return; - } - if (!password) { - setState({ error: 'Password is required' }); - return; - } - - const now = new Date().toISOString(); - const entry: Entry = { - name, - url, - username, - password, - notes, - totp_secret, - group, - created_at: existing?.created_at ?? now, - updated_at: now, - }; - - setState({ loading: true, error: null }); - - let resp; - if (mode === 'add') { - resp = await sendMessage({ type: 'add_entry', entry }); - } else { - resp = await sendMessage({ type: 'update_entry', id: state.selectedId!, entry }); - } - - if (resp.ok) { - // Refresh entries and go to list. - const listResp = await sendMessage({ type: 'list_entries' }); - if (listResp.ok) { - const data = listResp.data as { entries: Array<[string, ManifestEntry]> }; - navigate('list', { entries: data.entries, selectedId: null, selectedEntry: null }); - } else { - navigate('list'); - } - } else { - setState({ loading: false, error: resp.error }); - } - }); - - // --- Escape to cancel --- - const escHandler = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - document.removeEventListener('keydown', escHandler); - if (mode === 'edit' && state.selectedId && state.selectedEntry) { - navigate('detail'); - } else { - navigate('list'); - } - } - }; - document.addEventListener('keydown', escHandler); - - // Focus the name field. - (document.getElementById('f-name') as HTMLInputElement)?.focus(); -} diff --git a/extension/src/popup/components/entry-list.ts b/extension/src/popup/components/entry-list.ts deleted file mode 100644 index 2fa7aa2..0000000 --- a/extension/src/popup/components/entry-list.ts +++ /dev/null @@ -1,176 +0,0 @@ -/// Entry list view — search bar, group tabs, scrollable entry list with keyboard nav. - -import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; -import type { ManifestEntry } from '../../shared/types'; - -/// Extract the domain from a URL for display. -function domainOf(url: string | undefined): string { - if (!url) return ''; - try { - return new URL(url).hostname; - } catch { - return ''; - } -} - -/// Derive unique group names from the current entries. -function getGroups(entries: Array<[string, ManifestEntry]>): string[] { - const groups = new Set(); - for (const [, e] of entries) { - if (e.group) groups.add(e.group); - } - return Array.from(groups).sort(); -} - -export function renderEntryList(app: HTMLElement): void { - const state = getState(); - const groups = getGroups(state.entries); - const filtered = getFilteredEntries(); - - const groupTabsHtml = groups.length > 0 - ? `
- - ${groups.map(g => - `` - ).join('')} -
` - : ''; - - const entriesHtml = filtered.length > 0 - ? filtered.map(([id, e], i) => ` -
- ${escapeHtml(e.name)} - -
- `).join('') - : '
no entries
'; - - app.innerHTML = ` - - ${groupTabsHtml} -
- ${entriesHtml} -
-
- / search - + add - ↑↓ nav - Enter open -
- `; - - // --- Event listeners --- - - const searchInput = document.getElementById('search-input') as HTMLInputElement; - searchInput?.addEventListener('input', () => { - setState({ searchQuery: searchInput.value, selectedIndex: 0 }); - }); - - // Group tab clicks. - const groupTabs = app.querySelectorAll('.group-tab'); - groupTabs.forEach(tab => { - tab.addEventListener('click', () => { - const group = (tab as HTMLElement).dataset.group || null; - setState({ activeGroup: group, selectedIndex: 0 }); - }); - }); - - // Entry row clicks. - const rows = app.querySelectorAll('.entry-row'); - rows.forEach(row => { - row.addEventListener('click', async () => { - const id = (row as HTMLElement).dataset.id!; - await openEntry(id); - }); - }); - - // Keyboard navigation. - document.addEventListener('keydown', handleListKeydown); - - // Focus search on / key (unless already focused). - searchInput?.focus(); -} - -async function openEntry(id: string): Promise { - setState({ loading: true }); - const resp = await sendMessage({ type: 'get_entry', id }); - if (resp.ok) { - const data = resp.data as { entry: import('../../shared/types').Entry }; - navigate('detail', { - selectedId: id, - selectedEntry: data.entry, - }); - } else { - setState({ loading: false, error: resp.error }); - } -} - -/// Compute the visible (filtered) entry list from current state. -function getFilteredEntries(): Array<[string, ManifestEntry]> { - const state = getState(); - let filtered = state.entries; - if (state.activeGroup) { - const g = state.activeGroup.toLowerCase(); - filtered = filtered.filter(([, e]) => e.group?.toLowerCase() === g); - } - if (state.searchQuery) { - const q = state.searchQuery.toLowerCase(); - filtered = filtered.filter(([, e]) => { - if (e.name.toLowerCase().includes(q)) return true; - if (e.url?.toLowerCase().includes(q)) return true; - if (e.username?.toLowerCase().includes(q)) return true; - return false; - }); - } - filtered.sort((a, b) => a[1].name.localeCompare(b[1].name)); - return filtered; -} - -function handleListKeydown(e: KeyboardEvent): void { - const state = getState(); - const target = e.target as HTMLElement; - const isSearch = target.id === 'search-input'; - - if (e.key === '/' && !isSearch) { - e.preventDefault(); - (document.getElementById('search-input') as HTMLInputElement)?.focus(); - return; - } - - if (e.key === '+' && !isSearch) { - e.preventDefault(); - navigate('add'); - return; - } - - const filtered = getFilteredEntries(); - - if (e.key === 'ArrowDown') { - e.preventDefault(); - const max = Math.max(filtered.length - 1, 0); - setState({ selectedIndex: Math.min(state.selectedIndex + 1, max) }); - return; - } - - if (e.key === 'ArrowUp') { - e.preventDefault(); - setState({ selectedIndex: Math.max(state.selectedIndex - 1, 0) }); - return; - } - - if (e.key === 'Enter' && !isSearch) { - e.preventDefault(); - if (filtered[state.selectedIndex]) { - openEntry(filtered[state.selectedIndex][0]); - } - return; - } - - if (e.key === 'Escape') { - document.removeEventListener('keydown', handleListKeydown); - window.close(); - return; - } -} diff --git a/extension/src/popup/components/item-detail.ts b/extension/src/popup/components/item-detail.ts new file mode 100644 index 0000000..e00f121 --- /dev/null +++ b/extension/src/popup/components/item-detail.ts @@ -0,0 +1,374 @@ +/// Typed-item detail view — dispatches on `item.type`. Slice 6 delivers +/// full Login parity; all other types show a "coming soon" placeholder. +/// +/// Autofill uses the (capturedTabId, capturedUrl) pair snapshotted at +/// popup-open (see PopupState + router/popup-only.ts#handleFillCredentials) +/// so the SW can reject the fill if the tab navigated. + +import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; +import type { Item, ItemId, ManifestEntry, LoginCore, TotpConfig } from '../../shared/types'; + +let totpInterval: ReturnType | null = null; + +function stopTotpTimer(): void { + if (totpInterval !== null) { + clearInterval(totpInterval); + totpInterval = null; + } +} + +async function copyToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + } catch { + // Fallback for older browsers. + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } +} + +export function renderItemDetail(app: HTMLElement): void { + const state = getState(); + const item = state.selectedItem; + if (!item) { + navigate('list'); + return; + } + + stopTotpTimer(); + + if (item.type === 'login') { + renderLogin(app, item); + } else { + renderComingSoon(app, item); + } +} + +// --- Login detail ------------------------------------------------------ + +function renderLogin(app: HTMLElement, item: Item): void { + const core = item.core as (LoginCore & { type: 'login' }); + const hasTotp = core.totp !== undefined; + + let html = ` +
+ ${escapeHtml(item.title)} + +
+ `; + + if (core.url) { + html += ` + + `; + } + + if (core.username) { + html += ` +
+
username
+
${escapeHtml(core.username)}
+
+ `; + } + + html += ` +
+
password
+
+ ******** + +
+
+ `; + + if (hasTotp) { + html += ` +
+
totp
+
------
+
+
+ `; + } + + if (item.notes) { + html += ` +
+
notes
+
${escapeHtml(item.notes)}
+
+ `; + } + + if (item.group) { + html += ` +
+
group
+
${escapeHtml(item.group)}
+
+ `; + } + + html += ` +
+
modified ${escapeHtml(new Date(item.modified * 1000).toISOString())}
+
+ `; + + html += ` +
+ + + +
+ `; + + html += ` +
+ c copy user + p copy pass + ${hasTotp ? 't copy totp' : ''} + f autofill + e edit + d trash +
+ `; + + app.innerHTML = html; + + // --- Password toggle --- + let passwordVisible = false; + const passwordDisplay = document.getElementById('password-display'); + const passwordVal = document.getElementById('password-val'); + const password = core.password ?? ''; + passwordVal?.addEventListener('click', (e) => { + // Ignore clicks originating on the copy button. + if ((e.target as HTMLElement).id === 'password-copy') return; + passwordVisible = !passwordVisible; + if (passwordDisplay) passwordDisplay.textContent = passwordVisible ? password : '********'; + }); + document.getElementById('password-copy')?.addEventListener('click', async (e) => { + e.stopPropagation(); + await copyToClipboard(password); + }); + + if (core.username) { + document.getElementById('username-val')?.addEventListener('click', async () => { + await copyToClipboard(core.username ?? ''); + }); + } + + document.getElementById('back-btn')?.addEventListener('click', goBack); + + document.getElementById('fill-btn')?.addEventListener('click', async () => { + const { capturedTabId, capturedUrl } = getState(); + if (capturedTabId === null) { + setState({ error: 'No active tab captured' }); + return; + } + const resp = await sendMessage({ + type: 'fill_credentials', + id: item.id, + capturedTabId, + capturedUrl, + }); + if (!resp.ok) { + setState({ error: resp.error }); + return; + } + window.close(); + }); + + document.getElementById('edit-btn')?.addEventListener('click', () => { + document.removeEventListener('keydown', handler); + stopTotpTimer(); + navigate('edit'); + }); + + document.getElementById('trash-btn')?.addEventListener('click', () => { + showDeleteConfirm(item.id, item.title, handler); + }); + + // --- TOTP timer --- + if (hasTotp) { + void refreshTotp(item.id); + totpInterval = setInterval(() => { void refreshTotp(item.id); }, 1000); + } + + // --- Keyboard shortcuts --- + const handler = async (e: KeyboardEvent) => { + // Bail if the user is typing into any editable field — don't steal + // printable keystrokes meant for an input/textarea/contenteditable element. + const t = e.target; + if (t instanceof HTMLElement) { + const tag = t.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return; + } + + switch (e.key) { + case 'Escape': + document.removeEventListener('keydown', handler); + goBack(); + break; + + case 'c': + if (core.username) await copyToClipboard(core.username); + break; + + case 'p': + await copyToClipboard(password); + break; + + case 't': + if (hasTotp) { + const codeEl = document.getElementById('totp-code'); + if (codeEl) await copyToClipboard(codeEl.textContent ?? ''); + } + break; + + case 'f': { + const { capturedTabId, capturedUrl } = getState(); + if (capturedTabId === null) { + setState({ error: 'No active tab captured' }); + break; + } + const resp = await sendMessage({ + type: 'fill_credentials', + id: item.id, + capturedTabId, + capturedUrl, + }); + if (!resp.ok) setState({ error: resp.error }); + else window.close(); + break; + } + + case 'e': + document.removeEventListener('keydown', handler); + stopTotpTimer(); + navigate('edit'); + break; + + case 'd': + e.preventDefault(); + showDeleteConfirm(item.id, item.title, handler); + break; + } + }; + + document.addEventListener('keydown', handler); +} + +async function refreshTotp(id: ItemId): Promise { + const resp = await sendMessage({ type: 'get_totp', id }); + if (resp.ok) { + const data = resp.data as { code: string; expires_at: number }; + const codeEl = document.getElementById('totp-code'); + const barEl = document.getElementById('totp-bar-fill'); + if (codeEl) codeEl.textContent = data.code; + if (barEl) { + const now = Math.floor(Date.now() / 1000); + const remaining = Math.max(0, data.expires_at - now); + // Period is 30 by default; compute ratio against 30. + barEl.style.width = `${(remaining / 30) * 100}%`; + } + } + // Suppress unused warning; TotpConfig referenced for typing only below. + void ({} as TotpConfig); +} + +// --- Coming-soon for non-login types ----------------------------------- + +function renderComingSoon(app: HTMLElement, item: Item): void { + app.innerHTML = ` +
+ ${escapeHtml(item.title)} + +
+
+
${typeEmoji(item.type)}
+
${escapeHtml(item.type.replace('_', ' '))}
+

read/write for this type is coming in a later slice.

+

use the CLI for now.

+
+ `; + + document.getElementById('back-btn')?.addEventListener('click', goBack); + + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + document.removeEventListener('keydown', handler); + goBack(); + } + }; + document.addEventListener('keydown', handler); +} + +function typeEmoji(t: Item['type']): 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 '⏱'; + } +} + +// --- Shared helpers ---------------------------------------------------- + +function goBack(): void { + stopTotpTimer(); + // Reload the item list. + void sendMessage({ type: 'list_items' }).then(resp => { + if (resp.ok) { + const data = resp.data as { items: Array<[ItemId, ManifestEntry]> }; + navigate('list', { + entries: data.items, + selectedId: null, + selectedItem: null, + }); + } + }); +} + +function showDeleteConfirm(id: ItemId, title: string, parentHandler: (e: KeyboardEvent) => void): void { + const overlay = document.createElement('div'); + overlay.className = 'confirm-overlay'; + overlay.innerHTML = ` +
+

Trash ${escapeHtml(title)}?

+ + +
+ `; + document.body.appendChild(overlay); + + document.getElementById('cancel-delete')?.addEventListener('click', () => { + overlay.remove(); + }); + + document.getElementById('confirm-delete')?.addEventListener('click', async () => { + overlay.remove(); + setState({ loading: true }); + const resp = await sendMessage({ type: 'delete_item', id }); + if (resp.ok) { + document.removeEventListener('keydown', parentHandler); + stopTotpTimer(); + goBack(); + } else { + setState({ loading: false, error: resp.error }); + } + }); +} diff --git a/extension/src/popup/components/item-form.ts b/extension/src/popup/components/item-form.ts new file mode 100644 index 0000000..58b68c1 --- /dev/null +++ b/extension/src/popup/components/item-form.ts @@ -0,0 +1,281 @@ +/// Typed-item add/edit form. Slice 6 ships full Login parity; other +/// types show a coming-soon placeholder (use the CLI for now). +/// +/// Carry-forward from Slice 5 review M3: on edit, trashed_at is +/// explicitly reset to undefined so stale trash state cannot survive an +/// edit. (The capture path already uses spread + fetched item; this +/// popup flow uses state.selectedItem.) + +import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; +import type { + Item, ItemId, ItemType, ManifestEntry, LoginCore, TotpConfig, +} from '../../shared/types'; +import { DEFAULT_PASSWORD_REQUEST } from '../../shared/types'; +import { base32Decode, base32Encode } from '../../shared/base32'; + +// Which types support add/edit in Slice 6. +function isEditableType(t: ItemType): boolean { + return t === 'login'; +} + +export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void { + const state = getState(); + const existing = mode === 'edit' ? state.selectedItem : null; + + // Determine the type we're editing/creating. Add defaults to login. + const type: ItemType = existing?.type ?? 'login'; + + if (!isEditableType(type)) { + renderComingSoon(app, type); + return; + } + + renderLoginForm(app, mode, existing); +} + +// --- Coming-soon ------------------------------------------------------- + +function renderComingSoon(app: HTMLElement, type: ItemType): void { + app.innerHTML = ` +
+
${escapeHtml(type.replace('_', ' '))}
+

editing ${escapeHtml(type)} items is coming in a later slice.

+

use the CLI for now.

+
+ +
+
+ `; + document.getElementById('back-btn')?.addEventListener('click', () => navigate('list')); + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + document.removeEventListener('keydown', handler); + navigate('list'); + } + }; + document.addEventListener('keydown', handler); +} + +// --- Login add/edit ---------------------------------------------------- + +/// Encode TotpConfig secret bytes back to a base32 display string. +function totpSecretToBase32(totp: TotpConfig | undefined): string { + if (!totp) return ''; + return base32Encode(new Uint8Array(totp.secret)); +} + +function renderLoginForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void { + const state = getState(); + const existingCore = (existing?.core.type === 'login') + ? (existing.core as LoginCore & { type: 'login' }) + : null; + + const title = existing?.title ?? ''; + const url = existingCore?.url ?? ''; + const username = existingCore?.username ?? ''; + const password = existingCore?.password ?? ''; + const totpStr = totpSecretToBase32(existingCore?.totp); + const group = existing?.group ?? ''; + const notes = existing?.notes ?? ''; + + app.innerHTML = ` +
+
${mode === 'add' ? 'new login' : 'edit login'}
+ ${state.error ? `
${escapeHtml(state.error)}
` : ''} +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ `; + + // --- Generate password --- + document.getElementById('gen-btn')?.addEventListener('click', async () => { + const resp = await sendMessage({ type: 'generate_password', request: DEFAULT_PASSWORD_REQUEST }); + if (resp.ok) { + const data = resp.data as { password: string }; + const pwInput = document.getElementById('f-password') as HTMLInputElement; + pwInput.value = data.password; + pwInput.type = 'text'; // Show generated password. + } else { + setState({ error: resp.error }); + } + }); + + // --- Cancel --- + document.getElementById('cancel-btn')?.addEventListener('click', () => goBack(mode)); + + // --- Save --- + document.getElementById('save-btn')?.addEventListener('click', async () => { + await saveLogin(mode, existing); + }); + + // --- Escape to cancel --- + const escHandler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + document.removeEventListener('keydown', escHandler); + goBack(mode); + } + }; + document.addEventListener('keydown', escHandler); + + // Focus the title field. + (document.getElementById('f-title') as HTMLInputElement | null)?.focus(); +} + +function goBack(mode: 'add' | 'edit'): void { + const s = getState(); + if (mode === 'edit' && s.selectedId && s.selectedItem) { + navigate('detail'); + } else { + navigate('list'); + } +} + +/// Normalize a URL input so the Rust-side `url::Url::parse` accepts it. +/// +/// Prepends `https://` when the input looks like a bare host (no scheme), +/// then validates via the JS URL constructor. Returns { ok, value, error }. +function normalizeUrl(raw: string): { ok: true; value: string } | { ok: false; error: string } { + if (!raw) return { ok: true, value: '' }; + const trimmed = raw.trim(); + // If it already has a scheme, pass through. Otherwise assume https://. + const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) + ? trimmed + : `https://${trimmed}`; + try { + const u = new URL(candidate); + // url::Url rejects schemes without an authority (host). Require a host. + if (!u.host) return { ok: false, error: 'URL must include a host (e.g. https://example.com)' }; + return { ok: true, value: u.toString() }; + } catch { + return { ok: false, error: 'URL is not valid — try something like https://example.com' }; + } +} + +async function saveLogin(mode: 'add' | 'edit', existing: Item | null): Promise { + const state = getState(); + + const title = (document.getElementById('f-title') as HTMLInputElement).value.trim(); + const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value; + const username = (document.getElementById('f-username') as HTMLInputElement).value.trim(); + const password = (document.getElementById('f-password') as HTMLInputElement).value; + const totpStr = (document.getElementById('f-totp') as HTMLInputElement).value.trim(); + const group = (document.getElementById('f-group') as HTMLInputElement).value.trim(); + const notes = (document.getElementById('f-notes') as HTMLTextAreaElement).value; + + if (!title) { + setState({ error: 'Title is required' }); + return; + } + + const urlResult = normalizeUrl(rawUrl); + if (!urlResult.ok) { + setState({ error: urlResult.error }); + return; + } + const url = urlResult.value; + + let totp: TotpConfig | undefined; + if (totpStr) { + try { + const bytes = base32Decode(totpStr); + totp = { + secret: Array.from(bytes), + algorithm: 'sha1', + digits: 6, + period_seconds: 30, + kind: 'totp', + }; + } catch (err) { + setState({ error: `Invalid TOTP secret: ${err instanceof Error ? err.message : String(err)}` }); + return; + } + } + + const now = Math.floor(Date.now() / 1000); + const core: LoginCore & { type: 'login' } = { + type: 'login', + username: username || undefined, + password: password || undefined, + url: url || undefined, + totp, + }; + + // Build the Item. On edit we preserve id/created/tags/favorite/sections/ + // attachments/field_history from the existing item, but we EXPLICITLY + // set trashed_at: undefined — never preserve stale trash state through + // an edit (carry-forward from Slice 5 review M3). + const item: Item = { + id: existing?.id ?? '', // SW fills in for add_item. + title, + type: 'login', + tags: existing?.tags ?? [], + favorite: existing?.favorite ?? false, + group: group || undefined, + notes: notes || undefined, + created: existing?.created ?? now, + modified: now, + trashed_at: undefined, + core, + sections: existing?.sections ?? [], + attachments: existing?.attachments ?? [], + field_history: existing?.field_history ?? {}, + }; + + setState({ loading: true, error: null }); + + let resp; + if (mode === 'add') { + resp = await sendMessage({ type: 'add_item', item }); + } else { + if (!state.selectedId) { + setState({ loading: false, error: 'Missing item id' }); + return; + } + resp = await sendMessage({ type: 'update_item', id: state.selectedId, item }); + } + + if (resp.ok) { + const listResp = await sendMessage({ type: 'list_items' }); + if (listResp.ok) { + const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + navigate('list', { entries: data.items, selectedId: null, selectedItem: null }); + } else { + navigate('list'); + } + } else { + setState({ loading: false, error: resp.error }); + } +} diff --git a/extension/src/popup/components/item-list.ts b/extension/src/popup/components/item-list.ts new file mode 100644 index 0000000..db62bd7 --- /dev/null +++ b/extension/src/popup/components/item-list.ts @@ -0,0 +1,217 @@ +/// Typed-item list view — toolbar (search, new, sync, lock, settings) + +/// type-iconed rows. Clicking a row fetches the full Item and navigates +/// to the detail view. + +import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; +import type { ItemId, ItemType, ManifestEntry, Item } from '../../shared/types'; + +/// Extract the display hostname from an icon_hint or fallback to the first tag. +function metaLine(e: ManifestEntry): string { + if (e.icon_hint) return e.icon_hint; + if (e.tags.length > 0) return e.tags.join(', '); + return ''; +} + +/// Emoji icon per item type. Placeholder until we ship real SVG icons. +function typeIcon(t: ItemType): 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 '⏱'; + } +} + +export function renderItemList(app: HTMLElement): void { + const state = getState(); + const filtered = getFilteredEntries(); + + const rowsHtml = filtered.length > 0 + ? filtered.map(([id, e], i) => ` +
+ ${escapeHtml(e.title)} + +
+ `).join('') + : '
no items
'; + + app.innerHTML = ` + +
+ + + + + +
+
+ ${rowsHtml} +
+
+ / search + + new + ↑↓ nav + Enter open +
+ `; + + // --- Event listeners --- + + const searchInput = document.getElementById('search-input') as HTMLInputElement | null; + searchInput?.addEventListener('input', () => { + setState({ searchQuery: searchInput.value, selectedIndex: 0 }); + }); + + document.getElementById('new-btn')?.addEventListener('click', () => navigate('add')); + + document.getElementById('sync-btn')?.addEventListener('click', async () => { + setState({ loading: true, error: null }); + const resp = await sendMessage({ type: 'sync' }); + if (resp.ok) { + const listResp = await sendMessage({ type: 'list_items' }); + if (listResp.ok) { + const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + setState({ entries: data.items, loading: false }); + return; + } + setState({ loading: false, error: listResp.error }); + } else { + setState({ loading: false, error: resp.error }); + } + }); + + document.getElementById('lock-btn')?.addEventListener('click', async () => { + await sendMessage({ type: 'lock' }); + navigate('locked'); + }); + + document.getElementById('settings-btn')?.addEventListener('click', () => navigate('settings')); + + // Item row clicks. + const rows = app.querySelectorAll('.entry-row'); + rows.forEach(row => { + row.addEventListener('click', async () => { + const id = (row as HTMLElement).dataset.id!; + document.removeEventListener('keydown', handleListKeydown); + await openItem(id); + }); + }); + + // Keyboard navigation. + document.addEventListener('keydown', handleListKeydown); + + // Focus search on open. + searchInput?.focus(); +} + +async function openItem(id: ItemId): Promise { + setState({ loading: true }); + const resp = await sendMessage({ type: 'get_item', id }); + if (resp.ok) { + const data = resp.data as { item: Item }; + navigate('detail', { + selectedId: id, + selectedItem: data.item, + }); + } else { + setState({ loading: false, error: resp.error }); + } +} + +/// Compute the visible (filtered) entry list from current state. +function getFilteredEntries(): Array<[ItemId, ManifestEntry]> { + const state = getState(); + // Hide trashed items from the main list. + let filtered = state.entries.filter(([, e]) => e.trashed_at === undefined || e.trashed_at === null); + if (state.searchQuery) { + const q = state.searchQuery.toLowerCase(); + filtered = filtered.filter(([, e]) => { + if (e.title.toLowerCase().includes(q)) return true; + if (e.icon_hint?.toLowerCase().includes(q)) return true; + if (e.group?.toLowerCase().includes(q)) return true; + if (e.tags.some((t) => t.toLowerCase().includes(q))) return true; + return false; + }); + } + filtered.sort((a, b) => a[1].title.localeCompare(b[1].title)); + return filtered; +} + +/// True if the event target is an editable field (input/textarea/contenteditable). +/// Global shortcut handlers should bail when the user is typing into a field — +/// otherwise printable characters like "/" and "+" get eaten by the shortcut +/// routing and never reach the input. +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + const tag = target.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; + if (target.isContentEditable) return true; + return false; +} + +function handleListKeydown(e: KeyboardEvent): void { + const state = getState(); + const target = e.target as HTMLElement; + const isSearch = target.id === 'search-input'; + + // If the user is typing into any input/textarea (other than the list's own + // search field, which we want to focus on "/" even from outside it), let the + // keystroke through. The "/" shortcut below is specifically "jump to search + // from the list," not "steal printable characters while typing." + if (isEditableTarget(target) && !isSearch) { + if (e.key === 'Escape') { + document.removeEventListener('keydown', handleListKeydown); + window.close(); + } + return; + } + + if (e.key === '/' && !isSearch) { + e.preventDefault(); + (document.getElementById('search-input') as HTMLInputElement | null)?.focus(); + return; + } + + if (e.key === '+' && !isSearch) { + e.preventDefault(); + document.removeEventListener('keydown', handleListKeydown); + navigate('add'); + return; + } + + const filtered = getFilteredEntries(); + + if (e.key === 'ArrowDown') { + e.preventDefault(); + const max = Math.max(filtered.length - 1, 0); + setState({ selectedIndex: Math.min(state.selectedIndex + 1, max) }); + return; + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + setState({ selectedIndex: Math.max(state.selectedIndex - 1, 0) }); + return; + } + + if (e.key === 'Enter' && !isSearch) { + e.preventDefault(); + const selected = filtered[state.selectedIndex]; + if (selected) { + document.removeEventListener('keydown', handleListKeydown); + void openItem(selected[0]); + } + return; + } + + if (e.key === 'Escape') { + document.removeEventListener('keydown', handleListKeydown); + window.close(); + return; + } +} diff --git a/extension/src/popup/components/settings.ts b/extension/src/popup/components/settings.ts index e5dcee0..a30ea03 100644 --- a/extension/src/popup/components/settings.ts +++ b/extension/src/popup/components/settings.ts @@ -1,7 +1,7 @@ /// Settings view — capture toggle, prompt style, and blacklist management. import { sendMessage, navigate, escapeHtml } from '../popup'; -import type { RelicarioSettings } from '../../shared/types'; +import type { DeviceSettings } from '../../shared/types'; export async function renderSettings(app: HTMLElement): Promise { app.innerHTML = '
'; @@ -12,8 +12,8 @@ export async function renderSettings(app: HTMLElement): Promise { sendMessage({ type: 'get_blacklist' }), ]); - const settings: RelicarioSettings = settingsResp.ok - ? (settingsResp.data as { settings: RelicarioSettings }).settings + const settings: DeviceSettings = settingsResp.ok + ? (settingsResp.data as { settings: DeviceSettings }).settings : { captureEnabled: false, captureStyle: 'bar' }; const blacklist: string[] = blacklistResp.ok diff --git a/extension/src/popup/components/setup-wizard.ts b/extension/src/popup/components/setup-wizard.ts deleted file mode 100644 index 5b9b2ad..0000000 --- a/extension/src/popup/components/setup-wizard.ts +++ /dev/null @@ -1,31 +0,0 @@ -/// Setup prompt — directs users to the full-page setup wizard. -/// -/// The popup is too constrained for file pickers and multi-step forms -/// (Chrome closes it when focus shifts). All real setup happens in -/// setup.html, which pushes config to chrome.storage.local when done. - -import { escapeHtml } from '../popup'; - -export function renderSetupWizard(app: HTMLElement): void { - app.innerHTML = ` -
- -
relicario
-

two-factor vault

- -

- No vault configured yet. Open the setup wizard to - create a new vault or connect to an existing one. -

- - -
- `; - - document.getElementById('open-setup-btn')?.addEventListener('click', () => { - chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') }); - window.close(); - }); -} diff --git a/extension/src/popup/components/unlock.ts b/extension/src/popup/components/unlock.ts index 6e3d387..2106ffc 100644 --- a/extension/src/popup/components/unlock.ts +++ b/extension/src/popup/components/unlock.ts @@ -1,7 +1,7 @@ /// Unlock view — passphrase input with ENTER to submit. import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; -import type { ManifestEntry } from '../../shared/types'; +import type { ItemId, ManifestEntry } from '../../shared/types'; export function renderUnlock(app: HTMLElement): void { const state = getState(); @@ -38,10 +38,10 @@ export function renderUnlock(app: HTMLElement): void { setState({ loading: true, error: null }); const resp = await sendMessage({ type: 'unlock', passphrase }); if (resp.ok) { - const listResp = await sendMessage({ type: 'list_entries' }); + const listResp = await sendMessage({ type: 'list_items' }); if (listResp.ok) { - const data = listResp.data as { entries: Array<[string, ManifestEntry]> }; - navigate('list', { entries: data.entries }); + const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + navigate('list', { entries: data.items }); } else { setState({ loading: false, error: listResp.error }); } diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index 036bca1..c562f5c 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -4,12 +4,11 @@ /// Navigation works by updating `currentState` and calling `render()`. import type { Request, Response } from '../shared/messages'; -import type { ManifestEntry, Entry } from '../shared/types'; +import type { ItemId, ManifestEntry, Item } from '../shared/types'; import { renderUnlock } from './components/unlock'; -import { renderEntryList } from './components/entry-list'; -import { renderEntryDetail } from './components/entry-detail'; -import { renderEntryForm } from './components/entry-form'; -import { renderSetupWizard } from './components/setup-wizard'; +import { renderItemList } from './components/item-list'; +import { renderItemDetail } from './components/item-detail'; +import { renderItemForm } from './components/item-form'; import { renderSettings } from './components/settings'; // --- Escape HTML to prevent XSS --- @@ -21,30 +20,38 @@ export function escapeHtml(str: string): string { // --- State --- -export type View = 'setup' | 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings'; +export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings'; export interface PopupState { view: View; - entries: Array<[string, ManifestEntry]>; - selectedId: string | null; - selectedEntry: Entry | null; + entries: Array<[ItemId, ManifestEntry]>; + selectedId: ItemId | null; + selectedItem: Item | null; selectedIndex: number; searchQuery: string; activeGroup: string | null; error: string | null; loading: boolean; + // Captured tab snapshot taken at popup-open. Used by fill_credentials + // to guard against TOCTOU navigation — the SW re-checks this URL's + // hostname against the tab's live URL before forwarding fill_credentials + // to the content script. See router/popup-only.ts#handleFillCredentials. + capturedTabId: number | null; + capturedUrl: string; } let currentState: PopupState = { view: 'locked', entries: [], selectedId: null, - selectedEntry: null, + selectedItem: null, selectedIndex: 0, searchQuery: '', activeGroup: null, error: null, loading: false, + capturedTabId: null, + capturedUrl: '', }; export function getState(): PopupState { @@ -61,11 +68,44 @@ export function setState(partial: Partial): void { export function sendMessage(request: Request): Promise { return new Promise((resolve) => { chrome.runtime.sendMessage(request, (response: Response) => { + if (response && !response.ok && response.error) { + // Replace cryptic low-level errors with user-readable messages. + response = { ok: false, error: humanizeError(response.error) }; + } resolve(response); }); }); } +/// Translate cryptic Rust/serde/WASM error strings into messages a user +/// can act on. Unknown errors pass through unchanged. +export function humanizeError(err: string): string { + // URL parse failures (Rust `url::Url::parse`) bubble up through serde + // as `item json: ...`. Match the core phrasing. + if (/relative URL without a base/i.test(err)) { + return 'URL must start with https:// or http:// (e.g. https://example.com)'; + } + if (/item json:/i.test(err)) { + return 'Could not save item — one of the fields is in an invalid format.'; + } + if (/settings json:/i.test(err)) { + return 'Settings are in an invalid format — try reloading the extension.'; + } + if (/vault_locked/i.test(err)) { + return 'Vault is locked. Unlock and try again.'; + } + if (/origin_mismatch/i.test(err)) { + return 'This login belongs to a different site — refusing to leak credentials cross-origin.'; + } + if (/unauthorized_sender/i.test(err)) { + return 'This action is not allowed from here.'; + } + if (/tab_navigated|captured_tab_gone/i.test(err)) { + return 'The browser tab changed before the fill could complete — try again.'; + } + return err; +} + // --- Navigation --- export function navigate(view: View, extras?: Partial): void { @@ -79,23 +119,20 @@ function render(): void { if (!app) return; switch (currentState.view) { - case 'setup': - renderSetupWizard(app); - break; case 'locked': renderUnlock(app); break; case 'list': - renderEntryList(app); + renderItemList(app); break; case 'detail': - renderEntryDetail(app); + renderItemDetail(app); break; case 'add': - renderEntryForm(app, 'add'); + renderItemForm(app, 'add'); break; case 'edit': - renderEntryForm(app, 'edit'); + renderItemForm(app, 'edit'); break; case 'settings': renderSettings(app); @@ -106,12 +143,20 @@ function render(): void { // --- Init --- async function init(): Promise { + // Snapshot the active tab at popup-open — the fill path uses this + // tabId/url pair so the SW can verify the tab hasn't navigated before + // forwarding credentials (audit M5 + TOCTOU close via expectedHost). + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + currentState.capturedTabId = tab?.id ?? null; + currentState.capturedUrl = tab?.url ?? ''; + // Check if extension is configured. const setupResp = await sendMessage({ type: 'get_setup_state' }); if (setupResp.ok) { const data = setupResp.data as { isConfigured: boolean }; if (!data.isConfigured) { - navigate('setup'); + await chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') }); + window.close(); return; } } @@ -122,10 +167,10 @@ async function init(): Promise { const data = unlockResp.data as { unlocked: boolean }; if (data.unlocked) { // Load entries and go to list. - const listResp = await sendMessage({ type: 'list_entries' }); + const listResp = await sendMessage({ type: 'list_items' }); if (listResp.ok) { - const listData = listResp.data as { entries: Array<[string, ManifestEntry]> }; - navigate('list', { entries: listData.entries }); + const listData = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + navigate('list', { entries: listData.items }); return; } } diff --git a/extension/src/service-worker/index.ts b/extension/src/service-worker/index.ts index 78f5ffa..64ec2e8 100644 --- a/extension/src/service-worker/index.ts +++ b/extension/src/service-worker/index.ts @@ -1,34 +1,11 @@ -/// Background script entry point for the relicario browser extension. -/// -/// In Chrome this runs as a service worker (MV3). In Firefox this runs -/// as a persistent background script. WASM loading adapts automatically. -/// -/// Loads the WASM module, manages vault state (master key, manifest, git host), -/// and routes all messages from the popup and content scripts. +/// 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 { Manifest, VaultConfig, SetupState, RelicarioSettings } from '../shared/types'; -import { DEFAULT_SETTINGS } from '../shared/types'; -import type { GitHost } from './git-host'; -import { createGitHost } from './git-host'; -import { base64ToUint8Array } from './git-host'; +import type { RouterState } from './router/index'; +import { route } from './router/index'; import * as vault from './vault'; -// --- State held in memory (cleared on lock or service worker restart) --- - -let masterKey: Uint8Array | null = null; -let manifest: Manifest | null = null; -let gitHost: GitHost | null = null; -let wasmReady = false; -// Cache TOTP secrets by entry ID to avoid re-fetching the entry every second -const totpSecretCache: Map = new Map(); - -// --- WASM initialization --- - -// Chrome MV3 uses service workers which do NOT support dynamic import(). -// Firefox MV3 uses background scripts which DO support dynamic import(). -// We detect the environment at runtime and use the appropriate loading strategy. - // @ts-ignore TS2307 — resolved by webpack alias / copy import initDefault, { initSync } from '../../wasm/relicario_wasm.js'; // @ts-ignore TS2307 @@ -46,396 +23,48 @@ async function initWasm(): Promise { && self instanceof (SWGlobalScope as unknown as typeof EventTarget); if (isServiceWorker) { - // Chrome: fetch WASM binary and instantiate synchronously const wasmResponse = await fetch(chrome.runtime.getURL('relicario_wasm_bg.wasm')); const wasmBytes = await wasmResponse.arrayBuffer(); initSync({ module: new WebAssembly.Module(wasmBytes) }); } else { - // Firefox: background script — async init works const wasmUrl = chrome.runtime.getURL('relicario_wasm_bg.wasm'); await initDefault(wasmUrl); } vault.setWasm(wasmBindings); wasm = wasmBindings; - wasmReady = true; return wasm; } -// --- Storage helpers --- - -async function loadConfig(): Promise { - const result = await chrome.storage.local.get('vaultConfig'); - return (result.vaultConfig as VaultConfig) ?? null; -} - -async function loadImageBase64(): Promise { - const result = await chrome.storage.local.get('imageBase64'); - return (result.imageBase64 as string) ?? null; -} - -async function loadSetupState(): Promise { - const config = await loadConfig(); - const imageBase64 = await loadImageBase64(); - return { - config, - imageBase64, - isConfigured: config !== null && imageBase64 !== null, - }; -} - -// --- Settings & blacklist helpers --- - -async function loadSettings(): Promise { - const result = await chrome.storage.local.get('relicarioSettings'); - return (result.relicarioSettings as RelicarioSettings) ?? { ...DEFAULT_SETTINGS }; -} - -async function saveSettings(settings: RelicarioSettings): Promise { - await chrome.storage.local.set({ relicarioSettings: settings }); -} - -async function loadBlacklist(): Promise { - const result = await chrome.storage.local.get('captureBlacklist'); - return (result.captureBlacklist as string[]) ?? []; -} - -async function saveBlacklist(list: string[]): Promise { - 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 --- +// 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: (response: Response) => void) => { - handleMessage(request) - .then(sendResponse) - .catch((err: Error) => sendResponse({ ok: false, error: err.message })); - // Return true to indicate async response. - return true; + (request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => { + (async () => { + 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((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 }, ); - -async function handleMessage(req: Request): Promise { - switch (req.type) { - // --- Auth --- - - case 'is_unlocked': - return { ok: true, data: { unlocked: masterKey !== 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 imageSecret = w.extract_image_secret(imageBytes); - - const git = ensureGitHost(config); - const meta = await vault.fetchVaultMeta(git); - - const key = w.derive_master_key( - req.passphrase, - new Uint8Array(imageSecret), - meta.salt, - meta.paramsJson, - ); - masterKey = new Uint8Array(key); - - // Verify the key works by decrypting the manifest. - manifest = await vault.fetchAndDecryptManifest(git, masterKey); - - return { ok: true }; - } - - case 'lock': - masterKey = null; - manifest = null; - totpSecretCache.clear(); - return { ok: true }; - - // --- Entries --- - - case 'list_entries': { - if (!manifest) return { ok: false, error: 'Vault is locked' }; - const entries = vault.listEntries(manifest, req.group); - return { ok: true, data: { entries } }; - } - - case 'get_entry': { - if (!masterKey || !gitHost) return { ok: false, error: 'Vault is locked' }; - const entry = await vault.fetchAndDecryptEntry(gitHost, masterKey, req.id); - return { ok: true, data: { entry } }; - } - - case 'search_entries': { - if (!manifest) return { ok: false, error: 'Vault is locked' }; - const entries = vault.searchEntries(manifest, req.query); - return { ok: true, data: { entries } }; - } - - case 'add_entry': { - if (!masterKey || !gitHost || !manifest) { - return { ok: false, error: 'Vault is locked' }; - } - const w = await initWasm(); - const id = w.generate_entry_id(); - - await vault.encryptAndWriteEntry( - gitHost, masterKey, id, req.entry, - `add: ${req.entry.name}`, - ); - - manifest.entries[id] = { - name: req.entry.name, - url: req.entry.url, - username: req.entry.username, - group: req.entry.group, - updated_at: req.entry.updated_at, - }; - - await vault.encryptAndWriteManifest( - gitHost, masterKey, manifest, - `manifest: add ${req.entry.name}`, - ); - - return { ok: true, data: { id } }; - } - - case 'update_entry': { - if (!masterKey || !gitHost || !manifest) { - return { ok: false, error: 'Vault is locked' }; - } - - await vault.encryptAndWriteEntry( - gitHost, masterKey, req.id, req.entry, - `update: ${req.entry.name}`, - ); - - manifest.entries[req.id] = { - name: req.entry.name, - url: req.entry.url, - username: req.entry.username, - group: req.entry.group, - updated_at: req.entry.updated_at, - }; - - await vault.encryptAndWriteManifest( - gitHost, masterKey, manifest, - `manifest: update ${req.entry.name}`, - ); - - return { ok: true }; - } - - case 'delete_entry': { - if (!masterKey || !gitHost || !manifest) { - return { ok: false, error: 'Vault is locked' }; - } - - const name = manifest.entries[req.id]?.name ?? req.id; - await gitHost.deleteFile(`entries/${req.id}.enc`, `delete: ${name}`); - - delete manifest.entries[req.id]; - - await vault.encryptAndWriteManifest( - gitHost, masterKey, manifest, - `manifest: delete ${name}`, - ); - - return { ok: true }; - } - - // --- TOTP --- - - case 'get_totp': { - if (!masterKey || !gitHost) return { ok: false, error: 'Vault is locked' }; - const w = await initWasm(); - - // Use cached TOTP secret to avoid re-fetching the entry every second - let totpSecret = totpSecretCache.get(req.id); - if (!totpSecret) { - const entry = await vault.fetchAndDecryptEntry(gitHost, masterKey, req.id); - if (!entry.totp_secret) return { ok: false, error: 'No TOTP secret for this entry' }; - totpSecret = entry.totp_secret; - totpSecretCache.set(req.id, totpSecret); - } - - const now = Math.floor(Date.now() / 1000); - const code = w.generate_totp(totpSecret, BigInt(now)); - const remaining = 30 - (now % 30); - - return { ok: true, data: { code, remaining_seconds: remaining } }; - } - - // --- Autofill --- - - case 'get_autofill_candidates': { - if (!manifest) return { ok: false, error: 'Vault is locked' }; - const candidates = vault.findByUrl(manifest, req.url); - return { ok: true, data: { candidates } }; - } - - case 'get_credentials': { - if (!masterKey || !gitHost) return { ok: false, error: 'Vault is locked' }; - const entry = await vault.fetchAndDecryptEntry(gitHost, masterKey, req.id); - return { - ok: true, - data: { username: entry.username ?? '', password: entry.password }, - }; - } - - // --- Sync --- - - case 'sync': { - if (!masterKey || !gitHost) return { ok: false, error: 'Vault is locked' }; - // Re-fetch the manifest from the remote to pick up changes from other devices. - manifest = await vault.fetchAndDecryptManifest(gitHost, masterKey); - return { ok: true }; - } - - // --- Setup --- - - case 'get_setup_state': { - const state = await loadSetupState(); - return { ok: true, data: state }; - } - - case 'save_setup': { - await chrome.storage.local.set({ - vaultConfig: req.config, - imageBase64: req.imageBase64, - }); - // Reset git host so it picks up new config on next use. - gitHost = null; - return { ok: true }; - } - - // --- Password generation --- - - case 'generate_password': { - const w = await initWasm(); - const password = w.generate_password(req.length); - return { ok: true, data: { password } }; - } - - // --- Content script fill (forwarded to active tab) --- - - case 'fill_credentials': { - // This is actually sent TO the content script, not FROM it. - // The popup sends this to the service worker, which forwards it. - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - if (tab?.id) { - await chrome.tabs.sendMessage(tab.id, { - type: 'fill_credentials', - username: req.username, - password: req.password, - }); - } - return { ok: true }; - } - - // --- Settings & blacklist --- - - case 'get_settings': { - const settings = await loadSettings(); - return { ok: true, data: { settings } }; - } - - case 'update_settings': { - const current = await loadSettings(); - const updated = { ...current, ...req.settings }; - await saveSettings(updated); - return { ok: true }; - } - - case 'get_blacklist': { - const blacklist = await loadBlacklist(); - return { ok: true, data: { blacklist } }; - } - - case 'remove_blacklist': { - const bl = await loadBlacklist(); - await saveBlacklist(bl.filter((h) => h !== req.hostname)); - return { ok: true }; - } - - case 'blacklist_site': { - const bl2 = await loadBlacklist(); - if (!bl2.includes(req.hostname)) { - bl2.push(req.hostname); - await saveBlacklist(bl2); - } - return { ok: true }; - } - - // --- Credential capture --- - - case 'check_credential': { - // Skip if vault locked - if (!masterKey || !gitHost || !manifest) { - return { ok: true, data: { action: 'skip' } }; - } - - // Skip if capture disabled - const captureSettings = await loadSettings(); - if (!captureSettings.captureEnabled) { - return { ok: true, data: { action: 'skip' } }; - } - - // Skip if hostname blacklisted - let checkHostname: string; - try { - checkHostname = new URL(req.url).hostname; - } catch { - return { ok: true, data: { action: 'skip' } }; - } - - const captureBlacklist = await loadBlacklist(); - if (captureBlacklist.includes(checkHostname)) { - return { ok: true, data: { action: 'skip' } }; - } - - // Search manifest by hostname - const candidates = vault.findByUrl(manifest, req.url); - - if (candidates.length === 0) { - return { ok: true, data: { action: 'save' } }; - } - - // Check for matching username - for (const [entryId, entry] of candidates) { - if (entry.username === req.username) { - // Same hostname + username — compare passwords - try { - const fullEntry = await vault.fetchAndDecryptEntry(gitHost, masterKey, entryId); - if (fullEntry.password === req.password) { - return { ok: true, data: { action: 'skip' } }; - } else { - return { ok: true, data: { action: 'update', entryId, entryName: entry.name } }; - } - } catch { - // If we can't decrypt, skip rather than error - return { ok: true, data: { action: 'skip' } }; - } - } - } - - // Same hostname, different username — new account - return { ok: true, data: { action: 'save' } }; - } - - default: - return { ok: false, error: `Unknown message type: ${(req as { type: string }).type}` }; - } -} diff --git a/extension/src/service-worker/router/__tests__/router.test.ts b/extension/src/service-worker/router/__tests__/router.test.ts new file mode 100644 index 0000000..79cdb5a --- /dev/null +++ b/extension/src/service-worker/router/__tests__/router.test.ts @@ -0,0 +1,524 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// --- Mocks (must be declared before `route` is imported so the router's +// `import * as vault` / `import * as session` resolve to these doubles) --- + +// Partial mock: we override only the vault calls the new tests care about +// (fetchAndDecryptItem / fetchAndDecryptSettings / encryptAndWriteSettings) +// and let the real implementations of listItems / findByHostname / etc. +// continue to run for the other tests that don't need mocks. +vi.mock('../../vault', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchAndDecryptItem: vi.fn(), + fetchAndDecryptSettings: vi.fn(), + encryptAndWriteSettings: vi.fn(), + encryptAndWriteItem: vi.fn(), + encryptAndWriteManifest: vi.fn(), + }; +}); + +vi.mock('../../session', () => ({ + setCurrent: vi.fn(), + getCurrent: vi.fn(), + clearCurrent: vi.fn(), + requireCurrent: vi.fn(), +})); + +import { route, type RouterState } from '../index'; +import type { Request } from '../../../shared/messages'; +import type { Item } from '../../../shared/types'; +import * as vault from '../../vault'; +import * as session from '../../session'; + +// --- 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'); + } + }); +}); + +// --- fill_credentials TOCTOU + origin verification --- + +describe('fill_credentials captured-tab verification', () => { + const FAKE_ITEM_ID = 'cccccccccccccccc'; + + function loginItem(url: string): Item { + return { + id: FAKE_ITEM_ID, + title: 'Example', + type: 'login', + tags: [], + favorite: false, + created: 0, + modified: 0, + core: { type: 'login', username: 'alice', password: 'hunter2', url }, + sections: [], + attachments: [], + field_history: {}, + }; + } + + function primeUnlocked(state: RouterState): void { + // Provide a fake handle + githost so the handler's "vault_locked" guard + // passes — values don't matter because vault is mocked. + vi.mocked(session.getCurrent).mockReturnValue({ free: () => {} } as never); + state.gitHost = {} as never; + } + + beforeEach(() => { + vi.mocked(session.getCurrent).mockReset(); + vi.mocked(vault.fetchAndDecryptItem).mockReset(); + (chrome.tabs.get as ReturnType).mockReset(); + (chrome.tabs.sendMessage as ReturnType).mockReset(); + }); + + it('returns tab_navigated when captured tab hostname differs from current', async () => { + const state = makeState(); + primeUnlocked(state); + // chrome.tabs.get returns a tab that has navigated to a DIFFERENT host. + (chrome.tabs.get as ReturnType).mockResolvedValue({ + id: 42, + url: 'https://evil.example/landing', + }); + + const res = await route( + { + type: 'fill_credentials', + id: FAKE_ITEM_ID, + capturedTabId: 42, + capturedUrl: 'https://example.com/login', + }, + state, + makePopupSender(), + ); + expect(res).toEqual({ ok: false, error: 'tab_navigated' }); + // We must NOT have attempted to deliver credentials. + expect(chrome.tabs.sendMessage).not.toHaveBeenCalled(); + }); + + it('returns origin_mismatch when item hostname differs from current tab', async () => { + const state = makeState(); + primeUnlocked(state); + // Tab is still on example.com (matches capturedUrl) … + (chrome.tabs.get as ReturnType).mockResolvedValue({ + id: 42, + url: 'https://example.com/login', + }); + // … but the item we'd fill belongs to github.com. + vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue( + loginItem('https://github.com/login'), + ); + + const res = await route( + { + type: 'fill_credentials', + id: FAKE_ITEM_ID, + capturedTabId: 42, + capturedUrl: 'https://example.com/login', + }, + state, + makePopupSender(), + ); + expect(res).toEqual({ ok: false, error: 'origin_mismatch' }); + expect(chrome.tabs.sendMessage).not.toHaveBeenCalled(); + }); + + it('forwards fill_credentials with expectedHost when all checks pass', async () => { + const state = makeState(); + primeUnlocked(state); + (chrome.tabs.get as ReturnType).mockResolvedValue({ + id: 42, + url: 'https://example.com/login', + }); + vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue( + loginItem('https://example.com/login'), + ); + (chrome.tabs.sendMessage as ReturnType).mockResolvedValue({ ok: true }); + + const res = await route( + { + type: 'fill_credentials', + id: FAKE_ITEM_ID, + capturedTabId: 42, + capturedUrl: 'https://example.com/login', + }, + state, + makePopupSender(), + ); + expect(res).toEqual({ ok: true }); + expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(42, { + type: 'fill_credentials', + username: 'alice', + password: 'hunter2', + expectedHost: 'example.com', + }); + }); +}); + +// --- 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('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( + { + type: 'fill_credentials', + id: 'cccccccccccccccc', + capturedTabId: 42, + capturedUrl: 'https://example.com/', + }, + state, + makeSetupSender(), + ); + 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 --- + +describe('isContent sender.id guard', () => { + it('rejects content-shaped sender whose id is not the extension id', async () => { + const state = makeState(); + const sender: chrome.runtime.MessageSender = { + tab: { id: 42, url: 'https://example.com/' } as chrome.tabs.Tab, + frameId: 0, + id: 'some-other-extension', // NOT chrome.runtime.id + }; + const res = await route({ type: 'get_autofill_candidates' }, state, sender); + expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); +}); + +// --- capture_save_login (content-callable, origin-bound) --- + +describe('capture_save_login', () => { + const EXISTING_ID = 'dddddddddddddddd'; + + function loginItem(url: string, username: string, password: string): Item { + return { + id: EXISTING_ID, + title: 'Example', + type: 'login', + tags: [], + favorite: false, + created: 0, + modified: 0, + core: { type: 'login', username, password, url }, + sections: [], + attachments: [], + field_history: {}, + }; + } + + function primeUnlocked(state: RouterState): void { + vi.mocked(session.getCurrent).mockReturnValue({ free: () => {} } as never); + state.gitHost = {} as never; + } + + beforeEach(() => { + vi.mocked(session.getCurrent).mockReset(); + vi.mocked(vault.fetchAndDecryptItem).mockReset(); + vi.mocked(vault.encryptAndWriteItem).mockReset(); + vi.mocked(vault.encryptAndWriteManifest).mockReset(); + vi.mocked(vault.encryptAndWriteItem).mockResolvedValue(undefined); + vi.mocked(vault.encryptAndWriteManifest).mockResolvedValue(undefined); + }); + + it('accepts capture_save_login from top-frame content', async () => { + const state = makeState(); + primeUnlocked(state); + const res = await route( + { type: 'capture_save_login', username: 'alice', password: 'hunter2' }, + state, + makeContentSender('https://example.com/login'), + ); + expect(res.ok).toBe(true); + }); + + it('rejects capture_save_login from popup', async () => { + const state = makeState(); + primeUnlocked(state); + const res = await route( + { type: 'capture_save_login', username: 'alice', password: 'hunter2' }, + state, + makePopupSender(), + ); + expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); + + it('update path: existing (host, username) match rotates the password', async () => { + const state = makeState(); + primeUnlocked(state); + // Seed manifest with a login for example.com. + state.manifest = { + schema_version: 2, + items: { + [EXISTING_ID]: { + id: EXISTING_ID, type: 'login', title: 'Example', + tags: [], favorite: false, icon_hint: 'example.com', + modified: 0, attachment_summaries: [], + }, + }, + }; + vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue( + loginItem('https://example.com/', 'alice', 'oldpass'), + ); + + const res = await route( + { type: 'capture_save_login', username: 'alice', password: 'newpass' }, + state, + makeContentSender('https://example.com/login'), + ); + expect(res).toMatchObject({ ok: true, data: { action: 'updated', id: EXISTING_ID } }); + // Verify write was invoked with a core whose password is the new one. + expect(vault.encryptAndWriteItem).toHaveBeenCalledTimes(1); + const writtenItem = vi.mocked(vault.encryptAndWriteItem).mock.calls[0][3]; + expect(writtenItem.id).toBe(EXISTING_ID); + if (writtenItem.core.type !== 'login') throw new Error('expected login core'); + expect(writtenItem.core.password).toBe('newpass'); + expect(writtenItem.core.username).toBe('alice'); + }); + + it('add path: no match creates a new item bound to senderHost', async () => { + const state = makeState(); + primeUnlocked(state); + // Empty manifest — no candidates. + state.manifest = { schema_version: 2, items: {} }; + + const res = await route( + { type: 'capture_save_login', username: 'bob', password: 's3cret' }, + state, + makeContentSender('https://example.com/signup'), + ); + expect(res.ok).toBe(true); + if (res.ok) { + const data = res.data as { action: string; id: string }; + expect(data.action).toBe('added'); + expect(data.id).toBe('fakeitemid0000ab'); // from stub new_item_id() + } + expect(vault.encryptAndWriteItem).toHaveBeenCalledTimes(1); + const newItem = vi.mocked(vault.encryptAndWriteItem).mock.calls[0][3]; + expect(newItem.title).toBe('example.com'); + if (newItem.core.type !== 'login') throw new Error('expected login core'); + expect(newItem.core.url).toBe('https://example.com'); + expect(newItem.core.username).toBe('bob'); + expect(newItem.core.password).toBe('s3cret'); + // Manifest entry should have been added too. + expect(state.manifest!.items['fakeitemid0000ab']).toBeDefined(); + }); + + it('origin_mismatch when existing item for same username has a different host', async () => { + const state = makeState(); + primeUnlocked(state); + // Manifest says there's a match for example.com (icon_hint), but the + // underlying item actually belongs to github.com — defense-in-depth + // check should reject. + state.manifest = { + schema_version: 2, + items: { + [EXISTING_ID]: { + id: EXISTING_ID, type: 'login', title: 'Example', + tags: [], favorite: false, icon_hint: 'example.com', + modified: 0, attachment_summaries: [], + }, + }, + }; + vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue( + loginItem('https://github.com/', 'alice', 'oldpass'), + ); + + const res = await route( + { type: 'capture_save_login', username: 'alice', password: 'newpass' }, + state, + makeContentSender('https://example.com/login'), + ); + expect(res).toEqual({ ok: false, error: 'origin_mismatch' }); + expect(vault.encryptAndWriteItem).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/service-worker/router/content-callable.ts b/extension/src/service-worker/router/content-callable.ts new file mode 100644 index 0000000..73143b5 --- /dev/null +++ b/extension/src/service-worker/router/content-callable.ts @@ -0,0 +1,204 @@ +/// 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 { Item, 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; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + wasm: any; +} + +export async function handle( + msg: ContentMessage, + state: ContentState, + sender: chrome.runtime.MessageSender, +): Promise { + 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 }; + } + + case 'capture_save_login': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; + + // Look for an existing login for this origin + username. Origin is + // always senderHost (derived from sender.tab.url by the router) — the + // content script cannot influence which host we bind to. + const candidates = vault.findByHostname(state.manifest, senderHost); + for (const [id, entry] of candidates) { + if (entry.type !== 'login') continue; + const full = await vault.fetchAndDecryptItem(state.gitHost, handle, id); + if (full.core.type !== 'login') continue; + if (full.core.username === msg.username) { + // Defense in depth: verify the existing item's own URL hostname + // matches senderHost. If it doesn't (e.g. manifest icon_hint + // drifted from core.url), refuse to mutate — updating here would + // silently bind a password to the wrong origin. + const existingHost = safeHostname(full.core.url ?? ''); + if (existingHost !== senderHost) return { ok: false, error: 'origin_mismatch' }; + + // Update only the password field + modified timestamp. + const updated: Item = { + ...full, + modified: Math.floor(Date.now() / 1000), + core: { ...full.core, password: msg.password }, + }; + await vault.encryptAndWriteItem(state.gitHost, handle, id, updated, `capture: update ${existingHost}`); + state.manifest.items[id] = itemToManifestEntry(updated); + await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: update ${existingHost}`); + return { ok: true, data: { action: 'updated', id } }; + } + } + + // No match → create a new Login item bound to senderHost. Title + // defaults to the hostname; url is the sender's full origin when we + // have it, otherwise derived from senderHost. + const now = Math.floor(Date.now() / 1000); + const newId = state.wasm.new_item_id(); + const senderOrigin = (() => { + try { return sender.tab?.url ? new URL(sender.tab.url).origin : `https://${senderHost}`; } + catch { return `https://${senderHost}`; } + })(); + const item: Item = { + id: newId, + title: senderHost, + type: 'login', + tags: [], + favorite: false, + created: now, + modified: now, + core: { + type: 'login', + username: msg.username, + password: msg.password, + url: senderOrigin, + }, + sections: [], + attachments: [], + field_history: {}, + }; + await vault.encryptAndWriteItem(state.gitHost, handle, newId, item, `capture: add ${senderHost}`); + state.manifest.items[newId] = itemToManifestEntry(item); + await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: add ${senderHost}`); + return { ok: true, data: { action: 'added', id: newId } }; + } + } +} + +// --- Manifest entry derivation (duplicated from popup-only for self-containment) --- + +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, + })), + }; +} + +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 { + const r = await chrome.storage.local.get('captureBlacklist'); + return (r.captureBlacklist as string[]) ?? []; +} + +async function saveBlacklist(list: string[]): Promise { + await chrome.storage.local.set({ captureBlacklist: list }); +} + +function safeHostname(url: string): string | undefined { + try { return new URL(url).hostname; } catch { return undefined; } +} diff --git a/extension/src/service-worker/router/index.ts b/extension/src/service-worker/router/index.ts new file mode 100644 index 0000000..50cbd39 --- /dev/null +++ b/extension/src/service-worker/router/index.ts @@ -0,0 +1,70 @@ +/// 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 { 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'; +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; +} + +/// 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, + sender: chrome.runtime.MessageSender, +): Promise { + 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)) { + 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) { + // 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/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts new file mode 100644 index 0000000..b71f9d0 --- /dev/null +++ b/extension/src/service-worker/router/popup-only.ts @@ -0,0 +1,273 @@ +/// 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 { + 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, + state: PopupState, +): Promise { + 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' }; + + // Pass the hostname the SW validated. The content script re-verifies + // against location.href before filling — if the tab navigated between + // our chrome.tabs.get check above and the sendMessage delivery below, + // fill.ts rejects with 'origin_changed'. + await chrome.tabs.sendMessage(msg.capturedTabId, { + type: 'fill_credentials', + username: item.core.username ?? '', + password: item.core.password ?? '', + expectedHost: currentHost, + }); + return { ok: true }; +} + +// --- chrome.storage.local helpers (module-scoped so all handlers share) --- + +async function loadConfig(): Promise { + const r = await chrome.storage.local.get('vaultConfig'); + return (r.vaultConfig as VaultConfig) ?? null; +} + +async function loadImageBase64(): Promise { + const r = await chrome.storage.local.get('imageBase64'); + return (r.imageBase64 as string) ?? null; +} + +async function loadSetupState(): Promise { + const config = await loadConfig(); + const imageBase64 = await loadImageBase64(); + return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null }; +} + +async function loadDeviceSettings(): Promise { + const r = await chrome.storage.local.get('relicarioSettings'); + return (r.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS }; +} + +async function saveDeviceSettings(s: DeviceSettings): Promise { + await chrome.storage.local.set({ relicarioSettings: s }); +} + +async function loadBlacklist(): Promise { + const r = await chrome.storage.local.get('captureBlacklist'); + return (r.captureBlacklist as string[]) ?? []; +} + +async function saveBlacklist(list: string[]): Promise { + 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; } +} diff --git a/extension/src/service-worker/session.ts b/extension/src/service-worker/session.ts new file mode 100644 index 0000000..89d0992 --- /dev/null +++ b/extension/src/service-worker/session.ts @@ -0,0 +1,28 @@ +/// 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. +/// +/// Future multi-vault (β+) would replace `current` with +/// `Map` and thread `vaultId` through every +/// handler. Deliberate α simplicity — not an oversight. + +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; +} diff --git a/extension/src/service-worker/vault.ts b/extension/src/service-worker/vault.ts index cb1bc65..044ede8 100644 --- a/extension/src/service-worker/vault.ts +++ b/extension/src/service-worker/vault.ts @@ -1,34 +1,26 @@ -/// Vault operations module. -/// -/// Bridges the WASM crypto functions with the git host API to provide -/// high-level vault operations: fetch/decrypt manifest, fetch/decrypt entries, -/// encrypt/write entries, search, and URL matching. +/// 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 { Entry, Manifest, ManifestEntry } from '../shared/types'; +import type { Item, ItemId, Manifest, ManifestEntry, VaultSettings } from '../shared/types'; -// WASM module reference — set once during init. // eslint-disable-next-line @typescript-eslint/no-explicit-any let wasm: any = null; -/// Store the WASM module reference after initialization. // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function setWasm(w: any): void { - wasm = w; -} +export function setWasm(w: any): void { wasm = w; } function requireWasm(): any { if (!wasm) throw new Error('WASM module not initialized'); return wasm; } -/// Vault metadata: salt and KDF params stored unencrypted in the repo. export interface VaultMeta { salt: Uint8Array; paramsJson: string; } -/// Read the vault salt and KDF params from the git repo. export async function fetchVaultMeta(git: GitHost): Promise { const saltBytes = await git.readFile('.relicario/salt'); const paramsRaw = await git.readFile('.relicario/params.json'); @@ -36,102 +28,115 @@ export async function fetchVaultMeta(git: GitHost): Promise { return { salt: saltBytes, paramsJson }; } -/// Fetch and decrypt the manifest from the git repo. +// --- Manifest --- + export async function fetchAndDecryptManifest( git: GitHost, - masterKey: Uint8Array, + handle: SessionHandle, ): Promise { const w = requireWasm(); const ciphertext = await git.readFile('manifest.enc'); - const json = w.decrypt_manifest(ciphertext, masterKey); - return JSON.parse(json) as Manifest; + return w.manifest_decrypt(handle, ciphertext) as Manifest; } -/// Fetch and decrypt a single entry from the git repo. -export async function fetchAndDecryptEntry( - git: GitHost, - masterKey: Uint8Array, - id: string, -): Promise { - const w = requireWasm(); - const ciphertext = await git.readFile(`entries/${id}.enc`); - const json = w.decrypt_entry(ciphertext, masterKey); - return JSON.parse(json) as Entry; -} - -/// Encrypt an entry and write it to the git repo. -export async function encryptAndWriteEntry( - git: GitHost, - masterKey: Uint8Array, - id: string, - entry: Entry, - message: string, -): Promise { - const w = requireWasm(); - const entryJson = JSON.stringify(entry); - const ciphertext = w.encrypt_entry(entryJson, masterKey); - await git.writeFile(`entries/${id}.enc`, ciphertext, message); -} - -/// Encrypt the manifest and write it to the git repo. export async function encryptAndWriteManifest( git: GitHost, - masterKey: Uint8Array, + handle: SessionHandle, manifest: Manifest, message: string, ): Promise { const w = requireWasm(); - const manifestJson = JSON.stringify(manifest); - const ciphertext = w.encrypt_manifest(manifestJson, masterKey); + const ciphertext = w.manifest_encrypt(handle, JSON.stringify(manifest)); await git.writeFile('manifest.enc', ciphertext, message); } -/// Filter manifest entries by group (case-insensitive). If no group given, returns all. -export function listEntries( +// --- Items --- + +export async function fetchAndDecryptItem( + git: GitHost, + handle: SessionHandle, + id: ItemId, +): Promise { + 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 { + 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 { + 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 { + 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<[string, ManifestEntry]> { - const entries = Object.entries(manifest.entries); - if (!group) return entries; +): 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 entries.filter(([, e]) => - e.group?.toLowerCase() === g - ); + return live.filter(([, e]) => e.group?.toLowerCase() === g); } -/// Case-insensitive substring search on name, url, and username. -export function searchEntries( +export function searchItems( manifest: Manifest, query: string, -): Array<[string, ManifestEntry]> { +): Array<[ItemId, ManifestEntry]> { const q = query.toLowerCase(); - return Object.entries(manifest.entries).filter(([, e]) => { - if (e.name.toLowerCase().includes(q)) return true; - if (e.url?.toLowerCase().includes(q)) return true; - if (e.username?.toLowerCase().includes(q)) return true; - return false; - }); -} - -/// Find entries whose URL matches the given page URL by hostname. -export function findByUrl( - manifest: Manifest, - url: string, -): Array<[string, ManifestEntry]> { - let hostname: string; - try { - hostname = new URL(url).hostname; - } catch { - return []; - } - - return Object.entries(manifest.entries).filter(([, e]) => { - if (!e.url) return false; - try { - const entryHost = new URL(e.url).hostname; - return entryHost === hostname; - } catch { + 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; - } - }); + }); +} + +/// Match manifest entries against a page hostname. +/// +/// icon_hint is derived by the Rust core (crates/relicario-core/src/manifest.rs) +/// from LoginCore.url's hostname, so equality on icon_hint is the cheapest match. +/// α is intentionally coarse: no www.-stripping, no public-suffix matching +/// (`www.github.com` saved items will not match `github.com`, and vice versa). +/// Tighter matching is a 1C-β/γ concern. +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); } diff --git a/extension/src/setup/setup.ts b/extension/src/setup/setup.ts index 61ac574..218c554 100644 --- a/extension/src/setup/setup.ts +++ b/extension/src/setup/setup.ts @@ -6,7 +6,6 @@ /// Step 4: Finish (download reference image, push config to extension or copy JSON) import { createGitHost, uint8ArrayToBase64 } from '../service-worker/git-host'; -import type { GitHost } from '../service-worker/git-host'; import type { VaultConfig } from '../shared/types'; // --- WASM module (loaded dynamically) --- @@ -37,6 +36,11 @@ interface WizardState { carrierImageBytes: Uint8Array | null; passphrase: string; passphraseConfirm: string; + // zxcvbn meter state — -1 means "not yet scored" (empty passphrase). + passphraseScore: number; + passphraseGuessesLog10: number; // -1 before first rating + passphraseVisible: boolean; + confirmVisible: boolean; referenceImageBytes: Uint8Array | null; creating: boolean; error: string | null; @@ -54,6 +58,10 @@ const state: WizardState = { carrierImageBytes: null, passphrase: '', passphraseConfirm: '', + passphraseScore: -1, + passphraseGuessesLog10: -1, + passphraseVisible: false, + confirmVisible: false, referenceImageBytes: null, creating: false, error: null, @@ -71,17 +79,138 @@ function escapeHtml(s: string): string { .replace(/"/g, '"'); } -function passphraseStrength(pw: string): 'weak' | 'fair' | 'good' | 'strong' { - let score = 0; - if (pw.length >= 8) score++; - if (pw.length >= 14) score++; - if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++; - if (/[0-9]/.test(pw)) score++; - if (/[^a-zA-Z0-9]/.test(pw)) score++; - if (score <= 1) return 'weak'; - if (score <= 2) return 'fair'; - if (score <= 3) return 'good'; - return 'strong'; +interface Strength { score: number; guessesLog10: number } + +/// Call the SW to score a passphrase with zxcvbn. Returns score in [0, 4] +/// and guesses_log10, or -1 on both if the round-trip failed. +function ratePassphrase(passphrase: string): Promise { + return new Promise((resolve) => { + try { + chrome.runtime.sendMessage( + { type: 'rate_passphrase', passphrase }, + (response: { ok: boolean; data?: { score: number; guesses_log10: number }; error?: string }) => { + if (chrome.runtime.lastError) { + // eslint-disable-next-line no-console + console.warn('[relicario setup] rate_passphrase lastError:', chrome.runtime.lastError); + resolve({ score: -1, guessesLog10: -1 }); return; + } + if (!response?.ok) { + // eslint-disable-next-line no-console + console.warn('[relicario setup] rate_passphrase rejected by SW:', response); + resolve({ score: -1, guessesLog10: -1 }); return; + } + resolve({ + score: response.data?.score ?? -1, + guessesLog10: response.data?.guesses_log10 ?? -1, + }); + }, + ); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('[relicario setup] rate_passphrase threw:', err); + resolve({ score: -1, guessesLog10: -1 }); + } + }); +} + +/// 150ms debounce around the rate_passphrase call so we don't hammer the SW +/// on every keystroke. The last invocation wins. +let rateDebounceTimer: ReturnType | null = null; +function scheduleRate(passphrase: string, onResult: (s: Strength) => void): void { + if (rateDebounceTimer !== null) clearTimeout(rateDebounceTimer); + rateDebounceTimer = setTimeout(async () => { + rateDebounceTimer = null; + if (!passphrase) { onResult({ score: -1, guessesLog10: -1 }); return; } + onResult(await ratePassphrase(passphrase)); + }, 150); +} + +const STRENGTH_LABELS: Record = { + 0: { text: 'very weak', cls: 's-very-weak' }, + 1: { text: 'weak', cls: 's-weak' }, + 2: { text: 'fair', cls: 's-fair' }, + 3: { text: 'good', cls: 's-good' }, + 4: { text: 'strong', cls: 's-strong' }, +}; + +/// Render the entropy readout as "~10^N guesses to crack" or a friendlier +/// shorthand for large values. Returns empty string when no data. +function entropyText(guessesLog10: number): string { + if (guessesLog10 < 0) return ''; + const rounded = Math.round(guessesLog10); + if (rounded < 6) return `~10^${rounded} guesses — trivially crackable`; + if (rounded < 9) return `~10^${rounded} guesses — minutes on a single GPU`; + if (rounded < 12) return `~10^${rounded} guesses — hours to days on a GPU`; + if (rounded < 15) return `~10^${rounded} guesses — years on consumer hardware`; + if (rounded < 20) return `~10^${rounded} guesses — beyond consumer-hardware reach`; + return `~10^${rounded} guesses — effectively uncrackable`; +} + +/// Update just the meter DOM without a full re-render (so the input keeps +/// focus and the user's cursor position is preserved). Also updates the +/// char counter and confirm-match indicator live. +function updateStrengthUi(): void { + const bar = document.getElementById('strength-bar'); + const label = document.getElementById('strength-label'); + const entropy = document.getElementById('entropy-line'); + const counter = document.getElementById('passphrase-counter'); + const matchInd = document.getElementById('match-indicator'); + const create = document.getElementById('create-btn') as HTMLButtonElement | null; + + const score = state.passphraseScore; + const guessesLog10 = state.passphraseGuessesLog10; + + if (bar) bar.className = `strength-bar${score >= 0 ? ` s${score}` : ''}`; + + if (label) { + if (score < 0) { + label.className = 'strength-label'; + label.innerHTML = ' '; + } else { + const meta = STRENGTH_LABELS[score] ?? STRENGTH_LABELS[0]; + label.className = `strength-label ${meta.cls}`; + label.textContent = meta.text; + } + } + + if (entropy) { + const txt = entropyText(guessesLog10); + entropy.textContent = txt; + entropy.style.visibility = txt ? 'visible' : 'hidden'; + } + + if (counter) { + const n = state.passphrase.length; + counter.textContent = n === 0 ? '' : `${n} character${n === 1 ? '' : 's'}`; + } + + if (matchInd) { + const p = state.passphrase; + const c = state.passphraseConfirm; + if (!p || !c) { + matchInd.className = 'match-indicator'; + matchInd.textContent = ''; + } else if (p === c) { + matchInd.className = 'match-indicator ok'; + matchInd.textContent = '✓'; + } else { + matchInd.className = 'match-indicator bad'; + matchInd.textContent = '✗'; + } + } + + const matchOk = !state.passphraseConfirm || state.passphrase === state.passphraseConfirm; + if (create) { + const disabled = state.creating || score < 3 || !state.passphraseConfirm || !matchOk; + create.disabled = disabled; + create.title = disabled + ? (score < 3 + ? 'passphrase must score "good" or better' + : !state.passphraseConfirm ? 'confirm your passphrase' + : !matchOk ? 'passphrases do not match' + : '') + : ''; + } } // --- Render --- @@ -266,11 +395,35 @@ function attachStep2(): void { // --- Step 3: Create Vault --- function renderStep3(): string { - const strength = state.passphrase ? passphraseStrength(state.passphrase) : null; + const score = state.passphraseScore; + const guessesLog10 = state.passphraseGuessesLog10; + const hasScore = score >= 0; + const meterClass = hasScore ? `s${score}` : ''; + const labelMeta = hasScore ? STRENGTH_LABELS[score] : null; + const labelClass = labelMeta?.cls ?? ''; + const labelText = labelMeta?.text ?? ' '; + const entropy = entropyText(guessesLog10); + + const p = state.passphrase; + const c = state.passphraseConfirm; + const matchState = !p || !c ? '' : p === c ? 'ok' : 'bad'; + const matchGlyph = matchState === 'ok' ? '✓' : matchState === 'bad' ? '✗' : ''; + + const pType = state.passphraseVisible ? 'text' : 'password'; + const cType = state.confirmVisible ? 'text' : 'password'; + const pToggle = state.passphraseVisible ? 'hide' : 'show'; + const cToggle = state.confirmVisible ? 'hide' : 'show'; + + const matchOk = !c || p === c; + const gateDisabled = state.creating || score < 3 || !c || !matchOk; + + const nChars = p.length; + const counterText = nChars === 0 ? '' : `${nChars} character${nChars === 1 ? '' : 's'}`; return `

create vault

+
@@ -281,23 +434,44 @@ function renderStep3(): string {

A 256-bit secret will be steganographically embedded in this image.

+ +
+ A long phrase of unrelated words is stronger than a short complex password. + Your vault needs good (score ≥ 3) to continue. +
+
- - ${strength ? ` -
-
-
-

strength: ${strength}

- ` : ''} +
+ + +
+ +
+

${labelText}

+

${escapeHtml(counterText)}

+
+

${escapeHtml(entropy || ' ')}

+
- +
+ + ${matchGlyph} + +
+
-
@@ -323,26 +497,43 @@ function attachStep3(): void { reader.readAsArrayBuffer(file); }); - // Track passphrase changes without full re-render - document.getElementById('passphrase')?.addEventListener('input', (e) => { + // Track passphrase changes inline (no full re-render) so the input keeps focus. + // zxcvbn score is computed via the SW on a 150ms debounce — see scheduleRate. + const passInput = document.getElementById('passphrase') as HTMLInputElement | null; + passInput?.addEventListener('input', (e) => { state.passphrase = (e.target as HTMLInputElement).value; - // Update strength bar inline - const strength = passphraseStrength(state.passphrase); - const bar = document.querySelector('.strength-bar-fill') as HTMLElement | null; - const label = document.querySelector('.strength-bar + .muted') as HTMLElement | null; - if (bar) { - bar.className = `strength-bar-fill ${strength}`; - } - if (label) { - label.textContent = `strength: ${strength}`; - } - if (!bar && state.passphrase) { - render(); - } + // Update char counter + match indicator + button gate immediately on every keystroke. + updateStrengthUi(); + // Score updates on the 150ms debounce to avoid SW hammering. + scheduleRate(state.passphrase, (s) => { + state.passphraseScore = s.score; + state.passphraseGuessesLog10 = s.guessesLog10; + updateStrengthUi(); + }); }); - document.getElementById('passphrase-confirm')?.addEventListener('input', (e) => { + const confirmInput = document.getElementById('passphrase-confirm') as HTMLInputElement | null; + confirmInput?.addEventListener('input', (e) => { state.passphraseConfirm = (e.target as HTMLInputElement).value; + updateStrengthUi(); + }); + + // Eye toggles — flip the input type and label without a full re-render so + // focus + cursor position survive the click. + document.getElementById('eye-btn')?.addEventListener('click', () => { + state.passphraseVisible = !state.passphraseVisible; + if (passInput) passInput.type = state.passphraseVisible ? 'text' : 'password'; + const btn = document.getElementById('eye-btn'); + if (btn) btn.textContent = state.passphraseVisible ? 'hide' : 'show'; + passInput?.focus(); + }); + + document.getElementById('confirm-eye-btn')?.addEventListener('click', () => { + state.confirmVisible = !state.confirmVisible; + if (confirmInput) confirmInput.type = state.confirmVisible ? 'text' : 'password'; + const btn = document.getElementById('confirm-eye-btn'); + if (btn) btn.textContent = state.confirmVisible ? 'hide' : 'show'; + confirmInput?.focus(); }); document.getElementById('back-btn')?.addEventListener('click', () => { @@ -366,6 +557,17 @@ function attachStep3(): void { render(); return; } + // Re-rate synchronously in case the button was clicked before the + // debounced rater fired. Defence in depth — the button is already + // disabled in the UI when score < 3 (audit H3). + const strength = await ratePassphrase(state.passphrase); + state.passphraseScore = strength.score; + state.passphraseGuessesLog10 = strength.guessesLog10; + if (state.passphraseScore < 3) { + state.error = 'Passphrase is too weak (zxcvbn score must be ≥ 3).'; + render(); + return; + } if (state.passphrase !== state.passphraseConfirm) { state.error = 'Passphrases do not match'; render(); @@ -376,80 +578,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) + w.embed_image_secret(state.carrierImageBytes, imageSecret), ); + log('embedded', { referenceBytes: state.referenceImageBytes.byteLength }); - // 3. Generate 32-byte salt + stage = 'generate salt'; const salt = new Uint8Array(32); crypto.getRandomValues(salt); - - // 4. Create KDF params const paramsJson = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}'; - // 5. Derive master key - const masterKey = w.derive_master_key( - 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'); - // 6. Encrypt empty manifest - const manifestJson = '{"entries":{},"version":1}'; - const encryptedManifest = w.encrypt_manifest(manifestJson, masterKey); + 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 }); - // 7. 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); - await host.writeFile( - '.relicario/salt', - salt, - 'init: vault salt', - ); + 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', - ); + 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', - ); + 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', ); - // 8. Advance to step 4 + stage = 'release handle'; + w.lock(handle); + + 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(); } }); diff --git a/extension/src/shared/__tests__/base32.test.ts b/extension/src/shared/__tests__/base32.test.ts new file mode 100644 index 0000000..5b6a381 --- /dev/null +++ b/extension/src/shared/__tests__/base32.test.ts @@ -0,0 +1,33 @@ +// 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(); + }); +}); diff --git a/extension/src/shared/base32.ts b/extension/src/shared/base32.ts new file mode 100644 index 0000000..ba0d01c --- /dev/null +++ b/extension/src/shared/base32.ts @@ -0,0 +1,44 @@ +/// 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); +} diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index 72b4213..3001b9f 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -1,68 +1,79 @@ -import type { Entry, Manifest, ManifestEntry, VaultConfig, SetupState } from './types'; +import type { + Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState, + DeviceSettings, GeneratorRequest, +} from './types'; -// --- Request types (popup/content -> service worker) --- +// --- Messages a popup (or setup page) may send --- -export type Request = +export type PopupMessage = + | { type: 'is_unlocked' } | { type: 'unlock'; passphrase: string } | { type: 'lock' } - | { type: 'is_unlocked' } - | { type: 'list_entries'; group?: string } - | { type: 'get_entry'; id: string } - | { type: 'search_entries'; query: string } - | { type: 'add_entry'; entry: Entry } - | { type: 'update_entry'; id: string; entry: Entry } - | { type: 'delete_entry'; id: string } - | { type: 'get_totp'; id: string } - | { type: 'get_autofill_candidates'; url: string } - | { type: 'get_credentials'; id: string } + | { 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: 'generate_password'; length: number } - | { type: 'fill_credentials'; username: string; password: string } - | { type: 'check_credential'; url: string; username: string; password: string } - | { type: 'blacklist_site'; hostname: 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 } + | { type: 'update_settings'; settings: Partial } | { type: 'get_blacklist' } | { type: 'remove_blacklist'; hostname: string }; -// --- Response types (service worker -> popup/content) --- +// --- 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' } + | { type: 'capture_save_login'; username: string; password: string }; + +// --- Union for chrome.runtime.sendMessage call sites --- + +export type Request = PopupMessage | ContentMessage; + +// --- Response --- export type Response = | { ok: true; data?: unknown } | { ok: false; error: string }; -export interface UnlockResponse extends Extract { - data: undefined; -} +// --- Typed response helpers --- export interface IsUnlockedResponse extends Extract { data: { unlocked: boolean }; } -export interface ListEntriesResponse extends Extract { - data: { entries: Array<[string, ManifestEntry]> }; +export interface ListItemsResponse extends Extract { + data: { items: Array<[ItemId, ManifestEntry]> }; } -export interface GetEntryResponse extends Extract { - data: { entry: Entry }; -} - -export interface SearchEntriesResponse extends Extract { - data: { entries: Array<[string, ManifestEntry]> }; +export interface GetItemResponse extends Extract { + data: { item: Item }; } export interface TotpResponse extends Extract { - data: { code: string; remaining_seconds: number }; + data: { code: string; expires_at: number }; } export interface AutofillCandidatesResponse extends Extract { - data: { candidates: Array<[string, ManifestEntry]> }; + data: { candidates: Array<[ItemId, ManifestEntry]> }; } export interface CredentialsResponse extends Extract { - data: { username: string; password: string }; + data: + | { requires_ack: true; hostname: string } + | { username: string; password: string }; } export interface SetupStateResponse extends Extract { @@ -72,3 +83,22 @@ export interface SetupStateResponse extends Extract { export interface GeneratePasswordResponse extends Extract { data: { password: string }; } + +export interface RatePassphraseResponse extends Extract { + data: { score: number; guesses_log10: number }; +} + +// --- Capability sets (consumed by the router) --- + +export const POPUP_ONLY_TYPES: ReadonlySet = 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 = new Set([ + 'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site', + 'capture_save_login', +] as ContentMessage['type'][]); diff --git a/extension/src/shared/types.ts b/extension/src/shared/types.ts index a5ac6cb..e2b7340 100644 --- a/extension/src/shared/types.ts +++ b/extension/src/shared/types.ts @@ -1,32 +1,202 @@ -/// Full credential entry (matches Rust Entry struct in relicario-core). -export interface Entry { - name: string; - url?: string; +/// 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; + 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 → 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; - totp_secret?: string; - group?: string; - created_at: string; - updated_at: string; + created: number; + modified: number; + trashed_at?: number; + core: ItemCore; + sections: Section[]; + attachments: AttachmentRef[]; + field_history: Record; } -/// Lightweight manifest entry for listing/searching without full decrypt. -export interface ManifestEntry { - name: string; - url?: string; - username?: string; - group?: string; - updated_at: string; -} +// --- Manifest (schema_version 2) --- -/// Encrypted manifest containing all entry metadata. export interface Manifest { - entries: Record; - version: number; + schema_version: number; // 2 + items: Record; } -/// Configuration for connecting to a git host. +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; +} + +// --- Vault config (device-local) --- + export interface VaultConfig { hostType: 'gitea' | 'github'; hostUrl: string; @@ -34,20 +204,41 @@ export interface VaultConfig { apiToken: string; } -/// Persisted setup state in chrome.storage.local. export interface SetupState { config: VaultConfig | null; imageBase64: string | null; isConfigured: boolean; } -/// User-configurable credential capture settings. -export interface RelicarioSettings { +// --- Device-local UX settings (chrome.storage.local — renamed from RelicarioSettings) --- + +export interface DeviceSettings { captureEnabled: boolean; captureStyle: 'bar' | 'toast'; } -export const DEFAULT_SETTINGS: RelicarioSettings = { +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' }, +}; diff --git a/extension/src/wasm.d.ts b/extension/src/wasm.d.ts index 5b2f5cb..ccd1681 100644 --- a/extension/src/wasm.d.ts +++ b/extension/src/wasm.d.ts @@ -1,58 +1,65 @@ // 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. +// +// Declared under the bare specifier 'relicario-wasm' so `typeof +// import('relicario-wasm')` resolves in setup.ts. Webpack doesn't +// actually resolve the module — setup.ts loads the auto-generated +// wasm/relicario_wasm.js via a webpackIgnore dynamic import at runtime. -export class SessionHandle { - readonly value: number; - free(): void; +declare module 'relicario-wasm' { + 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: bigint; + 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; + + export default function init(module_or_path?: unknown): Promise; + export function initSync(args: { module: WebAssembly.Module }): 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; diff --git a/extension/tsconfig.json b/extension/tsconfig.json index fde6d86..89512ab 100644 --- a/extension/tsconfig.json +++ b/extension/tsconfig.json @@ -9,11 +9,8 @@ "rootDir": "./src", "sourceMap": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], - "paths": { - "relicario-wasm": ["./wasm/relicario_wasm.js"] - }, "baseUrl": "." }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "wasm"] + "exclude": ["node_modules", "dist", "wasm", "src/**/__tests__/**"] } diff --git a/extension/vitest.config.ts b/extension/vitest.config.ts new file mode 100644 index 0000000..02569a1 --- /dev/null +++ b/extension/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'happy-dom', + include: ['src/**/__tests__/**/*.test.ts'], + }, +});