38 Commits

Author SHA1 Message Date
adlee-was-taken
da3c3893bb feat(ext/icons): replace idfoto ID-card icon with reliquary design
The prior icon was a holdover from the pre-rename idfoto project — a
stylized ID card with a portrait silhouette. Replaced with a proper
reliquary: an arched vessel with a horizontal seal band, small rivets,
standing on a blue pedestal, with a faceted gem at center representing
the protected relic.

- relicario-logo.svg: full 128-px-native design used by the setup
  wizard header and rasterized to icon-48.png and icon-128.png.
- relicario-logo-16.svg: 16-px-optimized variant (bolder strokes, no
  rivets, single-facet gem) for crisp toolbar rendering.
- Palette matches the gh-dark aesthetic used across the extension
  (#0d1117 / #161b22 background, #58a6ff / #79c0ff / #1f6feb accents).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:50:02 -04:00
adlee-was-taken
9139dd78a0 fix(ext/popup): normalize url field + humanize cryptic error messages
Bug: typing "Test" into an add-login form's URL field produced
"item json: relative URL without a base: "Test" at line 1 column 227"
in the UI banner — a serde-internal error message that no user should
ever see.

Two fixes:

1. Client-side URL normalization in the add/edit Login form
   (item-form.ts:normalizeUrl):
   - Empty string stays empty (URL is optional).
   - Scheme-less inputs get "https://" prepended so "github.com"
     becomes "https://github.com".
   - The result is run through the JS URL constructor. If that rejects
     OR if the result has no host, show a targeted message like
     "URL must include a host (e.g. https://example.com)".
   - Prevents the Rust-side url::Url::parse failure from ever firing
     for a form-shaped error.

2. Popup-side error humanizer (popup.ts:humanizeError):
   - Applied inside sendMessage so every UI-visible error passes
     through it before the state banner gets the string.
   - Translates: "relative URL without a base" → "URL must start with
     https://...", generic "item json:" / "settings json:" → form-
     field or corruption messages, and the sender/origin gates
     (vault_locked, origin_mismatch, unauthorized_sender,
     tab_navigated, captured_tab_gone) to user-action prompts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:45:55 -04:00
adlee-was-taken
357455d979 fix(ext/popup): don't eat '/' and other keystrokes while typing in inputs
Bug: item-list's global "/" shortcut (focus search) and "+" shortcut
(new item) fired even when focus was inside any input/textarea other
than the list's own search field. This ate forward-slashes typed into
the setup wizard's host-url field and the add/edit form's notes area,
and would have done the same for any printable shortcut in a future
text field.

Root cause: the handler was attached to `document`, stays attached
when the user opens an item (and its click-handler navigated without
removing the listener), and only excluded the search field by id.

Fix:
- Add isEditableTarget() helper — returns true for
  INPUT/TEXTAREA/SELECT and contenteditable elements. Global shortcut
  handlers bail early when this fires, passing the keystroke through
  to the field.
- Apply the same guard in item-detail.ts (previously only guarded
  against INPUT, missing TEXTAREA + contenteditable).
- Remove handleListKeydown on row-click so it doesn't linger on
  detail/edit views even before the route-transition keydown
  listeners install.
- Escape in the list view still works from inside an editable
  field — only the printable-character interceptions are gated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:43:43 -04:00
adlee-was-taken
69bb58c977 feat(ext/setup): polished passphrase entry UX
Setup wizard step 3 now has self-explanatory passphrase feedback:

- Strength meter: 5 segments with smooth color transitions
  (very-weak/weak/fair/good/strong). Tier 4 gets a subtle glow.
- Nuanced label (lowercase, tracked): "very weak" / "weak" / "fair" /
  "good" / "strong" — color-matched to each tier.
- Entropy readout line: "~10^N guesses — <time to crack>" with
  tiered shorthand (trivial / minutes-on-GPU / hours-to-days /
  years-on-consumer / beyond consumer / uncrackable).
- Live char counter in the strength row.
- Eye toggle buttons on both passphrase fields. Flip type="password"
  <-> type="text" without re-render, preserving focus + cursor.
- Live match indicator (✓ / ✗) between the confirm field and its eye
  toggle. Updates per keystroke.
- Create button gate widened: now requires score >= 3 AND confirm
  field filled AND confirm matches. Disabled button carries a
  tooltip saying which condition failed.
- Contextual help box above the passphrase field explaining the
  "long phrase > complex password" idea + the score >= 3 threshold.

All live-update paths (counter, label, entropy, match indicator,
button gate) go through updateStrengthUi() which targets the DOM
directly — no full re-render, so focus/cursor survive every keystroke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:38:50 -04:00
adlee-was-taken
4341124d38 fix(ext): allow rate_passphrase + is_unlocked from setup tab; add diagnostic logging
Bug: setup tab's zxcvbn meter silently stayed at score=-1 because the
router's isSetup exception only allowed save_setup, so rate_passphrase
got unauthorized_sender. Result: the "create vault" button stayed
disabled forever even with a strong passphrase.

Fix: add a narrow SETUP_ALLOWED set containing save_setup,
rate_passphrase, and is_unlocked (step-4 extension detection). Reject
everything else from the setup tab. Also clean up setup.ts's unlock
call — it was passing the raw 32-byte imageSecret where JPEG bytes with
embedded secret are required; the Rust-side unlock calls imgsecret::
extract internally.

Diagnostic logging across the message path so the next silent failure
speaks up:
- [relicario setup]    staged logs through vault-init; console.error
                       with the failure stage name in the UI banner.
- [relicario setup]    rate_passphrase lastError / rejected / threw
                       branches each log their own warning.
- [relicario router]   console.warn on unauthorized_sender (with sender
                       classification) and unknown_message_type.
- [relicario sw]       first-message wasm init announced; per-message
                       non-ok result logged; thrown errors console.error'd.

Tests: +3 setup-allowlist tests (rate_passphrase accepted, is_unlocked
accepted, fill_credentials + unlock rejected). 55/55 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:32:00 -04:00
adlee-was-taken
3238ef4dd4 refactor(ext/popup): remove last @ts-nocheck, align to typed-item types
Clears the final four transitional @ts-nocheck shields:
- popup.ts (already mostly updated in Slice 6 prior tasks; nocheck just
  removed and the init fallback switched to list_items / ItemId typing)
- unlock.ts (list_entries → list_items; ManifestEntry typing)
- settings.ts (RelicarioSettings → DeviceSettings; pure type rename, UX
  unchanged)

Also drops the stale `idfoto-extension` name in bun.lock (workspace was
renamed; lock file still carried the old name).

Verification:
  git grep -n '@ts-nocheck' extension/src/  → 0 hits
  bun run build + build:firefox             → both green
  bun run test                              → 52/52 passing
  cargo test --workspace                    → green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:44:12 -04:00
adlee-was-taken
f3b915a635 feat(ext/setup): zxcvbn strength meter + score>=3 gate (audit H3)
Replaces the ad-hoc char-class passphraseStrength() with a 5-segment
bar backed by a SW round-trip to rate_passphrase (zxcvbn). Input
handler debounces 150ms so we don't hammer the worker per keystroke.

The create-vault button is disabled unless the last score is ≥ 3
(zxcvbn's "safely unguessable" threshold), and the handler re-rates
synchronously on click as defence-in-depth. Label flips between "Too
weak" (red) and "Strong enough" (green).

Also:
- rewrites the vault-creation path to use the typed-item unlock +
  manifest_encrypt APIs (derive_master_key/encrypt_manifest are gone);
  the new initial manifest is { schema_version: 2, items: {} }.
- wasm.d.ts is now a pure `declare module 'relicario-wasm'` block;
  tsconfig's stale `paths` alias is removed.
- @ts-nocheck removed from setup.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:38:50 -04:00
adlee-was-taken
76bb61aa10 feat(ext/popup): Login add/edit form on typed-item API
Rewrites item-form.ts for the typed-item Item shape. Login is the only
editable type in Slice 6; other types fall through to coming-soon.

Form fields: title (required) + url + username + password (with gen
button backed by DEFAULT_PASSWORD_REQUEST) + totp (base32) + group +
notes. TOTP base32 is decoded via shared/base32 and wrapped as a
number[] into FieldValue-shape TotpConfig { secret, algorithm: sha1,
digits: 6, period_seconds: 30, kind: 'totp' }. Decode failure sets
state.error and aborts.

Save constructs a full Item envelope (id, title, type, tags, favorite,
group, notes, created, modified, trashed_at, core, sections,
attachments, field_history). On edit we preserve the existing item's
metadata but EXPLICITLY set trashed_at: undefined — carry-forward
from Slice 5 review M3, so an edit cannot accidentally preserve stale
trash state.

@ts-nocheck removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:12:14 -04:00
adlee-was-taken
bc95b047a2 feat(ext/popup): Login detail view + coming-soon for other types
Rewrites item-detail.ts to dispatch on item.type: login gets the full
detail view (url, username, masked password + copy, TOTP with 30s
countdown, notes, group, autofill/edit/trash/back buttons). Non-login
types get a coming-soon placeholder; those grow full UIs in later slices.

Fixes Slice 4 review I1: the old autofill path sent a malformed
fill_credentials payload ({ username, password } — no id/capturedTab).
The new handler uses the (capturedTabId, capturedUrl) pair snapshotted
at popup-open and calls fill_credentials with { id, capturedTabId,
capturedUrl }, matching the SW's handler signature that enforces the
M5 + TOCTOU checks.

TOTP poll now calls get_totp on a 1s timer and renders the 30s countdown
bar against expires_at. @ts-nocheck removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:10:41 -04:00
adlee-was-taken
dc8097589e feat(ext/popup): typed-item list view
Rewrites item-list.ts to render the typed-item ManifestEntry v2
surface: title + type-icon emoji (🔑/📝/🪪/💳/🗝/📄/⏱) + icon_hint
as the meta line. Toolbar now has +new, sync, settings, lock. Keyboard
nav unchanged (/, +, arrows, Enter).

Clicking a row fires list_items → get_item (the new typed-item
messages) and stores the full Item in state.selectedItem before
navigating to 'detail'.

Also updates popup.ts PopupState:
- entries now typed Array<[ItemId, ManifestEntry]>
- selectedEntry → selectedItem (Item)
- init() uses list_items not list_entries

Trashed items (trashed_at set) are filtered out of the visible list.
@ts-nocheck removed from item-list.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:09:28 -04:00
adlee-was-taken
d090fc421e refactor(ext/popup): rename entry-* → item-* components
Git-moves the three popup components so history survives the content
rewrite that follows in Tasks 22–24:
- entry-list.ts   → item-list.ts
- entry-detail.ts → item-detail.ts
- entry-form.ts   → item-form.ts

Also renames the exported render functions (renderEntryList →
renderItemList, etc.) and updates popup.ts imports + render switch.
The files still wear @ts-nocheck and reference the old Entry type;
content rewriting happens in Tasks 22–24.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:01:50 -04:00
adlee-was-taken
856ceb2d93 fix(ext): content-callable capture_save_login closes critical router gap
After Slice 4's router split, the capture prompt's Save button was
silently failing on every site: content/capture.ts called four handlers
(get_settings, get_item, update_item, add_item) that are all in
POPUP_ONLY_TYPES, so the router rejected each with unauthorized_sender.

Fix in two parts:

Part A — get_settings: content scripts already have storage permission
via the manifest, so read relicarioSettings directly from
chrome.storage.local instead of round-tripping through the SW.

Part B — new content-callable 'capture_save_login' message that
consolidates what was previously three separate popup-only calls
(get_item + update_item or add_item) into one SW-side operation.
Content scripts no longer need to distinguish add vs update — the SW
does that itself from the manifest.

Security model (all enforced SW-side, never trusting content):

- Origin is derived from sender.tab.url by the router. The payload
  contains only username + password; there is no way for content to
  influence which host the new/updated item binds to.
- Update path re-verifies the existing item's core.url hostname
  matches senderHost before mutating. If the manifest icon_hint ever
  drifts from core.url, we return origin_mismatch rather than
  silently binding a password to the wrong origin.
- Update mutates ONLY the password field + modified timestamp —
  never title, url, or any other core field.
- Add path creates a new Login item whose title is senderHost and
  whose url is the sender's origin.

Five new router tests cover: content-accept, popup-reject, update
path rotates only the password, add path creates bound item, and
origin_mismatch when the stored item's host disagrees with senderHost.
Tests: 47 -> 52.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:57:38 -04:00
adlee-was-taken
1d5ad5e59e test(ext/router): add fill_credentials + save_setup exception tests
Three new describe blocks cover the gaps flagged during Slice 4 review:

1. fill_credentials captured-tab verification — three cases:
   - tab_navigated: chrome.tabs.get returns a tab whose hostname differs
     from capturedUrl → handler must return { ok: false, tab_navigated }
     and not call chrome.tabs.sendMessage.
   - origin_mismatch: tab matches capturedUrl but the item's
     LoginCore.url hostname differs → same refusal, no delivery.
   - happy path: verify the forwarded message is exactly
     { type: 'fill_credentials', username, password, expectedHost }.

2. save_setup exception scope: the setup tab gets a narrow exception
   to POST save_setup, but nothing else. Prove fill_credentials from
   the setup tab is rejected with unauthorized_sender.

3. isContent sender.id guard: a content-shaped sender with a bogus
   sender.id (≠ chrome.runtime.id) must be rejected.

Vault/session modules are partial-mocked via vi.mock + importOriginal so
the existing tests continue to exercise real listItems/findByHostname.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:39:49 -04:00
adlee-was-taken
eed11acba2 feat(ext/popup): snapshot activeTab at popup-open for fill_credentials (audit M5)
Extend PopupState with {capturedTabId, capturedUrl} populated via
chrome.tabs.query({active: true, currentWindow: true}) in init().
These are later passed with fill_credentials so the SW can verify
the captured tab's hostname hasn't changed out from under the user
before forwarding credentials. Combined with expectedHost in the
forwarded payload + content-side re-check in fill.ts, this closes
the TOCTOU window on the popup → SW → content fill path.

popup.ts stays under @ts-nocheck (Slice 6 removes it alongside the
item-* rewrites).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:37:49 -04:00
adlee-was-taken
14397b33f0 feat(ext/content): closed Shadow DOM for icon/picker/TOFU + close fill TOCTOU
Two security fixes bundled together because they all live on the
icon-click/fill path:

1. Icon + picker + TOFU hint now render inside closed-mode Shadow DOM
   (via shadow.createShadowHost). Page scripts can no longer find our
   overlay via document.querySelector or rewrite buttons.

2. Icon's get_autofill_candidates call drops the `url` field — router
   derives origin from sender.tab.url. Similarly get_credentials.

3. Icon's get_credentials response handling was buggy: the response is a
   discriminated union { requires_ack, hostname } | { username, password }
   and the old code always read .username (→ undefined when requires_ack).
   New code dispatches on the `requires_ack` marker and either shows an
   in-page TOFU hint or fills directly.

4. fill_credentials is popup-only in the router — the icon click cannot
   (and MUST NOT) issue it from content. The new flow calls fillFields()
   directly after get_credentials returns the plaintext: the content
   script IS the origin, so no SW round-trip is needed for the typing.

5. TOCTOU on the popup → SW → content fill path: the SW verified the
   captured tab's hostname matched capturedUrl, then forwarded blindly.
   Between that check and chrome.tabs.sendMessage delivery, the tab can
   navigate; chrome.tabs.sendMessage delivers to whatever content-script
   principal is loaded at send-time. Closed by:
   - Router forwards { expectedHost: currentHost } in the payload.
   - fill.ts re-checks location.href.hostname === expectedHost before
     typing anything; on mismatch replies { ok: false, error: 'origin_changed' }
     and types nothing.

6. Remove @ts-nocheck from icon.ts, fill.ts, and detector.ts — all three
   now type-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:37:25 -04:00
adlee-was-taken
8cc1e777be feat(ext/content): closed Shadow DOM + textContent for capture prompt
Previously the capture prompt was a normal <div> appended to document.body
with innerHTML assembly. Any page script could find it via
document.querySelector('#relicario-capture-prompt') and either scrape
values or rewrite the buttons — and the innerHTML pattern meant hostname
interpolation was a latent XSS path (escapeForHtml helped but one mistake
would break it).

- Add content/shadow.ts — createShadowHost() with mode: 'closed', host.style.all = 'initial'.
- Rewrite capture.ts to mount inside the shadow root, build DOM via
  createElement + textContent only, never innerHTML.
- Drop the `url` field from check_credential / blacklist_site — the router
  now derives origin from sender.tab.url (Slice 3 contract).
- Update add_entry / update_entry calls to add_item / update_item with the
  new typed Item + LoginCore shape.
- Swap RelicarioSettings → DeviceSettings.
- Remove @ts-nocheck — the file type-checks clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:35:36 -04:00
adlee-was-taken
fbb64729ce feat(ext/popup): open setup via chrome.tabs.create, drop setup view from popup
The popup is too constrained for multi-step setup (Chrome closes it when
focus shifts to a file picker). Previously the popup rendered a pass-through
setup-wizard component that itself opened setup.html in a tab. Cut the
middleman: if not configured, directly chrome.tabs.create the setup page
and window.close() the popup.

- Remove 'setup' from the View union and the setup case from render().
- Delete setup-wizard component entirely — setup.html is the canonical flow.
- Drop renderSetupWizard import.

The @ts-nocheck stays on popup.ts until Slice 6 (item-* rewrites).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:33:49 -04:00
adlee-was-taken
2ff3ab1d7f feat(ext): drop setup.html / wasm from web_accessible_resources (audit C1)
setup.html is opened via chrome.tabs.create using a chrome-extension:// URL
which doesn't require WAR. WASM is bundled into service-worker.js/setup.js
and never fetched from a web page origin. Leaving them in WAR would expose
their URLs to any origin for probing/fingerprinting; shipping an empty WAR
array closes the surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:33:09 -04:00
adlee-was-taken
0cef607859 fix(ext/build): exclude test files from webpack tsc compile
Slice 4 spec review caught: router.test.ts's narrow chrome.* shim
triggered 4 TS errors in webpack's ts-loader pass during production
builds (partial mocks don't match the full chrome.* type surface).

Plan's verbatim test body assumes tests aren't part of the build
compile. Add src/**/__tests__/** to tsconfig exclude — tests still
compile under Vitest's independent ts pipeline (42/42 passing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:25:53 -04:00
adlee-was-taken
3d2b021cb2 test(ext): vitest + router sender-check + origin-bound autofill 2026-04-20 20:15:49 -04:00
adlee-was-taken
2d4dcb5f6b feat(ext/sw): collapse flat index onto router 2026-04-20 20:11:59 -04:00
adlee-was-taken
56ab58cbe9 feat(ext/sw): router index with sender-based dispatch 2026-04-20 20:11:20 -04:00
adlee-was-taken
be32ea13c6 feat(ext/sw): router/content-callable handlers with origin derivation 2026-04-20 20:11:02 -04:00
adlee-was-taken
533bfd5bea feat(ext/sw): router/popup-only handlers 2026-04-20 20:10:34 -04:00
adlee-was-taken
2fd6daad8e docs(ext/sw): tighten slice-3 comments per code review
Non-functional tightening flagged in the slice-3 code review:

- session.ts: document future multi-vault refactor (β+) so the module-
  scope singleton is explicitly "deliberately simple," not an oversight.
- vault.ts: move findByHostname doc comment above the function; note
  α's intentionally-coarse hostname match (no www-stripping, no
  public-suffix matching) and that tighter matching is a β/γ concern.
- index.ts: expand the passphrase scope-clearing comment to make
  the theatre explicit rather than leaving it looking like real defense.
- index.ts: TODO(slice-4) marker on delete_item's non-atomic two-write
  path — consider manifest-first ordering or retry/rollback at router-
  split time.
- index.ts: cross-reference comment on itemToManifestEntry pointing at
  the Rust-side ManifestEntry::from_item derivation it must mirror.

No behavior change; build still compiles with 2 bundle-size warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:07:27 -04:00
adlee-was-taken
c0fba2a8dc chore(ext): silence popup/content errors until slice 6 2026-04-20 19:57:32 -04:00
adlee-was-taken
20144e8e02 feat(ext/sw): rewire flat handler onto typed-item vault + SessionHandle 2026-04-20 19:55:50 -04:00
adlee-was-taken
bd9dd206ac feat(ext/sw): typed-item vault ops via SessionHandle 2026-04-20 19:53:28 -04:00
adlee-was-taken
7781a51848 feat(ext/sw): SessionHandle lifecycle module 2026-04-20 19:52:54 -04:00
adlee-was-taken
dc8afcb634 feat(ext): base32 encode/decode for TOTP secret parse 2026-04-20 19:44:18 -04:00
adlee-was-taken
b4da5bffcf feat(ext): split PopupMessage / ContentMessage unions + capability sets 2026-04-20 19:43:09 -04:00
adlee-was-taken
04c9503036 feat(ext): typed-item TS types mirroring relicario-core serde 2026-04-20 19:42:31 -04:00
adlee-was-taken
14aaac672c build(ext): align wasm.d.ts with relicario-wasm surface
Add initSync named export (Chrome MV3 service worker path — can't use
dynamic import()), and correct TotpCode.expires_at from number to bigint
to match the u64 wasm-bindgen output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:36:54 -04:00
adlee-was-taken
c03a492ee3 docs: Plan 1C-α (extension foundation) implementation plan
28 tasks across 6 slices + pre-flight + acceptance, following the 1C-α
design spec (a1d733d/ad6d8af). Each task is a single commit; each step
is 2-5 minutes of work. Design choices locked in:

- Slice 1 (Tasks 1-3): WASM artifact rebuild (replace stale idfoto_wasm)
- Slice 2 (Tasks 4-6): shared TS types + message unions + base32 util
- Slice 3 (Tasks 7-10): session.ts, vault.ts, transitional index.ts
- Slice 4 (Tasks 11-15): split router + Vitest + sender-check matrix
- Slice 5 (Tasks 16-20): WAR cleanup, setup-via-tabs, closed Shadow DOM
  for capture/icon/picker/ack, popup captured-tab snapshot
- Slice 6 (Tasks 21-27): popup rename + Login-parity + zxcvbn + manual
  cross-browser verification
- Slice 7 (Task 28): acceptance checks (cargo test, build, lint greps)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:28:13 -04:00
adlee-was-taken
ad6d8af2f6 docs(1c-alpha): correct TS type definitions to match actual serde shapes
Verified against the Plan 1A Rust sources:
- ItemType / ItemCore use snake_case with tag="type" internal tagging
  (not the external tagging I initially wrote)
- TotpKind is default-externally-tagged (no tag attr), so it serializes
  as bare "totp"/"steam" for unit variants and { hotp: { counter } }
- GeneratorRequest uses tag="kind" internal tagging
- FieldValue / TrashRetention / HistoryRetention / SymbolCharset use
  adjacent tagging { tag: "kind", content: "value" }
- Fix Login form TOTP parse example and "gen" button payload

No scope change — this is a bookkeeping correction so the plan
author references the correct wire shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:19:44 -04:00
adlee-was-taken
a1d733ddeb docs: Plan 1C-α (extension foundation) design spec
Foundation slice of the browser-extension migration onto the typed-item
core from Plans 1A+1B. Scope: WASM artifact rebuild, typed-item shared
types, SessionHandle-based service worker, split router with sender
checks, full security architecture (origin-bound autofill, TOFU ack,
closed Shadow DOM, popup captured-tab verification), zxcvbn setup gate,
Login-parity popup. Other 6 item types land in 1C-β; attachments/trash/
history/device UI in 1C-γ.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:14:25 -04:00
adlee-was-taken
76f34bfcf5 chore: remove stray vault files from Plan 1B + add plan doc
A Task 6 implementer subagent ran `relicario init` inside the worktree
root during manual testing and committed the resulting vault skeleton
(.relicario/, manifest.enc, settings.enc) plus overwrote .gitignore.
None of these should be in the source repo.

Restores the original .gitignore (adds reference.jpg and ref.jpg to it)
and checks in the Plan 1B design doc that describes the work just merged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:50:37 -04:00
adlee-was-taken
e0c511e320 Merge Plan 1B: typed-item CLI + WASM bridge
34 commits from plan-1a-rust-core-complete landing:
- Rename reconciliation (Task 1)
- Core imgsecret MAX_DIMENSION cap (Task 2, audit M3)
- CLI rewrite against typed-item core API (Tasks 3-17)
- WASM opaque SessionHandle bridge (Tasks 18-21)
- CLI integration test harness + tests (Tasks 22-24)
- CLAUDE.md typed-item layout refresh (Task 25)

Audit fixes: H4 H5 H6 H7 M3 M6 M7 M11 L8.
Tests: 151 passing (core + CLI + WASM native), WASM target builds clean.
Tag: plan-1b-cli-wasm-complete
2026-04-20 18:48:56 -04:00
49 changed files with 11501 additions and 1580 deletions

8
.gitignore vendored
View File

@@ -1 +1,9 @@
target/
.superpowers/
.worktrees/
extension/node_modules/
extension/dist/
extension/dist-firefox/
extension/wasm/
reference.jpg
ref.jpg

View File

@@ -1 +0,0 @@
[]

View File

@@ -1,11 +0,0 @@
{
"format_version": 2,
"kdf": {
"algorithm": "argon2id-v0x13",
"argon2_m": 65536,
"argon2_t": 3,
"argon2_p": 4
},
"aead": "xchacha20poly1305",
"salt_path": ".relicario/salt"
}

View File

@@ -1 +0,0 @@
ο½48^ΖΥ<CE96>\Χ<>ο<EFBFBD>Ιl$$Η. ώφrΥΩΛ

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,584 @@
# relicario — Extension Plan 1C-α (Foundation) Design
First of three sub-plans that port the browser extension from the v1 single-`Entry` data model to the typed-item model landed in Plans 1A + 1B. 1C-α is the **foundation slice**: rebuild the WASM artifact, migrate shared types, rewrite the service worker against the opaque `SessionHandle` surface, split the message router with sender checks, wire the full security architecture from the typed-items spec, and achieve Login-parity on the new stack. Other six item types show "Coming in 1C-β" placeholders.
This spec references the broader design at `docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md` — read that for the cryptographic envelope, data-model rationale, and threat model. This document is the extension-side implementation design for the first of the three 1C sub-plans.
## Plan 1C decomposition
| Sub-plan | Scope |
|---|---|
| **1C-α (this spec)** | WASM rebuild, shared types, service-worker rewrite, router split, security architecture, Login-parity popup, setup-wizard zxcvbn, Firefox parity |
| 1C-β | Per-type forms for the other six types, sections + custom fields, full vault-settings UI, generator-request UI |
| 1C-γ | Attachments (with `putBlob` Git-Data-API fallback), trash view, field-history view, device management |
Each sub-plan gets its own spec → plan → implementation cycle.
## Design Decisions
Captured during brainstorming:
| Question | Decision | Why |
|---|---|---|
| How many sub-plans? | Three (α/β/γ) | Single plan is too large to review or execute without drift; three sub-plans give natural checkpoints |
| What's "done" for 1C-α? | **Login-parity on the new stack** — all existing single-`Entry` flows re-expressed as `Item::Login`; other six types show "coming soon" | Validates the full pipeline end-to-end (item + manifest + git commit) before β's UI sprawl; keeps extension usable during β |
| Firefox in scope for α? | Yes, concurrent with Chrome | Shared TS source; marginal extra cost; avoids mid-β surprises from silent rot |
| Where do capture UX prefs + blacklist live? | `chrome.storage.local` (device-local) | TOFU origin-ack is a security posture (vault-level); capture prompt style is a UX preference that genuinely differs per device; blacklist churn pollutes git log |
| zxcvbn strength gate in α or β? | **α** | Audit H3 (security); leaving a weak-passphrase window open during β is the same shape of mistake as leaving autofill origin-unbound during α |
| Sequencing | **Bottom-up six-slice** (WASM artifact → types → SW vault/session → router → security → popup rewire + zxcvbn) | Matches Plan 1B's small-task cadence; gives the plan-executor clean checkpoints |
## Scope
### In
- **WASM artifact** rebuilt from `relicario-wasm` crate (replacing stale `idfoto_wasm*` files).
- **Shared TypeScript types / messages** migrated to the typed-item surface (`Item`, `ItemCore`, `Manifest` v2, `AttachmentRef`, plus the minimal `VaultSettings` subset needed for origin-ack).
- **Service-worker rewrite**: `SessionHandle`-based `session.ts`, rewritten `vault.ts`, split `router/{index,popup-only,content-callable}.ts` with sender checks.
- **Security architecture**:
- WAR cleanup — `setup.html`, `setup.js`, wasm artifacts dropped.
- Setup opened via `chrome.tabs.create`.
- Origin-bound autofill (`sender.tab.url` only, hostname equality, top-frame only).
- TOFU origin-ack via `VaultSettings.autofill_origin_acks`.
- Popup captured-tab verification for `fill_credentials` (audit M5).
- Closed Shadow DOM for all content-script UI.
- `textContent`-only DOM construction; randomized-per-prompt element references; bounded page-derived strings.
- **Login-parity popup**: view, add, edit, delete, autofill, capture for `Item::Login`. Other six types appear in the "New…" menu but open a "Coming in 1C-β" placeholder.
- **Setup wizard**: zxcvbn strength meter + `score >= 3` gate (via new `rate_passphrase` message).
- **Firefox build** re-verified with the new manifest + webpack config.
### Out (→ 1C-β / 1C-γ)
- Per-type forms for SecureNote / Identity / Card / Key / Document / Totp (β).
- Sections + custom fields UI (β).
- Full vault-settings view (retention policies, generator defaults, attachment caps) — α touches `settings.enc` only for origin-ack (β).
- Attachments and `putBlob` Git-Data-API fallback — Login items fit Contents API (γ).
- Trash view, field-history view (γ).
- Device management UI — CLI already handles it (γ).
- BIP39 / advanced generator-request UI — α uses a default `Random { length: 20, classes: lower+upper+digits+symbols, symbol_charset: SafeOnly }` for the "gen" button (β).
## File Map
### New files
```
extension/src/service-worker/session.ts # SessionHandle lifecycle
extension/src/service-worker/router/index.ts # single onMessage entry + sender dispatch
extension/src/service-worker/router/popup-only.ts # popup-callable handlers
extension/src/service-worker/router/content-callable.ts # content-script-callable handlers
extension/src/service-worker/router/__tests__/router.test.ts # sender-check + origin-bound autofill tests
extension/src/content/shadow.ts # closed Shadow DOM host helper
extension/src/shared/base32.ts # base32 encode/decode for TOTP field parse
```
### Rewritten
```
extension/src/shared/types.ts # Item, ItemCore, FieldKind, VaultSettings, ManifestEntry v2
extension/src/shared/messages.ts # PopupMessage + ContentMessage unions
extension/src/service-worker/vault.ts # typed-item ops via SessionHandle
extension/src/service-worker/index.ts # thin init + WASM load, delegates to router
extension/src/content/capture.ts # closed Shadow DOM, textContent
extension/src/content/icon.ts # closed Shadow DOM, textContent
extension/src/popup/popup.ts # item dispatch, captured-tab snapshot on init
extension/src/popup/components/entry-list.ts # → item-list.ts
extension/src/popup/components/entry-detail.ts # → item-detail.ts (Login dispatcher + "coming soon")
extension/src/popup/components/entry-form.ts # → item-form.ts (Login dispatcher + "coming soon")
extension/src/popup/components/setup-wizard.ts # zxcvbn meter + gate
extension/src/setup/setup.ts # zxcvbn meter + gate (mirror)
extension/src/wasm.d.ts # mirror crates/relicario-wasm/src/lib.rs
extension/manifest.json # WAR cleanup
extension/manifest.firefox.json # WAR cleanup
```
### Deleted
```
extension/wasm/idfoto_wasm* # stale pre-rename artifact
```
## WASM Artifact
`npm run build:wasm` already targets `../crates/relicario-wasm --out-dir ../../extension/wasm`. Running it produces `relicario_wasm.js`, `relicario_wasm_bg.wasm`, and `relicario_wasm.d.ts` in the output directory. Delete the stale `idfoto_wasm*` files. Both webpack configs already import from `../../wasm/relicario_wasm.js` — no config edits required.
`wasm.d.ts` currently mirrors the Plan 1B surface; skim it for drift against `crates/relicario-wasm/src/lib.rs` after the rebuild (particularly the `attachment_encrypt` third argument `max_bytes: bigint`).
## Shared Types (`shared/types.ts`)
Mirror the Rust core verbatim through serde serialization. The Rust-side shapes use a mix of `snake_case`, internal tagging (`#[serde(tag = "type")]` for `ItemCore`, `tag = "kind"` for `GeneratorRequest`), adjacent tagging (`tag = "kind", content = "value"` for `FieldValue`, `TrashRetention`, `HistoryRetention`, `SymbolCharset`), and default external tagging (`TotpKind`). The TS types must match exactly:
```ts
export type ItemId = string; // 16-char hex
export type FieldId = string;
export type AttachmentId = string;
// snake_case strings, matches serde rename_all = "snake_case"
export type ItemType = 'login' | 'secure_note' | 'identity' | 'card' | 'key' | 'document' | 'totp';
export interface Item {
id: ItemId;
title: string;
type: ItemType; // Rust's `r#type` serializes as `type`
tags: string[];
favorite: boolean;
group?: string; // omitted when None (#[serde(skip_serializing_if)])
notes?: string;
created: number;
modified: number;
trashed_at?: number;
core: ItemCore; // internally-tagged on `"type"` — see below
sections: Section[];
attachments: AttachmentRef[];
field_history: Record<FieldId, FieldHistoryEntry[]>;
}
// Internally-tagged: ItemCore variant's fields get merged with `"type"` discriminant.
// Example wire format for Login: { "type": "login", "username": "...", ... }
export type ItemCore =
| ({ type: 'login' } & LoginCore)
| ({ type: 'secure_note' } & SecureNoteCore)
| ({ type: 'identity' } & IdentityCore)
| ({ type: 'card' } & CardCore)
| ({ type: 'key' } & KeyCore)
| ({ type: 'document' } & DocumentCore)
| ({ type: 'totp' } & TotpCore);
export interface LoginCore {
username?: string;
password?: string;
url?: string; // Rust serializes `Url` as its string form
totp?: TotpConfig;
}
// TotpKind is externally-tagged (default for enums without #[serde(tag)]):
// Totp → "totp" (unit variant serializes as bare string)
// Hotp{counter}→ { "hotp": { "counter": 42 } }
// Steam → "steam"
export type TotpKind = 'totp' | 'steam' | { hotp: { counter: number } };
export interface TotpConfig {
secret: number[]; // Vec<u8> → JSON number array
algorithm: 'sha1' | 'sha256' | 'sha512';
digits: number;
period_seconds: number;
kind: TotpKind;
}
// Populated minimally for α (structural shape only, no UI); β fills in:
export interface SecureNoteCore { body: string; }
export interface IdentityCore { /* ... */ }
export interface CardCore { /* ... */ }
export interface KeyCore { /* ... */ }
export interface DocumentCore { filename: string; mime_type: string; primary_attachment: AttachmentId; }
export interface TotpCore { config: TotpConfig; issuer: string | null; label: string | null; }
export interface Manifest {
schema_version: number;
items: Record<ItemId, ManifestEntry>;
}
export interface ManifestEntry {
id: ItemId;
type: ItemType;
title: string;
tags: string[];
favorite: boolean;
group: string | null;
icon_hint: string | null;
modified: number;
trashed_at: number | null;
attachment_summaries: AttachmentSummary[];
}
export interface VaultSettings {
trash_retention: unknown; // opaque in α; full shape in β
field_history_retention: unknown;
generator_defaults: unknown;
attachment_caps: unknown;
autofill_origin_acks: Record<string, number>;
}
export interface DeviceSettings { // chrome.storage.local shape (was RelicarioSettings)
captureEnabled: boolean;
captureStyle: 'bar' | 'toast';
}
// GeneratorRequest is internally-tagged on "kind", struct variants:
export type GeneratorRequest =
| { kind: 'bip39'; word_count: number; separator: string; capitalization: Capitalization }
| { kind: 'random'; length: number; classes: CharClasses; symbol_charset: SymbolCharset };
export type Capitalization = 'lower' | 'upper' | 'first_of_each' | 'title' | 'mixed';
export interface CharClasses { lower: boolean; upper: boolean; digits: boolean; symbols: boolean; }
// SymbolCharset is adjacently-tagged { tag: "kind", content: "value" }:
export type SymbolCharset =
| { kind: 'safe_only' }
| { kind: 'extended' }
| { kind: 'custom'; value: string };
// TrashRetention / HistoryRetention use the same adjacent tagging:
export type TrashRetention =
| { kind: 'forever' }
| { kind: 'days'; value: number };
export type HistoryRetention =
| { kind: 'forever' }
| { kind: 'last_n'; value: number }
| { kind: 'days'; value: number };
// FieldValue adjacently-tagged { tag: "kind", content: "value" }, snake_case:
export type FieldValue =
| { kind: 'text'; value: string }
| { kind: 'multiline'; value: string }
| { kind: 'password'; value: string }
| { kind: 'concealed'; value: string }
| { kind: 'url'; value: string } // Url → string
| { kind: 'email'; value: string }
| { kind: 'phone'; value: string }
| { kind: 'date'; value: string } // chrono NaiveDate → "YYYY-MM-DD"
| { kind: 'month_year'; value: { month: number; year: number } }
| { kind: 'totp'; value: TotpConfig }
| { kind: 'reference'; value: AttachmentId };
export type FieldKind =
| 'text' | 'multiline' | 'password' | 'concealed' | 'url' | 'email'
| 'phone' | 'date' | 'month_year' | 'totp' | 'reference';
```
Plus `Section`, `Field`, `AttachmentRef`, `AttachmentSummary`, `FieldHistoryEntry` as declared. Most are unused by α's UI but present so the type-check catches drift with the Rust side.
The serialization shapes above are verified in slice 3's smoke test: `item_encrypt(handle, JSON.stringify(loginItem))` round-trips through `item_decrypt` with structural equality against a Rust-side item written by the CLI.
## Messages (`shared/messages.ts`)
Two unions, so TypeScript itself enforces the router boundary:
```ts
export type PopupMessage =
| { type: 'is_unlocked' }
| { type: 'unlock'; passphrase: string }
| { type: 'lock' }
| { type: 'list_items'; group?: string }
| { type: 'get_item'; id: ItemId }
| { type: 'add_item'; item: Item }
| { type: 'update_item'; id: ItemId; item: Item }
| { type: 'delete_item'; id: ItemId } // soft-delete (sets trashed_at)
| { type: 'get_totp'; id: ItemId }
| { type: 'sync' }
| { type: 'get_setup_state' }
| { type: 'save_setup'; config: VaultConfig; imageBase64: string }
| { type: 'rate_passphrase'; passphrase: string }
| { type: 'generate_password'; request: GeneratorRequest }
| { type: 'fill_credentials'; id: ItemId; capturedTabId: number; capturedUrl: string }
| { type: 'ack_autofill_origin'; hostname: string }
| { type: 'get_settings' } // DeviceSettings (local)
| { type: 'update_settings'; settings: Partial<DeviceSettings> }
| { type: 'get_blacklist' }
| { type: 'remove_blacklist'; hostname: string };
export type ContentMessage =
| { type: 'get_autofill_candidates' } // url comes from sender.tab.url
| { type: 'get_credentials'; id: ItemId } // origin-checked against sender.tab.url
| { type: 'check_credential'; username: string; password: string } // url from sender
| { type: 'blacklist_site' }; // hostname from sender
export type Request = PopupMessage | ContentMessage;
```
Deliberate omissions: `get_autofill_candidates`, `check_credential`, `blacklist_site` no longer carry a `url` — the SW derives it from `sender.tab.url`. `fill_credentials` now takes an item id + captured tab state instead of raw credentials, so the SW can re-verify origin on the forwarding hop.
## Service Worker
### `service-worker/session.ts`
Single module-scope "current" `SessionHandle` (single-vault assumption per the broader spec).
```ts
import type { SessionHandle } from '../../wasm/relicario_wasm';
let current: SessionHandle | null = null;
export function setCurrent(h: SessionHandle): void { current = h; }
export function getCurrent(): SessionHandle | null { return current; }
export function clearCurrent(): void {
if (!current) return;
try { current.free(); } catch { /* already freed */ }
current = null;
}
export function requireCurrent(): SessionHandle {
if (!current) throw new Error('vault_locked');
return current;
}
```
SW idle-suspend (Chrome) or explicit `lock` message clears the handle. Firefox's persistent background script retains it until explicit lock — consistent with the spec.
### `service-worker/vault.ts`
Public surface, all handle-keyed:
```ts
fetchVaultMeta(git): Promise<{ salt: Uint8Array; paramsJson: string }>
fetchAndDecryptManifest(git, handle): Promise<Manifest>
encryptAndWriteManifest(git, handle, manifest, message): Promise<void>
fetchAndDecryptItem(git, handle, id): Promise<Item>
encryptAndWriteItem(git, handle, id, item, message): Promise<void>
fetchAndDecryptSettings(git, handle): Promise<VaultSettings>
encryptAndWriteSettings(git, handle, settings, message): Promise<void>
listItems(manifest, filter?): Array<[ItemId, ManifestEntry]>
searchItems(manifest, query): Array<[ItemId, ManifestEntry]>
findByHostname(manifest, hostname): Array<[ItemId, ManifestEntry]> // hostname from caller
```
No `masterKey: Uint8Array` anywhere in the module surface.
### `service-worker/router/index.ts`
Single `chrome.runtime.onMessage.addListener`. Sender predicates:
```ts
const popupUrl = chrome.runtime.getURL('popup.html');
const setupUrl = chrome.runtime.getURL('setup.html');
const senderUrl = sender.url ?? '';
const isPopup = senderUrl === popupUrl;
const isSetup = senderUrl.startsWith(setupUrl);
const isContent = sender.tab !== undefined
&& sender.frameId === 0
&& sender.id === chrome.runtime.id;
```
`POPUP_ONLY` capability set = every `PopupMessage` type. `CONTENT_CALLABLE` = every `ContentMessage` type. `save_setup` is the one exception: accepted from `isPopup || isSetup`.
Unauthorized sender → `{ ok: false, error: 'unauthorized_sender' }` synchronously. Unknown type → `{ ok: false, error: 'unknown_message_type' }`. The two handler files stay thin — pure `switch (msg.type)` with no sender logic (the router already verified).
## Security Architecture
### WAR cleanup (audit C1)
Both manifests drop `setup.html`, `setup.js`, `relicario_wasm.js`, `relicario_wasm_bg.wasm` from `web_accessible_resources`. The WAR array becomes `[{ resources: ["styles.css"], matches: ["<all_urls>"] }]` if styles are still needed, else disappears.
The popup opens setup via:
```ts
chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
```
Own-origin extension tabs work without WAR. The SW loads WASM via `chrome.runtime.getURL(...)` from the extension origin — no WAR either.
### Sender dispatch (audit C1, C2)
Implemented in `router/index.ts` as described above. The `save_setup` exception is the one place this deviates from "pure set-membership" — it accepts either `isPopup` or `isSetup`.
### Origin-bound autofill (audit C4)
Content-callable handlers derive origin exclusively from `sender.tab.url`. Flow:
- **`get_autofill_candidates`**: parse `sender.tab.url` → hostname. Return manifest entries whose `LoginCore.url` hostname equals page hostname. Top-frame only (`sender.frameId === 0` already enforced at router).
- **`get_credentials(id)`**: fetch item. Parse `LoginCore.url` hostname. Compare to `sender.tab.url` hostname. Mismatch → `{ ok: false, error: 'origin_mismatch' }`. No item data leaked on mismatch.
- **`check_credential`**: same origin derivation; `username`/`password` compared against manifest + decrypted item.
- **`blacklist_site`**: hostname derived from `sender.tab.url`.
### TOFU origin-ack
When `get_credentials` succeeds on an origin not present in `VaultSettings.autofill_origin_acks`:
1. SW returns `{ ok: true, data: { requires_ack: true, hostname } }` — no credentials.
2. Content script surfaces a "confirm autofill" dialog inside its closed Shadow DOM (lightweight — just a re-use of the capture prompt layout).
3. User clicks "Confirm in relicario" → focuses the extension popup (the content-script dialog shows a prompt to open the popup; `chrome.action.openPopup()` is Chrome-only and unreliable, so α uses an instructional "open relicario to confirm" message instead).
4. User opens popup → popup detects a pending ack via `VaultSettings.autofill_origin_acks` diff, shows "Confirm autofill on `<hostname>`?" → user acks → popup sends `ack_autofill_origin { hostname }` (popup-only, writes `settings.enc`).
5. Next autofill attempt on the same origin succeeds.
The ack is popup-only because it's a vault write; the content-script dialog is purely instructional. Full in-page ack UI (including a tighter retry loop) is β-polish territory.
### Popup captured-tab verification (audit M5)
On popup open:
```ts
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const captured = { tabId: tab.id!, url: tab.url ?? '' };
```
Stashed on `PopupState`. `fill_credentials` messages carry `capturedTabId` + `capturedUrl`. SW handler:
1. Look up item by `id`.
2. `chrome.tabs.get(capturedTabId)` — if gone or navigated, reject.
3. Compare `new URL(tab.url).hostname` to `new URL(capturedUrl).hostname` — mismatch rejects.
4. Compare captured hostname to `LoginCore.url`'s hostname — mismatch rejects.
5. Forward via `chrome.tabs.sendMessage(capturedTabId, { type: 'fill_credentials', username, password })`.
Content-script fill listener stays as-is (native-setter trick is already correct).
### Closed Shadow DOM (audit C3)
`content/shadow.ts`:
```ts
export function createShadowHost(): { host: HTMLElement; root: ShadowRoot; destroy: () => void } {
const host = document.createElement('div');
document.body.appendChild(host);
const root = host.attachShadow({ mode: 'closed' });
return { host, root, destroy: () => host.remove() };
}
```
Used by `icon.ts` (per-password-field host for the icon + picker) and `capture.ts` (submit-prompt). Strict rules enforced by review + a lint rule (`no-restricted-syntax` against `MemberExpression[property.name=/^(innerHTML|outerHTML)$/]` inside `extension/src/content/`):
1. **No `innerHTML` / `insertAdjacentHTML` / `document.write`** anywhere in `content/`. All DOM via `createElement` + `textContent` + `appendChild`.
2. **No stable element IDs or classes inside shadow trees**. Wire handlers via local references.
3. **Page-derived strings bounded**: `findUsernameValue` result capped at 256 chars, control characters stripped via `replace(/\p{Cc}/gu, '')`, assigned only via `.textContent`.
4. **Disposal**: removing the host element drops the shadow root, detaches handlers.
Styles inside the shadow tree via `style.setProperty(...)` calls, or a single `<style>` element whose text content is a static literal.
### JS-side passphrase hygiene
Best-effort only (JS strings are immutable). In the `unlock` handler: receive passphrase, pass directly to `wasm.unlock(...)`, then `req.passphrase = ''` and let the message object go out of scope. Never log passphrase content. WASM-side zeroization is the primary defense (already handled Rust-side).
## Popup
### Entry flow
`popup.ts` unchanged in shape. Init sequence:
1. `get_setup_state` → if `!isConfigured`, `chrome.tabs.create(setup.html)` and close the popup.
2. `is_unlocked` → if unlocked, `list_items` + render list.
3. Otherwise render `unlock`.
4. Snapshot `(activeTabId, activeTabUrl)` at init; stash on `PopupState` for later `fill_credentials` calls.
### Item list / detail / form — Login-only
Rename `entry-*.ts``item-*.ts`. List view renders from `ManifestEntry`, shows type-icon + title + group + favorite + tags. Detail and form dispatch on `item.type`:
```ts
switch (item.type) {
case 'login': return renderLoginDetail(app, item);
case 'secure_note':
case 'identity':
case 'card':
case 'key':
case 'document':
case 'totp': return renderComingSoonPlaceholder(app, item.type);
}
```
Add flow: "New…" menu lists all seven types; picking Login opens the form, picking any other type shows "Coming in 1C-β".
Existing Login form (username/url/password/totp/group/notes) maps 1:1 to `LoginCore` + `Item` envelope. TOTP field takes a base32 string; `shared/base32.ts` parses it to a `number[]` (the `secret: Vec<u8>` on the Rust side) and emits `TotpConfig { secret, algorithm: 'sha1', digits: 6, period_seconds: 30, kind: 'totp' }`. Display is base32 re-encoded with a reveal toggle.
"gen" button sends:
```ts
{ type: 'generate_password',
request: { kind: 'random',
length: 20,
classes: { lower: true, upper: true, digits: true, symbols: true },
symbol_charset: { kind: 'safe_only' } } }
```
### Setup wizard + zxcvbn
Both `popup/components/setup-wizard.ts` and `setup/setup.ts`:
- Passphrase input with a 5-bar strength indicator (color-coded per zxcvbn `score`).
- On input (150ms debounce): `rate_passphrase { passphrase }``{ score, guesses_log10 }`.
- Submit disabled unless `score >= 3`.
- Copy: `score < 3` → "Too weak — try a longer phrase or add unpredictability."; `score >= 3` → "Strong enough."
Reference-image upload and vault-config fields stay as-is. Final step gains the spec's H8 warning copy: "factor 2 + git push token are readable from this browser profile's disk. Your remaining defense is the passphrase."
## Storage Split
| Data | Location | Rationale |
|---|---|---|
| `vaultConfig` (host/repo/token) | `chrome.storage.local` | Device-local; needed pre-unlock |
| `imageBase64` (reference JPEG) | `chrome.storage.local` | Device-local factor-2 material |
| `DeviceSettings { captureEnabled, captureStyle }` | `chrome.storage.local` (key: `relicarioSettings`) | Per-device UX preference |
| `captureBlacklist: string[]` | `chrome.storage.local` | Per-device; avoids git churn |
| `VaultSettings.autofill_origin_acks` | `settings.enc` in the repo | Per-vault security posture |
| Items / manifest / settings | git repo (AEAD'd) | Core vault state |
Existing `relicarioSettings` + `captureBlacklist` keys keep their names — no migration step.
## Firefox Parity
- `manifest.firefox.json` gets the same WAR cleanup as Chrome.
- `background.scripts: ["service-worker.js"]` stays (not an SW in Firefox — persistent background script).
- `initWasm()` in `service-worker/index.ts` already branches on `ServiceWorkerGlobalScope` presence; the branch survives the rewrite.
- Load via `about:debugging` → "Load Temporary Add-on" → `dist-firefox/manifest.json`.
- Final manual test matrix at the end of α runs on both browsers concurrently (see below).
## Testing Strategy
### Rust-side regression guard
Plan 1B's 151 tests must stay green. Every slice runs `cargo test --workspace` before commit. The WASM rebuild slice additionally runs `cargo build -p relicario-wasm --target wasm32-unknown-unknown`.
### Extension unit tests (new)
New harness in `extension/src/service-worker/router/__tests__/router.test.ts` using Vitest (runs TS natively, no webpack dependency). Tests:
- **Sender-check matrix**: for each message type, accepted from the right sender and rejected (`unauthorized_sender`) from every wrong sender. Mocks `chrome.runtime.onMessage` by calling the dispatcher directly with fabricated `sender` objects.
- **Origin-bound autofill**: `get_autofill_candidates` ignores any stray `url` field; only `sender.tab.url`'s hostname drives matching.
- **`get_credentials` origin equality**: mismatched `LoginCore.url` hostname → `origin_mismatch`, no item data in response.
- **`fill_credentials` captured-tab verification**: mismatched captured vs current tab → reject.
- **`generate_password` wiring**: calls through to WASM with the expected request shape (smoke only; generator itself is Rust-tested).
Harness scope: SW only. Popup/content-script DOM tests are β.
### Shadow DOM probe (dev-build only)
Runtime assertion in a dev-mode content-script path: after rendering the capture prompt, `console.warn` if `document.querySelector('#relicario-save-btn') !== null` or if the host's `shadowRoot` getter returns non-null from page JS. Stripped in production.
### Manual test matrix — end of slice 6, on both Chrome and Firefox
1. Fresh install → setup wizard opens in a tab (not popup-embedded); zxcvbn slider responds; weak passphrase blocks submit.
2. Unlock → list renders from manifest.
3. Add Login with TOTP → sync → item appears on a second browser profile.
4. Autofill icon in password field → click → fills (single-candidate path).
5. Multiple candidates → picker → pick → fills.
6. First autofill on a new hostname → origin-ack prompt → confirm → credentials arrive.
7. Capture prompt on submitting a new login; "Save" adds item, "Never" blacklists, "×" dismisses.
8. Edit Login → password rotates → field history captured (CLI cross-check: `relicario get --show` on second machine shows new password; item file's `field_history` populated).
9. Delete Login → moves to trash (not in list; CLI `relicario list --trashed` shows it).
10. Soft-lock via popup "lock" → list clears, re-unlock required.
11. Cross-origin autofill attempt via devtools: craft a `get_credentials` from a page whose hostname differs from item's `LoginCore.url``origin_mismatch`.
### Acceptance criteria
- `cargo test --workspace` green.
- `npm run build:all` green in `extension/` (Chrome + Firefox bundles).
- Router unit tests green.
- All 11 manual-matrix steps pass on both Chrome and Firefox.
- `git grep -n 'innerHTML\|insertAdjacentHTML' extension/src/content/` → zero hits.
- `git grep -n 'idfoto' extension/` → zero hits.
- WAR in both manifests contains no HTML/JS/WASM artifacts.
- `fetch(chrome.runtime.getURL('setup.html'))` from a content script fails (smoke test — confirms WAR removal took effect).
### Non-goals for α testing
- Automated browser integration (Playwright against a built extension) — γ.
- Heap-snapshot verification that master_key bytes aren't visible from JS — manual-only per broader spec; formalized in γ.
- Fuzz / property tests for the router — the message-type matrix is exhaustive.
## Audit Findings Addressed
| ID | Severity | How α addresses it |
|---|---|---|
| C1 | Critical | Setup wizard + WASM removed from WAR; sender check on `save_setup` |
| C2 | Critical | Split message router with sender-based dispatch |
| C3 | Critical | Closed Shadow DOM + textContent for all content-script UI |
| C4 | Critical | Origin-bound autofill (`sender.tab.url` only, hostname match) |
| H2 | High | `SessionHandle` opaque to JS; passphrase cleared from scope ASAP |
| H3 | High | zxcvbn strength gate in setup wizard |
| M5 | Medium | Popup captures `(tab.id, tab.url)` at open; verifies on `fill_credentials` |
| L11 | Low | `textContent` rule subsumes escaping concerns in the content-script surface |
Remaining audit items from the typed-items spec (retention UI, attachment caps UI, device-management UI, full vault-settings view) land in β/γ.
## Open Questions Deferred to the Plan
- Exact Vitest configuration — first router-test slice surfaces the final shape (`vitest.config.ts`, `tsconfig` overrides, jsdom vs happy-dom for the origin-parsing paths).
- Precise "pending ack" detection mechanism in the popup: poll `VaultSettings.autofill_origin_acks` on popup open vs a background-synthesized flag. Slice 6 decides based on latency feel.
- Whether the TOTP parse helper belongs in `shared/base32.ts` or gets added to the WASM surface as `totp_config_from_base32`. First approach is simpler; the second is more robust to charset edge cases. Slice 2 decides when wiring the Login form.

View File

@@ -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 111 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.
- [ ] **FF1FF11.** 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: '<the-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://<your-extension-id>/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. `<all_urls>` 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.*

View File

@@ -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=="],
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 886 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
<!-- 16x16-optimized: bolder strokes, simplified details, single gem
facet for crisp pixels at toolbar size. -->
<!-- Base plate -->
<rect x="1" y="13" width="14" height="2" rx="0.5" fill="#58a6ff"/>
<!-- Arched reliquary body -->
<path d="M 3 13
L 3 6
C 3 3.5, 5 2, 8 2
C 11 2, 13 3.5, 13 6
L 13 13 Z"
fill="#161b22"
stroke="#58a6ff"
stroke-width="1"
stroke-linejoin="round"/>
<!-- Seal band -->
<rect x="3" y="6" width="10" height="1" fill="#58a6ff"/>
<!-- Central gem — a simple filled diamond -->
<path d="M 8 8 L 10 10 L 8 12 L 6 10 Z" fill="#58a6ff"/>
</svg>

After

Width:  |  Height:  |  Size: 745 B

View File

@@ -1,30 +1,38 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none">
<!-- ID card outer body -->
<rect x="16" y="20" width="96" height="88" rx="6" fill="#0d1117" stroke="#58a6ff" stroke-width="3"/>
<!-- relicario: a reliquary — a vessel that holds precious things.
Arched container with a horizontal seal band, a central gem
(the "relic"), standing on a base plate.
Palette: gh-dark #0d1117/#161b22 background, #58a6ff primary,
#79c0ff / #1f6feb gem facets. -->
<!-- Photo rectangle (left side, dominant) -->
<rect x="26" y="32" width="44" height="64" rx="2" fill="#58a6ff"/>
<!-- Base plate / pedestal — extends slightly beyond the body. -->
<rect x="18" y="104" width="92" height="10" rx="2" fill="#58a6ff"/>
<rect x="18" y="112" width="92" height="2" fill="#1f6feb"/>
<!-- Silhouette in photo (cartoony connected portrait) -->
<path d="M 28 96
L 28 92
C 28 84, 34 79, 42 77
L 42 73
C 38 71, 36 66, 36 60
C 36 52, 41 46, 48 46
C 55 46, 60 52, 60 60
C 60 66, 58 71, 54 73
L 54 77
C 62 79, 68 84, 68 92
L 68 96 Z" fill="#0d1117"/>
<!-- Reliquary body: rounded arch over a rectangular casket. -->
<path d="M 28 104
L 28 54
C 28 34, 44 20, 64 20
C 84 20, 100 34, 100 54
L 100 104 Z"
fill="#161b22"
stroke="#58a6ff"
stroke-width="4"
stroke-linejoin="round"/>
<!-- Info lines (right side, suggest text without being text) -->
<rect x="78" y="36" width="24" height="4" rx="1" fill="#58a6ff"/>
<rect x="78" y="48" width="20" height="3" rx="1" fill="#30363d"/>
<rect x="78" y="56" width="24" height="3" rx="1" fill="#30363d"/>
<rect x="78" y="64" width="18" height="3" rx="1" fill="#30363d"/>
<!-- Lock icon (security indicator) -->
<rect x="82" y="84" width="16" height="12" rx="1.5" fill="#3fb950"/>
<path d="M85 84 V79 A5 5 0 0 1 95 79 V84" fill="none" stroke="#3fb950" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="90" cy="90" r="1.5" fill="#0d1117"/>
<!-- Horizontal seal band across the arch-to-body transition. -->
<rect x="26" y="56" width="76" height="5" fill="#58a6ff"/>
<!-- Small rivets at each end of the seal band. -->
<circle cx="32" cy="58.5" r="2" fill="#0d1117"/>
<circle cx="96" cy="58.5" r="2" fill="#0d1117"/>
<!-- The relic: a faceted diamond/gem centered in the casket chamber.
Three tones suggest light hitting facets. -->
<g transform="translate(64, 80)">
<path d="M 0 -18 L 16 0 L 0 22 L -16 0 Z" fill="#58a6ff"/>
<path d="M 0 -18 L 16 0 L 0 0 Z" fill="#79c0ff"/>
<path d="M -16 0 L 0 -18 L 0 0 Z" fill="#1f6feb"/>
<path d="M 0 22 L 16 0 L 0 0 Z" fill="#1f6feb" opacity="0.7"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -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": []
}

View File

@@ -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": ["<all_urls>"]
}]
"web_accessible_resources": []
}

View File

@@ -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"
}

View File

@@ -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;

View File

@@ -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<HTMLFormElement>();
const hookedButtons = new WeakSet<HTMLElement>();
let currentPrompt: ShadowSurface | null = null;
// --- Messaging ---
@@ -73,11 +80,10 @@ async function onFormSubmit(pwField: HTMLInputElement): Promise<void> {
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<void> {
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 = `
<span style="flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
${actionLabel} login for <strong style="color:#58a6ff">${escapeForHtml(hostname)}</strong>${escapeForHtml(displayUser)}?
</span>
<button id="relicario-save-btn" style="
background:#1f6feb; color:#fff; border:none; padding:5px 14px;
border-radius:3px; cursor:pointer; font-family:inherit; font-size:12px;
white-space:nowrap;
">${actionLabel}</button>
<button id="relicario-never-btn" style="
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;
">Never</button>
<button id="relicario-close-btn" style="
background:transparent; color:#8b949e; border:none;
cursor:pointer; font-size:16px; padding:2px 6px;
font-family:inherit; line-height:1;
">\u2715</button>
`;
// Message span: "<actionLabel> login for <hostname>(<username>)?"
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 {

View File

@@ -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;

View File

@@ -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<HTMLInputElement>();
/// 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];
await handleSingleCandidate(candidates[0][0]);
} else {
showPicker(passwordField, candidates);
}
});
}
/// Fetch credentials for a single item and either fill immediately or
/// display the TOFU ack hint.
async function handleSingleCandidate(id: ItemId): Promise<void> {
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,
});
}) as Response;
if (!credResp?.ok) return;
const data = (credResp as CredentialsResponse).data;
if ('requires_ack' in data && data.requires_ack) {
showAckHint(data.hostname);
return;
}
} else {
// Multiple matches — show inline picker.
showPicker(icon, candidates);
// Discriminated union: must be the {username, password} variant here.
if ('username' in data && 'password' in data) {
fillFields(data.username, data.password);
}
});
}
/// Show a small dropdown picker below the icon for selecting among multiple candidates.
/// 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);
}

View File

@@ -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 <div> 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();
},
};
}

View File

@@ -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<typeof setInterval> | null = null;
function stopTotpTimer(): void {
if (totpInterval !== null) {
clearInterval(totpInterval);
totpInterval = null;
}
}
async function copyToClipboard(text: string): Promise<void> {
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 = `
<div class="detail-header">
<span class="detail-title">${escapeHtml(entry.name)}</span>
<button class="btn" id="back-btn" style="font-size:11px;">esc</button>
</div>
`;
// URL
if (entry.url) {
html += `
<div class="field">
<div class="label">url</div>
<div class="field-value">${escapeHtml(entry.url)}</div>
</div>
`;
}
// Username
if (entry.username) {
html += `
<div class="field">
<div class="label">username</div>
<div class="field-value" id="username-val">${escapeHtml(entry.username)}</div>
</div>
`;
}
// Password (masked by default)
html += `
<div class="field">
<div class="label">password</div>
<div class="field-value" id="password-val" style="cursor:pointer;">
<span id="password-display">********</span>
</div>
</div>
`;
// TOTP
if (entry.totp_secret) {
html += `
<div class="field">
<div class="label">totp</div>
<div class="totp-code" id="totp-code">------</div>
<div class="totp-bar"><div class="totp-bar-fill" id="totp-bar-fill" style="width:100%;"></div></div>
</div>
`;
}
// Notes
if (entry.notes) {
html += `
<div class="field">
<div class="label">notes</div>
<div class="field-value">${escapeHtml(entry.notes)}</div>
</div>
`;
}
// Group
if (entry.group) {
html += `
<div class="field">
<div class="label">group</div>
<div class="field-value">${escapeHtml(entry.group)}</div>
</div>
`;
}
// Metadata
html += `
<div class="field">
<div class="muted">updated ${escapeHtml(entry.updated_at)}</div>
</div>
`;
// Key hints
html += `
<div class="keyhints">
<span><kbd>c</kbd> copy user</span>
<span><kbd>p</kbd> copy pass</span>
${entry.totp_secret ? '<span><kbd>t</kbd> copy totp</span>' : ''}
<span><kbd>f</kbd> autofill</span>
<span><kbd>e</kbd> edit</span>
<span><kbd>d</kbd> delete</span>
</div>
`;
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<void> {
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 = `
<div class="confirm-box">
<p>Delete <strong>${escapeHtml(name)}</strong>?</p>
<button class="btn" id="cancel-delete">cancel</button>
<button class="btn btn-danger" id="confirm-delete">delete</button>
</div>
`;
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 });
}
});
}

View File

@@ -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 = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new entry' : 'edit entry'}</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group">
<label class="label" for="f-name">name *</label>
<input id="f-name" type="text" value="${escapeHtml(existing?.name ?? '')}" placeholder="GitHub">
</div>
<div class="form-group">
<label class="label" for="f-url">url</label>
<input id="f-url" type="text" value="${escapeHtml(existing?.url ?? '')}" placeholder="https://github.com/login">
</div>
<div class="form-group">
<label class="label" for="f-username">username</label>
<input id="f-username" type="text" value="${escapeHtml(existing?.username ?? '')}" placeholder="alice@example.com">
</div>
<div class="form-group">
<label class="label" for="f-password">password</label>
<div class="inline-row">
<input id="f-password" type="password" value="${escapeHtml(existing?.password ?? '')}">
<button class="btn" id="gen-btn" title="generate">gen</button>
</div>
</div>
<div class="form-group">
<label class="label" for="f-totp">totp secret</label>
<input id="f-totp" type="text" value="${escapeHtml(existing?.totp_secret ?? '')}" placeholder="JBSWY3DPEHPK3PXP">
</div>
<div class="form-group">
<label class="label" for="f-group">group</label>
<input id="f-group" type="text" value="${escapeHtml(existing?.group ?? '')}" placeholder="work">
</div>
<div class="form-group">
<label class="label" for="f-notes">notes</label>
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(existing?.notes ?? '')}</textarea>
</div>
<div class="form-actions">
<button class="btn" id="cancel-btn">cancel</button>
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
</div>
</div>
`;
// --- 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();
}

View File

@@ -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<string>();
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
? `<div class="group-tabs">
<button class="group-tab ${!state.activeGroup ? 'active' : ''}" data-group="">all</button>
${groups.map(g =>
`<button class="group-tab ${state.activeGroup === g ? 'active' : ''}" data-group="${escapeHtml(g)}">${escapeHtml(g)}</button>`
).join('')}
</div>`
: '';
const entriesHtml = filtered.length > 0
? filtered.map(([id, e], i) => `
<div class="entry-row ${i === state.selectedIndex ? 'selected' : ''}" data-id="${escapeHtml(id)}" data-index="${i}">
<span class="entry-name">${escapeHtml(e.name)}</span>
<span class="entry-meta">${escapeHtml(e.username ?? '')}${e.username && e.url ? ' · ' : ''}${escapeHtml(domainOf(e.url))}</span>
</div>
`).join('')
: '<div class="empty">no entries</div>';
app.innerHTML = `
<div class="search-bar">
<input type="text" id="search-input" placeholder="/ search..." value="${escapeHtml(state.searchQuery)}">
</div>
${groupTabsHtml}
<div class="entry-list" id="entry-list">
${entriesHtml}
</div>
<div class="keyhints">
<span><kbd>/</kbd> search</span>
<span><kbd>+</kbd> add</span>
<span><kbd>&uarr;&darr;</kbd> nav</span>
<span><kbd>Enter</kbd> open</span>
</div>
`;
// --- 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<void> {
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;
}
}

View File

@@ -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<typeof setInterval> | null = null;
function stopTotpTimer(): void {
if (totpInterval !== null) {
clearInterval(totpInterval);
totpInterval = null;
}
}
async function copyToClipboard(text: string): Promise<void> {
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 = `
<div class="detail-header">
<span class="detail-title">${escapeHtml(item.title)}</span>
<button class="btn" id="back-btn" style="font-size:11px;">esc</button>
</div>
`;
if (core.url) {
html += `
<div class="field">
<div class="label">url</div>
<div class="field-value"><a href="${escapeHtml(core.url)}" target="_blank" rel="noopener noreferrer" style="color:#58a6ff; text-decoration:none;">${escapeHtml(core.url)}</a></div>
</div>
`;
}
if (core.username) {
html += `
<div class="field">
<div class="label">username</div>
<div class="field-value" id="username-val" style="cursor:pointer;" title="click to copy">${escapeHtml(core.username)}</div>
</div>
`;
}
html += `
<div class="field">
<div class="label">password</div>
<div class="field-value" id="password-val" style="cursor:pointer;" title="click to toggle">
<span id="password-display">********</span>
<button class="btn" id="password-copy" style="font-size:10px; margin-left:8px; padding:2px 6px;">copy</button>
</div>
</div>
`;
if (hasTotp) {
html += `
<div class="field">
<div class="label">totp</div>
<div class="totp-code" id="totp-code">------</div>
<div class="totp-bar"><div class="totp-bar-fill" id="totp-bar-fill" style="width:100%;"></div></div>
</div>
`;
}
if (item.notes) {
html += `
<div class="field">
<div class="label">notes</div>
<div class="field-value" style="white-space:pre-wrap;">${escapeHtml(item.notes)}</div>
</div>
`;
}
if (item.group) {
html += `
<div class="field">
<div class="label">group</div>
<div class="field-value">${escapeHtml(item.group)}</div>
</div>
`;
}
html += `
<div class="field">
<div class="muted">modified ${escapeHtml(new Date(item.modified * 1000).toISOString())}</div>
</div>
`;
html += `
<div class="form-actions" style="padding:8px 12px;">
<button class="btn btn-primary" id="fill-btn">autofill</button>
<button class="btn" id="edit-btn">edit</button>
<button class="btn btn-danger" id="trash-btn" style="margin-left:auto;">trash</button>
</div>
`;
html += `
<div class="keyhints">
<span><kbd>c</kbd> copy user</span>
<span><kbd>p</kbd> copy pass</span>
${hasTotp ? '<span><kbd>t</kbd> copy totp</span>' : ''}
<span><kbd>f</kbd> autofill</span>
<span><kbd>e</kbd> edit</span>
<span><kbd>d</kbd> trash</span>
</div>
`;
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<void> {
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 = `
<div class="detail-header">
<span class="detail-title">${escapeHtml(item.title)}</span>
<button class="btn" id="back-btn" style="font-size:11px;">esc</button>
</div>
<div class="pad" style="text-align:center; padding:32px 16px;">
<div style="font-size:32px; margin-bottom:12px;">${typeEmoji(item.type)}</div>
<div style="font-size:14px; color:#c9d1d9; margin-bottom:4px;">${escapeHtml(item.type.replace('_', ' '))}</div>
<p class="muted">read/write for this type is coming in a later slice.</p>
<p class="muted" style="margin-top:8px;">use the CLI for now.</p>
</div>
`;
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 = `
<div class="confirm-box">
<p>Trash <strong>${escapeHtml(title)}</strong>?</p>
<button class="btn" id="cancel-delete">cancel</button>
<button class="btn btn-danger" id="confirm-delete">trash</button>
</div>
`;
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 });
}
});
}

View File

@@ -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 = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${escapeHtml(type.replace('_', ' '))}</div>
<p class="muted">editing ${escapeHtml(type)} items is coming in a later slice.</p>
<p class="muted" style="margin-top:8px;">use the CLI for now.</p>
<div class="form-actions">
<button class="btn" id="back-btn">back</button>
</div>
</div>
`;
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 = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new login' : 'edit login'}</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group">
<label class="label" for="f-title">title *</label>
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub">
</div>
<div class="form-group">
<label class="label" for="f-url">url</label>
<input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login">
</div>
<div class="form-group">
<label class="label" for="f-username">username</label>
<input id="f-username" type="text" value="${escapeHtml(username)}" placeholder="alice@example.com">
</div>
<div class="form-group">
<label class="label" for="f-password">password</label>
<div class="inline-row">
<input id="f-password" type="password" value="${escapeHtml(password)}">
<button class="btn" id="gen-btn" title="generate">gen</button>
</div>
</div>
<div class="form-group">
<label class="label" for="f-totp">totp secret (base32)</label>
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP">
</div>
<div class="form-group">
<label class="label" for="f-group">group</label>
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work">
</div>
<div class="form-group">
<label class="label" for="f-notes">notes</label>
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea>
</div>
<div class="form-actions">
<button class="btn" id="cancel-btn">cancel</button>
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
</div>
</div>
`;
// --- 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<void> {
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 });
}
}

View File

@@ -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) => `
<div class="entry-row ${i === state.selectedIndex ? 'selected' : ''}" data-id="${escapeHtml(id)}" data-index="${i}">
<span class="entry-name"><span class="type-icon" aria-hidden="true">${typeIcon(e.type)}</span> ${escapeHtml(e.title)}</span>
<span class="entry-meta">${escapeHtml(metaLine(e))}</span>
</div>
`).join('')
: '<div class="empty">no items</div>';
app.innerHTML = `
<div class="search-bar">
<input type="text" id="search-input" placeholder="/ search..." value="${escapeHtml(state.searchQuery)}">
</div>
<div class="toolbar" style="display:flex; gap:4px; padding:6px 12px; border-bottom:1px solid #21262d;">
<button class="btn" id="new-btn" style="font-size:11px;">+ new</button>
<button class="btn" id="sync-btn" style="font-size:11px;">sync</button>
<span style="flex:1;"></span>
<button class="btn" id="settings-btn" style="font-size:11px;">settings</button>
<button class="btn" id="lock-btn" style="font-size:11px;">lock</button>
</div>
<div class="entry-list" id="item-list">
${rowsHtml}
</div>
<div class="keyhints">
<span><kbd>/</kbd> search</span>
<span><kbd>+</kbd> new</span>
<span><kbd>&uarr;&darr;</kbd> nav</span>
<span><kbd>Enter</kbd> open</span>
</div>
`;
// --- 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<void> {
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;
}
}

View File

@@ -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<void> {
app.innerHTML = '<div class="pad" style="text-align:center; padding-top:20px;"><span class="spinner"></span></div>';
@@ -12,8 +12,8 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
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

View File

@@ -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 = `
<div class="pad" style="padding-top:24px;text-align:center;">
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
<div class="brand" style="font-size:16px;margin-bottom:4px;">relicario</div>
<p class="secondary" style="margin-bottom:20px;">two-factor vault</p>
<p class="muted" style="margin-bottom:16px;font-size:11px;line-height:1.6;">
No vault configured yet. Open the setup wizard to
create a new vault or connect to an existing one.
</p>
<button class="btn btn-primary" id="open-setup-btn" style="width:100%;">
open setup wizard
</button>
</div>
`;
document.getElementById('open-setup-btn')?.addEventListener('click', () => {
chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
window.close();
});
}

View File

@@ -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 });
}

View File

@@ -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<PopupState>): void {
export function sendMessage(request: Request): Promise<Response> {
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<PopupState>): 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<void> {
// 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<void> {
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;
}
}

View File

@@ -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<string, string> = 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<WasmModule> {
&& 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<VaultConfig | null> {
const result = await chrome.storage.local.get('vaultConfig');
return (result.vaultConfig as VaultConfig) ?? null;
}
async function loadImageBase64(): Promise<string | null> {
const result = await chrome.storage.local.get('imageBase64');
return (result.imageBase64 as string) ?? null;
}
async function loadSetupState(): Promise<SetupState> {
const config = await loadConfig();
const imageBase64 = await loadImageBase64();
return {
config,
imageBase64,
isConfigured: config !== null && imageBase64 !== null,
// Single router-state object shared by all messages for this SW instance.
const state: RouterState = {
manifest: null,
gitHost: null,
wasm: null,
};
}
// --- Settings & blacklist helpers ---
async function loadSettings(): Promise<RelicarioSettings> {
const result = await chrome.storage.local.get('relicarioSettings');
return (result.relicarioSettings as RelicarioSettings) ?? { ...DEFAULT_SETTINGS };
}
async function saveSettings(settings: RelicarioSettings): Promise<void> {
await chrome.storage.local.set({ relicarioSettings: settings });
}
async function loadBlacklist(): Promise<string[]> {
const result = await chrome.storage.local.get('captureBlacklist');
return (result.captureBlacklist as string[]) ?? [];
}
async function saveBlacklist(list: string[]): Promise<void> {
await chrome.storage.local.set({ captureBlacklist: list });
}
function ensureGitHost(config: VaultConfig): GitHost {
if (!gitHost) {
gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
}
return gitHost;
}
// --- Message handler ---
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<Response> {
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}` };
}
}

View File

@@ -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<typeof import('../../vault')>();
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<typeof vi.fn>).mockReset();
(chrome.tabs.sendMessage as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({
id: 42,
url: 'https://example.com/login',
});
vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue(
loginItem('https://example.com/login'),
);
(chrome.tabs.sendMessage as ReturnType<typeof vi.fn>).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();
});
});

View File

@@ -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<Response> {
const senderHost = safeHostname(sender.tab?.url ?? '');
if (!senderHost) return { ok: false, error: 'invalid_sender_url' };
switch (msg.type) {
case 'get_autofill_candidates': {
if (!state.manifest) return { ok: false, error: 'vault_locked' };
return {
ok: true,
data: { candidates: vault.findByHostname(state.manifest, senderHost) },
};
}
case 'get_credentials': {
const handle = session.getCurrent();
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
if (item.core.type !== 'login') return { ok: false, error: 'not_a_login' };
const itemHost = safeHostname(item.core.url ?? '');
if (!itemHost || itemHost !== senderHost) return { ok: false, error: 'origin_mismatch' };
// TOFU origin-ack check (VaultSettings.autofill_origin_acks):
const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
const acks = settings.autofill_origin_acks ?? {};
if (!(senderHost in acks)) {
return { ok: true, data: { requires_ack: true, hostname: senderHost } };
}
return {
ok: true,
data: {
username: item.core.username ?? '',
password: item.core.password ?? '',
},
};
}
case 'check_credential': {
const handle = session.getCurrent();
if (!handle || !state.gitHost || !state.manifest) {
return { ok: true, data: { action: 'skip' } };
}
// Settings-gating: capture off or site blacklisted → skip.
const captureSettings = await loadDeviceSettings();
if (!captureSettings.captureEnabled) return { ok: true, data: { action: 'skip' } };
const blacklist = await loadBlacklist();
if (blacklist.includes(senderHost)) return { ok: true, data: { action: 'skip' } };
const candidates = vault.findByHostname(state.manifest, senderHost);
if (candidates.length === 0) return { ok: true, data: { action: 'save' } };
for (const [itemId, entry] of candidates) {
if (entry.type !== 'login') continue;
const full = await vault.fetchAndDecryptItem(state.gitHost, handle, itemId);
if (full.core.type !== 'login') continue;
if (full.core.username === msg.username) {
if (full.core.password === msg.password) return { ok: true, data: { action: 'skip' } };
return { ok: true, data: { action: 'update', entryId: itemId, entryName: entry.title } };
}
}
return { ok: true, data: { action: 'save' } };
}
case 'blacklist_site': {
const bl = await loadBlacklist();
if (!bl.includes(senderHost)) {
bl.push(senderHost);
await saveBlacklist(bl);
}
return { ok: true };
}
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<string[]> {
const r = await chrome.storage.local.get('captureBlacklist');
return (r.captureBlacklist as string[]) ?? [];
}
async function saveBlacklist(list: string[]): Promise<void> {
await chrome.storage.local.set({ captureBlacklist: list });
}
function safeHostname(url: string): string | undefined {
try { return new URL(url).hostname; } catch { return undefined; }
}

View File

@@ -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<PopupMessage['type']> = new Set<PopupMessage['type']>([
'save_setup',
'rate_passphrase',
'is_unlocked',
]);
export async function route(
msg: Request,
state: RouterState,
sender: chrome.runtime.MessageSender,
): Promise<Response> {
const popupUrl = chrome.runtime.getURL('popup.html');
const setupUrl = chrome.runtime.getURL('setup.html');
const senderUrl = sender.url ?? '';
const isPopup = senderUrl === popupUrl;
const isSetup = senderUrl.startsWith(setupUrl);
const isContent = sender.tab !== undefined
&& sender.frameId === 0
&& sender.id === chrome.runtime.id;
if (POPUP_ONLY_TYPES.has(msg.type as never)) {
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' };
}

View File

@@ -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<Response> {
void sender; // unused in most branches; retained for symmetry with content-callable
switch (msg.type) {
case 'is_unlocked':
return { ok: true, data: { unlocked: session.getCurrent() !== null } };
case 'unlock': {
const w = state.wasm;
const config = await loadConfig();
if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' };
const imageB64 = await loadImageBase64();
if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' };
const imageBytes = base64ToUint8Array(imageB64);
if (!state.gitHost) state.gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
const meta = await vault.fetchVaultMeta(state.gitHost);
const handle = w.unlock(msg.passphrase, imageBytes, meta.salt, meta.paramsJson);
session.setCurrent(handle);
(msg as { passphrase: string }).passphrase = '';
state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle);
return { ok: true };
}
case 'lock':
session.clearCurrent();
state.manifest = null;
return { ok: true };
case 'list_items': {
if (!state.manifest) return { ok: false, error: 'vault_locked' };
return { ok: true, data: { items: vault.listItems(state.manifest, msg.group) } };
}
case 'get_item': {
const handle = session.getCurrent();
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
return { ok: true, data: { item } };
}
case 'add_item': {
const handle = session.getCurrent();
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
const id = state.wasm.new_item_id();
const item: Item = { ...msg.item, id };
await vault.encryptAndWriteItem(state.gitHost, handle, id, item, `add: ${item.title}`);
state.manifest.items[id] = itemToManifestEntry(item);
await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: add ${item.title}`);
return { ok: true, data: { id } };
}
case 'update_item': {
const handle = session.getCurrent();
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
await vault.encryptAndWriteItem(state.gitHost, handle, msg.id, msg.item, `update: ${msg.item.title}`);
state.manifest.items[msg.id] = itemToManifestEntry(msg.item);
await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: update ${msg.item.title}`);
return { ok: true };
}
case 'delete_item': {
const handle = session.getCurrent();
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
const entry = state.manifest.items[msg.id];
if (!entry) return { ok: false, error: 'item_not_found' };
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
const now = Math.floor(Date.now() / 1000);
const updated: Item = { ...item, trashed_at: now, modified: now };
await vault.encryptAndWriteItem(state.gitHost, handle, msg.id, updated, `trash: ${entry.title}`);
state.manifest.items[msg.id] = { ...entry, trashed_at: now, modified: now };
await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: trash ${entry.title}`);
return { ok: true };
}
case 'get_totp': {
const handle = session.getCurrent();
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
if (item.core.type !== 'login' || !item.core.totp) {
return { ok: false, error: 'no_totp' };
}
const now = Math.floor(Date.now() / 1000);
const code = state.wasm.totp_compute(JSON.stringify(item.core.totp), BigInt(now));
return { ok: true, data: { code: code.code, expires_at: code.expires_at } };
}
case 'sync': {
const handle = session.getCurrent();
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle);
return { ok: true };
}
case 'get_setup_state':
return { ok: true, data: await loadSetupState() };
case 'save_setup': {
await chrome.storage.local.set({
vaultConfig: msg.config,
imageBase64: msg.imageBase64,
});
state.gitHost = null;
return { ok: true };
}
case 'rate_passphrase':
return { ok: true, data: state.wasm.rate_passphrase(msg.passphrase) };
case 'generate_password': {
const password = state.wasm.generate_password(JSON.stringify(msg.request));
return { ok: true, data: { password } };
}
case 'fill_credentials':
return handleFillCredentials(msg, state);
case 'ack_autofill_origin': {
const handle = session.getCurrent();
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
const acks = { ...(settings.autofill_origin_acks ?? {}), [msg.hostname]: Math.floor(Date.now() / 1000) };
const updated = { ...settings, autofill_origin_acks: acks };
await vault.encryptAndWriteSettings(state.gitHost, handle, updated, `settings: ack origin ${msg.hostname}`);
return { ok: true };
}
case 'get_settings':
return { ok: true, data: { settings: await loadDeviceSettings() } };
case 'update_settings': {
const current = await loadDeviceSettings();
await saveDeviceSettings({ ...current, ...msg.settings });
return { ok: true };
}
case 'get_blacklist':
return { ok: true, data: { blacklist: await loadBlacklist() } };
case 'remove_blacklist': {
const bl = await loadBlacklist();
await saveBlacklist(bl.filter((h) => h !== msg.hostname));
return { ok: true };
}
}
}
// --- fill_credentials with captured-tab verification (audit M5) ---
async function handleFillCredentials(
msg: Extract<PopupMessage, { type: 'fill_credentials' }>,
state: PopupState,
): Promise<Response> {
const handle = session.getCurrent();
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
let tab: chrome.tabs.Tab;
try { tab = await chrome.tabs.get(msg.capturedTabId); }
catch { return { ok: false, error: 'captured_tab_gone' }; }
const currentHost = safeHostname(tab.url ?? '');
const capturedHost = safeHostname(msg.capturedUrl);
if (!currentHost || !capturedHost || currentHost !== capturedHost) {
return { ok: false, error: 'tab_navigated' };
}
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id);
if (item.core.type !== 'login') return { ok: false, error: 'not_a_login' };
const itemHost = safeHostname(item.core.url ?? '');
if (!itemHost || itemHost !== currentHost) return { ok: false, error: 'origin_mismatch' };
// 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<VaultConfig | null> {
const r = await chrome.storage.local.get('vaultConfig');
return (r.vaultConfig as VaultConfig) ?? null;
}
async function loadImageBase64(): Promise<string | null> {
const r = await chrome.storage.local.get('imageBase64');
return (r.imageBase64 as string) ?? null;
}
async function loadSetupState(): Promise<SetupState> {
const config = await loadConfig();
const imageBase64 = await loadImageBase64();
return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null };
}
async function loadDeviceSettings(): Promise<DeviceSettings> {
const r = await chrome.storage.local.get('relicarioSettings');
return (r.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS };
}
async function saveDeviceSettings(s: DeviceSettings): Promise<void> {
await chrome.storage.local.set({ relicarioSettings: s });
}
async function loadBlacklist(): Promise<string[]> {
const r = await chrome.storage.local.get('captureBlacklist');
return (r.captureBlacklist as string[]) ?? [];
}
async function saveBlacklist(list: string[]): Promise<void> {
await chrome.storage.local.set({ captureBlacklist: list });
}
// --- Manifest entry derivation (duplicated from index; kept here to keep handler self-contained) ---
function itemToManifestEntry(item: Item) {
return {
id: item.id,
type: item.type,
title: item.title,
tags: item.tags,
favorite: item.favorite,
group: item.group,
icon_hint: (item.core.type === 'login' && item.core.url)
? safeHostname(item.core.url) : undefined,
modified: item.modified,
trashed_at: item.trashed_at,
attachment_summaries: item.attachments.map((a) => ({
id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
})),
};
}
function safeHostname(url: string): string | undefined {
try { return new URL(url).hostname; } catch { return undefined; }
}

View File

@@ -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<vaultId, SessionHandle>` 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;
}

View File

@@ -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<VaultMeta> {
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<VaultMeta> {
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<Manifest> {
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<Entry> {
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<void> {
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<void> {
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<Item> {
const w = requireWasm();
const ciphertext = await git.readFile(`items/${id}.enc`);
return w.item_decrypt(handle, ciphertext) as Item;
}
export async function encryptAndWriteItem(
git: GitHost,
handle: SessionHandle,
id: ItemId,
item: Item,
message: string,
): Promise<void> {
const w = requireWasm();
const ciphertext = w.item_encrypt(handle, JSON.stringify(item));
await git.writeFile(`items/${id}.enc`, ciphertext, message);
}
// --- Settings (the α subset the SW reads/writes is autofill_origin_acks) ---
export async function fetchAndDecryptSettings(
git: GitHost,
handle: SessionHandle,
): Promise<VaultSettings> {
const w = requireWasm();
const ciphertext = await git.readFile('settings.enc');
return w.settings_decrypt(handle, ciphertext) as VaultSettings;
}
export async function encryptAndWriteSettings(
git: GitHost,
handle: SessionHandle,
settings: VaultSettings,
message: string,
): Promise<void> {
const w = requireWasm();
const ciphertext = w.settings_encrypt(handle, JSON.stringify(settings));
await git.writeFile('settings.enc', ciphertext, message);
}
// --- In-memory manifest helpers ---
export function listItems(
manifest: Manifest,
group?: string,
): Array<[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 (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;
});
}
/// Find entries whose URL matches the given page URL by hostname.
export function findByUrl(
/// 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,
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 false;
}
});
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);
}

View File

@@ -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, '&quot;');
}
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<Strength> {
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<typeof setTimeout> | 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<number, { text: string; cls: string }> = {
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 = '&nbsp;';
} 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 ?? '&nbsp;';
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 `
<div class="wizard-step">
<h3>create vault</h3>
<div class="form-group">
<label class="label">carrier image (JPEG)</label>
<div class="file-drop ${state.carrierImageBytes ? 'has-file' : ''}" id="file-drop">
@@ -281,23 +434,44 @@ function renderStep3(): string {
</div>
<p class="muted" style="margin-top:4px;">A 256-bit secret will be steganographically embedded in this image.</p>
</div>
<div class="pass-help">
A long phrase of unrelated words is stronger than a short complex password.
Your vault needs <strong>good</strong> (score&nbsp;≥&nbsp;3) to continue.
</div>
<div class="form-group">
<label class="label" for="passphrase">passphrase</label>
<input id="passphrase" type="password" value="${escapeHtml(state.passphrase)}" placeholder="enter a strong passphrase">
${strength ? `
<div class="strength-bar">
<div class="strength-bar-fill ${strength}"></div>
<div class="passphrase-field">
<input id="passphrase" type="${pType}" value="${escapeHtml(p)}" placeholder="enter a strong passphrase" autocomplete="new-password">
<button type="button" class="eye-btn" id="eye-btn" aria-label="toggle passphrase visibility">${pToggle}</button>
</div>
<p class="muted" style="margin-top:2px;">strength: ${strength}</p>
` : ''}
<div class="strength-bar ${meterClass}" id="strength-bar" aria-hidden="true">
<div class="seg i0"></div>
<div class="seg i1"></div>
<div class="seg i2"></div>
<div class="seg i3"></div>
<div class="seg i4"></div>
</div>
<div class="strength-row">
<p class="strength-label ${labelClass}" id="strength-label">${labelText}</p>
<p class="char-counter" id="passphrase-counter">${escapeHtml(counterText)}</p>
</div>
<p class="entropy-line" id="entropy-line" style="visibility:${entropy ? 'visible' : 'hidden'};">${escapeHtml(entropy || ' ')}</p>
</div>
<div class="form-group">
<label class="label" for="passphrase-confirm">confirm passphrase</label>
<input id="passphrase-confirm" type="password" value="${escapeHtml(state.passphraseConfirm)}" placeholder="re-enter passphrase">
<div class="passphrase-field">
<input id="passphrase-confirm" type="${cType}" value="${escapeHtml(c)}" placeholder="re-enter passphrase" autocomplete="new-password">
<span class="match-indicator ${matchState}" id="match-indicator">${matchGlyph}</span>
<button type="button" class="eye-btn" id="confirm-eye-btn" aria-label="toggle confirm visibility">${cToggle}</button>
</div>
</div>
<div class="form-actions">
<button class="btn" id="back-btn">back</button>
<button class="btn btn-primary" id="create-btn" ${state.creating ? 'disabled' : ''}>
<button class="btn btn-primary" id="create-btn" ${gateDisabled ? 'disabled' : ''}>
${state.creating ? '<span class="spinner"></span> creating...' : 'create vault'}
</button>
</div>
@@ -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();
}
});

View File

@@ -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();
});
});

View File

@@ -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);
}

View File

@@ -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<import('./types').RelicarioSettings> }
| { type: 'update_settings'; settings: Partial<DeviceSettings> }
| { 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<Response, { ok: true }> {
data: undefined;
}
// --- Typed response helpers ---
export interface IsUnlockedResponse extends Extract<Response, { ok: true }> {
data: { unlocked: boolean };
}
export interface ListEntriesResponse extends Extract<Response, { ok: true }> {
data: { entries: Array<[string, ManifestEntry]> };
export interface ListItemsResponse extends Extract<Response, { ok: true }> {
data: { items: Array<[ItemId, ManifestEntry]> };
}
export interface GetEntryResponse extends Extract<Response, { ok: true }> {
data: { entry: Entry };
}
export interface SearchEntriesResponse extends Extract<Response, { ok: true }> {
data: { entries: Array<[string, ManifestEntry]> };
export interface GetItemResponse extends Extract<Response, { ok: true }> {
data: { item: Item };
}
export interface TotpResponse extends Extract<Response, { ok: true }> {
data: { code: string; remaining_seconds: number };
data: { code: string; expires_at: number };
}
export interface AutofillCandidatesResponse extends Extract<Response, { ok: true }> {
data: { candidates: Array<[string, ManifestEntry]> };
data: { candidates: Array<[ItemId, ManifestEntry]> };
}
export interface CredentialsResponse extends Extract<Response, { ok: true }> {
data: { username: string; password: string };
data:
| { requires_ack: true; hostname: string }
| { username: string; password: string };
}
export interface SetupStateResponse extends Extract<Response, { ok: true }> {
@@ -72,3 +83,22 @@ export interface SetupStateResponse extends Extract<Response, { ok: true }> {
export interface GeneratePasswordResponse extends Extract<Response, { ok: true }> {
data: { password: string };
}
export interface RatePassphraseResponse extends Extract<Response, { ok: true }> {
data: { score: number; guesses_log10: number };
}
// --- Capability sets (consumed by the router) ---
export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
'is_unlocked', 'unlock', 'lock', 'list_items', 'get_item', 'add_item',
'update_item', 'delete_item', 'get_totp', 'sync', 'get_setup_state',
'save_setup', 'rate_passphrase', 'generate_password', 'fill_credentials',
'ack_autofill_origin', 'get_settings', 'update_settings', 'get_blacklist',
'remove_blacklist',
] as PopupMessage['type'][]);
export const CONTENT_CALLABLE_TYPES: ReadonlySet<ContentMessage['type']> = new Set([
'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site',
'capture_save_login',
] as ContentMessage['type'][]);

View File

@@ -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<u8> → JSON number array
algorithm: 'sha1' | 'sha256' | 'sha512';
digits: number;
period_seconds: number;
kind: TotpKind;
}
// --- Sections + custom fields ---
export interface Section {
name?: string;
fields: Field[];
}
export interface Field {
id: FieldId;
label: string;
kind: FieldKind;
value: FieldValue;
hidden_by_default: boolean;
}
export type FieldKind =
| 'text' | 'multiline' | 'password' | 'concealed' | 'url' | 'email'
| 'phone' | 'date' | 'month_year' | 'totp' | 'reference';
// adjacently-tagged { tag: "kind", content: "value" }
export type FieldValue =
| { kind: 'text'; value: string }
| { kind: 'multiline'; value: string }
| { kind: 'password'; value: string }
| { kind: 'concealed'; value: string }
| { kind: 'url'; value: string }
| { kind: 'email'; value: string }
| { kind: 'phone'; value: string }
| { kind: 'date'; value: string }
| { kind: 'month_year'; value: { month: number; year: number } }
| { kind: 'totp'; value: TotpConfig }
| { kind: 'reference'; value: AttachmentId };
// --- Attachments + history ---
export interface AttachmentRef {
id: AttachmentId;
filename: string;
mime_type: string;
size: number;
created: number;
}
export interface FieldHistoryEntry {
value: string;
replaced_at: number;
}
export interface AttachmentSummary {
id: AttachmentId;
filename: string;
mime_type: string;
size: number;
}
// --- Item envelope ---
export interface Item {
id: ItemId;
title: string;
type: ItemType; // Rust r#type → JSON key "type"
tags: string[];
favorite: boolean;
group?: string;
notes?: string;
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<FieldId, FieldHistoryEntry[]>;
}
/// 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<string, ManifestEntry>;
version: number;
schema_version: number; // 2
items: Record<ItemId, ManifestEntry>;
}
/// 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<string, number>;
}
// --- 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' },
};

View File

@@ -1,7 +1,13 @@
// 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.
declare module 'relicario-wasm' {
export class SessionHandle {
readonly value: number;
free(): void;
@@ -15,7 +21,7 @@ export class EncryptedAttachment {
export class TotpCode {
readonly code: string;
readonly expires_at: number;
readonly expires_at: bigint;
free(): void;
}
@@ -54,5 +60,6 @@ export function embed_image_secret(carrier: Uint8Array, secret: Uint8Array): Uin
export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode;
// Initializer (wasm-bindgen's default init function).
export default function init(module_or_path?: unknown): Promise<void>;
export function initSync(args: { module: WebAssembly.Module }): void;
}

View File

@@ -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__/**"]
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'happy-dom',
include: ['src/**/__tests__/**/*.test.ts'],
},
});

Binary file not shown.

Binary file not shown.