108 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
adlee-was-taken
65e0d3cb80 docs: update CLAUDE.md for the typed-item module layout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 18:47:08 -04:00
adlee-was-taken
c3edf9d413 test(cli): vault_dir detection (L8) + v1 vault rejection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 18:39:13 -04:00
adlee-was-taken
20350d509b test(cli): integration tests for edit/history, attachments, settings
Adds RELICARIO_TEST_ITEM_SECRET env hatch for rpassword calls in
cmd_add / cmd_edit so piped-stdin tests can exercise the password
prompt paths without a TTY.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 18:37:56 -04:00
adlee-was-taken
b263c27da9 test(cli): integration harness + basic flow tests
Uses assert_cmd + tempfile to spin up a fresh vault per test.
Covers init layout, add/list/get mask semantics, rm/restore/purge cycle,
and generate smoke. Adds RELICARIO_TEST_PASSPHRASE env-var hatch in
unlock_interactive and cmd_init so tests don't need a TTY.

Also fixes read_params in session.rs to correctly parse the nested
params.json format (kdf sub-object) rather than trying to deserialize
the whole file as KdfParams.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 18:32:45 -04:00
adlee-was-taken
494eedbbb8 init: new relicario vault (format v2) 2026-04-20 18:31:46 -04:00
adlee-was-taken
b8afec3560 feat(wasm): configure serde_wasm_bindgen for plain-object HashMap
Maps serialize as JS objects, not Maps — what the extension popup
expects. Also ships hand-written TS declarations for the bridge
(consumed by Plan 1C).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:41:41 -04:00
adlee-was-taken
92b9e64ef9 feat(wasm): attachment / generator / totp / imgsecret / id bridges
Also ports TOTP RFC 6238 compute to relicario-core::item_types::totp
so native + CLI + WASM share one implementation (audit H5: CSPRNG
via core's Uniform-sampling generator).

Adds hmac = "0.12" and sha1 = "0.10" to relicario-core deps to support
HOTP/TOTP HMAC with Sha1/Sha256/Sha512. RFC 6238 test vector (t=59,
SHA-1, 8 digits) passes: "94287082".
2026-04-20 17:39:45 -04:00
adlee-was-taken
fac2e49cf1 feat(wasm): manifest / item / settings encrypt+decrypt via SessionHandle
Adds six #[wasm_bindgen] functions (manifest_encrypt/decrypt,
item_encrypt/decrypt, settings_encrypt/decrypt) plus a native
round-trip test that verifies encrypt→core_decrypt and nonce
uniqueness without calling js-sys (serde_wasm_bindgen::from_value
is wasm32-only; documented in test comment).
2026-04-20 17:37:50 -04:00
adlee-was-taken
f3ce76d9fb feat(wasm): opaque SessionHandle bridge with unlock/lock
Master key never leaves WASM linear memory. Held in Zeroizing<[u8;32]>
inside a thread_local HashMap keyed by u32. lock() removes + zeroizes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:34:50 -04:00
adlee-was-taken
8c315654ae feat(cli): device add / list / revoke rewired to hardened git 2026-04-20 17:32:09 -04:00
adlee-was-taken
a3871ac890 feat(cli): relicario sync — pull --rebase then push via hardened git 2026-04-20 17:31:45 -04:00
adlee-was-taken
10f249d95e feat(cli): relicario settings show / trash-retention / history-retention / attachment-cap 2026-04-20 17:31:27 -04:00
adlee-was-taken
a6bad4bb3e feat(cli): relicario generate delegates to core (audit H6)
CLI no longer has its own charset-sampling path — uses the CSPRNG
generate_password / generate_passphrase in relicario-core, which use
rand::distributions::Uniform internally.
2026-04-20 17:30:56 -04:00
adlee-was-taken
cbd1dbd706 feat(cli): attachment ops — attach / attachments / extract
Respects AttachmentCaps from settings.enc; content-addressed aid
comes from core::encrypt_attachment.
2026-04-19 22:27:13 -04:00
adlee-was-taken
b5015b3e9b fix(cli): trash empty unlocks vault once, not per item
Extracted purge_item helper so cmd_trash_empty loops over it without
re-prompting for passphrase per item. Single git commit per trash empty
summarizing the count. Caught in Task 12 review.
2026-04-19 22:25:57 -04:00
adlee-was-taken
cc279bac0b feat(cli): trash ops — rm / restore / purge / trash empty
Soft-delete sets trashed_at via Item::soft_delete; restore clears it.
Purge deletes item + attachment dir and removes manifest entry.
Trash empty scans for items past settings.trash_retention.
2026-04-19 22:24:32 -04:00
adlee-was-taken
06c8903e2b feat(cli): relicario edit — interactive field updates + history
Title/group/tags always optional. Per-type prompts for core secret
fields (Login.password, Card.number, Key.material, SecureNote.body)
push the old value to field_history via a synthetic core:<key>
FieldId so rotation is audit-traceable.
2026-04-19 22:22:45 -04:00
adlee-was-taken
377d73355b feat(cli): relicario list with --type/--group/--tag/--trashed filters
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:20:26 -04:00
adlee-was-taken
ed451041b0 feat(cli): relicario get with masking, --show, and zeroize-clipboard
Secrets masked by default (audit M7). --show reveals plaintext.
--copy writes to clipboard and spawns a detached 30s auto-clear
thread holding a Zeroizing copy that wipes on drop (audit M6).
2026-04-19 22:19:15 -04:00
adlee-was-taken
fe017455d3 feat(cli): relicario add — remaining 6 item types
SecureNote, Identity, Card, Key, Document (with inline attachment),
and Totp with base32 secret decoding. Document widens the commit
to include the attachment blob path.
2026-04-19 22:16:51 -04:00
adlee-was-taken
89b22cb089 feat(cli): relicario add login with flag + interactive prompting
Unlocks vault, builds LoginCore from flags (password via rpassword if
--password-prompt), saves item + manifest, commits via hardened git.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:13:17 -04:00
adlee-was-taken
5dce2c10f9 fix(cli): init stages salt, handles --output ..-paths, zeroizes image_secret
1. Add .relicario/salt to the initial git commit so it syncs across
   devices (Argon2 salt must match at unlock time).
2. Return a proper error instead of panicking when --output has no
   filename component (e.g., trailing ..).
3. Wrap the generated 32-byte image_secret in Zeroizing for
   consistency with the passphrase + master_key handling in Task 4.

Caught in Task 6 review.
2026-04-19 22:11:10 -04:00
adlee-was-taken
a50099a066 feat(cli): relicario init creates a format-v2 vault
Prompts for a strong passphrase (zxcvbn gate via core), generates a
32-byte image secret, embeds it in the carrier JPEG, writes the
standard vault skeleton, and makes an initial git commit via the
hardened git_command helper.
2026-04-19 22:02:53 -04:00
adlee-was-taken
15e6ed9c75 feat(cli): scaffold clap surface for all typed-item commands
Every subcommand from the Plan 1B CLI spec present; bodies return
'not yet implemented' so subsequent tasks land one command at a time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:00:14 -04:00
adlee-was-taken
589d7b90b4 fix(cli): zeroize image_secret + correct atomic_write temp path
atomic_write now appends .tmp instead of replacing the extension
(manifest.enc.tmp, not manifest.tmp). image_secret is wrapped in
Zeroizing so both KDF inputs wipe on drop. Caught in Task 4 review.
2026-04-19 21:57:42 -04:00
adlee-was-taken
06d21bf7c9 feat(cli): add UnlockedVault session wrapping master_key in Zeroizing
Provides load/save helpers for Manifest/Settings/Item; atomic_write keeps
vault files consistent across crashes. main.rs is transiently broken
against the old Entry API — Task 5+ rewrites the command handlers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:44:44 -04:00
adlee-was-taken
6890926e31 feat(cli): add helpers module (vault_dir/L8, git_command/H4, iso8601/M11)
Bumps rpassword to 7.x (H7) and adds zeroize/chrono/assert_cmd dev-deps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 21:37:28 -04:00
adlee-was-taken
c8535e11f5 fix(core): correct off-by-one in imgsecret SOF bounds guard
peek_jpeg_dimensions reads jpeg[i+8] as the last byte, so the guard
should be \`i + 8 >= jpeg.len()\`, not \`i + 9 >= jpeg.len()\`. The old
guard would reject a valid SOF marker ending exactly at len()-1.
Caught in Task 2 code-quality review.
2026-04-19 21:34:53 -04:00
adlee-was-taken
7853db061e fix(core): cap imgsecret MAX_DIMENSION at 10000px (audit M3) 2026-04-19 21:27:17 -04:00
adlee-was-taken
3e0cafb269 chore: update Cargo.lock after typed-item dependency additions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 20:58:50 -04:00
adlee-was-taken
17bf47611f chore: merge rename commit into Plan 1B branch
Resolves conflicts from merging origin/main (idfoto→relicario rename):
- Kept Plan 1A's typed-item vault.rs, lib.rs, integration.rs over main's
  old entry-based versions
- Took main's relicario_dir() fix in CLI main.rs (sed had missed idfoto_dir)
- Kept Plan 1A's UnsupportedFormatVersion error variant in crypto.rs
- Kept Plan 1A's opaque Decrypt message (audit M4) in error.rs
- Deleted entry.rs (replaced by item.rs + typed modules in Plan 1A)
- Resolved Cargo.toml description to main's "relicario password manager"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 20:58:35 -04:00
adlee-was-taken
9c49e5e148 chore: reconcile Plan 1A branch with idfoto→relicario rename
Renames crate directories and sweeps identifiers so Plan 1B can reference
the post-rename names throughout.

- git mv crates/idfoto-{core,cli,wasm} → crates/relicario-{core,cli,wasm}
- sed sweep: idfoto_core/idfoto-core/IdfotoError/IDFOTO_IMAGE/.idfoto/ etc.
- All 128 relicario-core tests pass post-sweep

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 20:33:04 -04:00
adlee-was-taken
49b78203f8 chore(core): clean up Plan 1A clippy warnings
Auto-deref at &Zeroizing<[u8;32]> call sites, range pattern in generators,
useless String::into conversions in tests, unused Zeroizing import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:55:32 -04:00
adlee-was-taken
3cf09faf1e test(core): field history integration (capture, prune, round-trip)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:52:03 -04:00
adlee-was-taken
557fb95b69 test(core): integration tests for format v2 invariants
VERSION_BYTE = 0x02; v1 blobs rejected with UnsupportedFormatVersion;
length-prefix Argon2 input distinguishes collision-engineerable pairs
(audit H1 regression test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:50:29 -04:00
adlee-was-taken
9cd5924109 test(core): integration tests for generators (balance, BIP39, gate)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:49:08 -04:00
adlee-was-taken
08b1735b0e test(core): integration tests for attachments (round-trip, AID, caps)
Also derives Debug on EncryptedAttachment (required by the test's panic arm).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:47:16 -04:00
adlee-was-taken
c7064183d6 test(core): rewrite integration test for typed items
- full_workflow_login_and_note: round-trips Login + SecureNote + Manifest + Settings
- two_factor_independence: confirms image_secret + passphrase combine into the master key
- field_history_persists_through_round_trip: history survives encrypt/decrypt
- wrong_key_fails_with_opaque_decrypt: opaque error per audit M4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:45:49 -04:00
adlee-was-taken
950ae3d8dd refactor(core): delete entry.rs; finalize typed-item lib.rs re-exports
The old Entry/ManifestEntry/Manifest types are gone. CLI/extension
references break and will be fixed by Plans 1B and 1C respectively.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:43:16 -04:00
adlee-was-taken
2074677278 feat(core): add Item::prune_history honoring retention policy
Forever, LastN, and Days policies all covered. Tests verify drop order
(keeps newest), days cutoff, and forever-no-op semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:41:13 -04:00
adlee-was-taken
4a98be0dae feat(core): rewrite vault.rs for typed items
encrypt_item / decrypt_item / encrypt_manifest / decrypt_manifest /
encrypt_settings / decrypt_settings. All plaintext flows through
Zeroizing so JSON buffers are wiped on drop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:38:52 -04:00
adlee-was-taken
f673b1ddee feat(core): add encrypt_attachment + decrypt_attachment
AttachmentId is derived from sha256(plaintext) so identical content
deduplicates naturally. Size cap enforced at encrypt time, returning
IdfotoError::AttachmentTooLarge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:36:19 -04:00
adlee-was-taken
1fb0f8cc03 chore(core): Debug derive on StrengthEstimate + fix stale test comment
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:35:01 -04:00
adlee-was-taken
61b1a9710b feat(core): add BIP39 passphrase generator + zxcvbn strength gate
generate_passphrase honors word_count (3-12), separator, capitalization.
validate_passphrase_strength enforces zxcvbn score >= 3 (audit H3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:31:50 -04:00
adlee-was-taken
61d6fb723d fix(core): reject non-ASCII SymbolCharset::Custom at generate time
Avoids from_utf8 panic when Custom contains multi-byte UTF-8 chars
whose individual bytes are independently sampled into the output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:04:13 -04:00
adlee-was-taken
db3f2e15f2 feat(core): add CSPRNG random password generator with safe charset
Uses rand::distributions::Uniform for unbiased sampling (audit H6).
Safe symbols = !@#$%^&*-_=+ (excludes characters that web forms
commonly reject). Test length capped at 128 (validator upper bound).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:02:39 -04:00
adlee-was-taken
b2d8a759ef fix(core): SymbolCharset needs content="value" for Custom(String)
Same latent bug as TrashRetention/HistoryRetention — serde's
internally-tagged repr cannot merge a newtype primitive payload
into a tag object. Add regression test for Custom round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:01:09 -04:00
adlee-was-taken
266761232d feat(core): add VaultSettings with retention + generator + caps
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:30:17 -04:00
adlee-was-taken
1a30c4ffe0 feat(core): add typed-item Manifest with schema_version 2
ManifestEntry holds the per-item browse summary including derived
icon_hint (Login URL hostname) and attachment_summaries. Search matches
title or tag substring case-insensitively.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:27:40 -04:00
adlee-was-taken
a5ddbf2e40 feat(core): add Item envelope with field history + soft-delete
set_field_value() captures old values for Password, Concealed, and Totp
kinds. Soft-delete via trashed_at timestamp; restore clears it. Kind
changes on set_field_value are rejected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:25:11 -04:00
adlee-was-taken
509db707e0 feat(core): add AttachmentRef + AttachmentSummary
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:16:15 -04:00
adlee-was-taken
23f7cb76b1 feat(core): add Field, FieldKind, FieldValue, Section
Parallel kind/value enums with a validate() invariant. Password,
Concealed, and Totp kinds are marked history-tracked so the Item setter
(next task) can decide whether to capture history on update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:14:00 -04:00
adlee-was-taken
a95f92fe71 test(core): exhaustive round-trip for all seven ItemCore variants
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:11:08 -04:00
adlee-was-taken
91b4b5b7a4 feat(core): flesh out TotpCore + TotpConfig + TotpAlgorithm + TotpKind
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:09:34 -04:00
adlee-was-taken
5786d9ef1a feat(core): flesh out DocumentCore
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:08:03 -04:00
adlee-was-taken
0b0f1cea73 feat(core): flesh out KeyCore
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:06:50 -04:00
adlee-was-taken
0707628d58 feat(core): flesh out CardCore + CardKind
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:05:36 -04:00
adlee-was-taken
316036832c feat(core): flesh out IdentityCore
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:04:23 -04:00
adlee-was-taken
ee25ffed41 feat(core): flesh out SecureNoteCore (Zeroizing body)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:03:03 -04:00
adlee-was-taken
24ed740718 feat(core): flesh out LoginCore with Zeroizing<password> and Url
Also enables zeroize's `serde` feature so Zeroizing<String> can
round-trip through serde_json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:59:46 -04:00
adlee-was-taken
bc60f0a6b4 docs(core): add "type" tag-collision invariant to ItemCore
Reviewer note: flatten semantics of serde tag = "type" means no *Core
struct may ever use "type" as a field name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:58:43 -04:00
adlee-was-taken
0eac9c7991 feat(core): scaffold item_types module with ItemType + ItemCore enum
Stub LoginCore, SecureNoteCore, IdentityCore, CardCore, KeyCore,
DocumentCore, TotpCore. Tag-based serde representation with snake_case
discriminants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:55:34 -04:00
adlee-was-taken
87ead533e5 feat(core): bump VERSION_BYTE to 0x02 with typed UnsupportedFormatVersion
Clean break from v1 — no migration. Decrypting a v1 blob now returns
IdfotoError::UnsupportedFormatVersion { found: 0x01, expected: 0x02 }.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:48:49 -04:00
adlee-was-taken
2ea7658036 feat(core): length-prefixed Argon2 input + NFC + Zeroize (audit H1, H2)
derive_master_key now:
- length-prefixes passphrase and image_secret to eliminate concatenation
  ambiguity (H1)
- normalizes passphrase to UTF-8 NFC before hashing
- returns Zeroizing<[u8; 32]> so the master key is wiped on drop (H2)
- wraps the intermediate password buffer in Zeroizing for the same reason

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:57:58 -04:00
adlee-was-taken
1bd86bdb13 refactor(core): rewrite IdfotoError variants for typed items
- Decrypt is now opaque (audit M4)
- Add WeakPassphrase, AttachmentTooLarge, ItemNotFound, UnsupportedFormatVersion
- Rename EntryNotFound → ItemNotFound across remaining call sites

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:53:28 -04:00
adlee-was-taken
1e8ffb02a3 feat(core): add now_unix() and MonthYear
MonthYear used for card expiries; bounds 2000..=2099 are intentional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:51:35 -04:00
adlee-was-taken
6c601fae08 chore(core): add Default impls + FieldId uniqueness test
Code review fixups:
- ItemId/FieldId need impl Default delegating to ::new() to silence
  clippy::new_without_default
- FieldId was missing the parallel uniqueness test that ItemId has

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:50:24 -04:00
adlee-was-taken
69c2c7453b feat(core): add ItemId, FieldId, AttachmentId types
16-char hex (64-bit) random IDs for items and fields (audit M8).
AttachmentId is sha256(plaintext)[..16] for content-addressing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:47:23 -04:00
adlee-was-taken
9a5ae2c704 chore(core): add chrono wasmbind feature for WASM target
Code review flagged that chrono's clock feature requires wasmbind for
WASM builds — without it Utc::now() will fail at runtime in the
idfoto-wasm crate. Also drops the redundant hex entry in
[dev-dependencies] (already in [dependencies]).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:42:27 -04:00
adlee-was-taken
166f1418f7 chore(core): add deps for typed-item rewrite
zeroize, zxcvbn, bip39, unicode-normalization, chrono, hex, url, getrandom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:37:56 -04:00
86 changed files with 17572 additions and 3309 deletions

2
.gitignore vendored
View File

@@ -5,3 +5,5 @@ extension/node_modules/
extension/dist/
extension/dist-firefox/
extension/wasm/
reference.jpg
ref.jpg

View File

@@ -10,8 +10,10 @@ relicario is a git-backed, self-hostable password manager with a Rust core. Two-
cargo build # build everything
cargo test # run all tests (unit + integration)
cargo test -p relicario-core # core library tests only
cargo run -- --help # CLI help
cargo run -- generate -l 32 # quick smoke test
cargo test -p relicario-cli --test basic_flows # CLI integration tests
cargo build -p relicario-wasm --target wasm32-unknown-unknown # WASM target
cargo run -p relicario-cli -- --help # CLI help
cargo run -p relicario-cli -- generate --length 32 # quick smoke test
```
## Project structure
@@ -22,15 +24,26 @@ crates/
│ ├── src/
│ │ ├── lib.rs # Re-exports public API
│ │ ├── error.rs # RelicarioError enum (thiserror)
│ │ ├── crypto.rs # Argon2id KDF + XChaCha20-Poly1305 encrypt/decrypt
│ │ ├── entry.rs # Entry, ManifestEntry, Manifest structs (serde)
│ │ ├── vault.rs # encrypt_entry, decrypt_entry, encrypt_manifest, decrypt_manifest
│ │ ── imgsecret.rs # DCT-based 256-bit secret embedding in JPEGs
── tests/
── integration.rs # Full-workflow and two-factor independence tests
└── relicario-cli/ # CLI binary
└── src/
── main.rs # clap CLI: init, add, get, list, edit, rm, sync, generate, device
│ │ ├── crypto.rs # Argon2id KDF (length-prefixed, Zeroizing) + XChaCha20-Poly1305
│ │ ├── ids.rs # ItemId, FieldId, content-addressed AttachmentId
│ │ ├── time.rs # now_unix, MonthYear
│ │ ── item_types/ # per-type cores + ItemType/ItemCore enums
│ ├── item.rs # Item envelope, Field, FieldKind, FieldValue, Section
── attachment.rs # AttachmentRef, EncryptedAttachment, encrypt/decrypt helpers
│ │ ├── manifest.rs # Browse-without-decrypt index (schema_version 2)
│ ├── settings.rs # VaultSettings: retention, generator defaults, caps
── generators.rs # CSPRNG password + BIP39 + zxcvbn gate
│ │ ├── vault.rs # JSON ↔ AEAD wrappers for Item/Manifest/VaultSettings
│ │ └── imgsecret.rs # DCT steganography (MAX_DIMENSION cap)
│ └── tests/ # integration.rs, attachments.rs, generators.rs, format_v2.rs, field_history.rs
├── relicario-cli/ # `relicario` binary
│ ├── src/main.rs # clap surface + command handlers
│ ├── src/helpers.rs # vault_dir, git_command, iso8601
│ ├── src/session.rs # UnlockedVault (master key in Zeroizing)
│ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection
└── relicario-wasm/ # WASM bindings for the extension
├── src/lib.rs # #[wasm_bindgen] surface
└── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]>
```
## Key design decisions
@@ -49,14 +62,14 @@ passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG)
→ Argon2id(salt=vault_salt, m=64MiB, t=3, p=4)
→ master_key (32 bytes)
→ XChaCha20-Poly1305(nonce=random 24 bytes)
→ encrypted entry/manifest
→ encrypted Item/Manifest/VaultSettings
```
## Conventions
- Tests use fast Argon2id params (m=256, t=1, p=1) so they don't take forever.
- Test JPEGs are generated synthetically via `make_test_jpeg()` — no binary test fixtures.
- Entry IDs are random 8-char hex strings.
- Item IDs are random 8-char hex strings.
- Git history is preserved as an audit log — no squashing.
- The CLI shells out to `git` for sync — no libgit2/gitoxide dependency.

1091
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,11 +12,22 @@ path = "src/main.rs"
relicario-core = { path = "../relicario-core" }
clap = { version = "4", features = ["derive"] }
anyhow = "1"
rpassword = "5"
rpassword = "7"
arboard = "3"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
dirs = "5"
hex = "0.4"
ed25519-dalek = { version = "2", features = ["rand_core"] }
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
zeroize = "1"
url = "2"
data-encoding = "2"
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"
image = { version = "0.25", default-features = false, features = ["jpeg"] }
serde_json = "1"

View File

@@ -0,0 +1,101 @@
//! CLI-side helpers: vault dir detection, hardened git shell-out, ISO-8601
//! timestamp formatting. Kept in their own module so every command handler
//! stays terse.
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{bail, Context, Result};
use chrono::DateTime;
/// Walk up from `start` looking for a directory containing `.relicario/`.
/// Returns the vault root (the directory that contains `.relicario/`).
/// Audit L8: refuses to operate outside an initialized vault.
pub fn find_vault_dir_from(start: &Path) -> Result<PathBuf> {
let mut cur = start.to_path_buf();
loop {
if cur.join(".relicario").is_dir() {
return Ok(cur);
}
if !cur.pop() {
bail!(
"no .relicario/ directory found in {} or any parent — \
run `relicario init` first",
start.display()
);
}
}
}
/// Convenience wrapper that starts the search from `std::env::current_dir()`.
pub fn vault_dir() -> Result<PathBuf> {
let cwd = std::env::current_dir().context("failed to get current directory")?;
find_vault_dir_from(&cwd)
}
/// Path to the `.relicario/` configuration directory within the vault.
pub fn relicario_dir() -> Result<PathBuf> {
Ok(vault_dir()?.join(".relicario"))
}
/// Build a hardened `git` command — no hooks, no GPG signing, no editor.
/// Audit H4: prevents vault mutations from running hostile hooks, blocking on
/// GPG passphrase prompts (which would hold the master key alive), or entering
/// $EDITOR during rebase conflict markers.
pub fn git_command(repo: &Path, args: &[&str]) -> Command {
let mut cmd = Command::new("git");
cmd.current_dir(repo);
cmd.args([
"-c", "core.hooksPath=/dev/null",
"-c", "commit.gpgsign=false",
"-c", "core.editor=true",
]);
cmd.args(args);
cmd
}
/// Format a Unix-seconds timestamp as an ISO-8601 UTC string.
/// Audit M11: replaces the old `now_iso8601` helper that actually returned
/// a numeric string.
pub fn iso8601(unix_seconds: i64) -> String {
DateTime::from_timestamp(unix_seconds, 0)
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
.unwrap_or_else(|| format!("invalid-timestamp:{unix_seconds}"))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn vault_dir_finds_marker_in_cwd() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join(".relicario")).unwrap();
let found = find_vault_dir_from(tmp.path()).unwrap();
assert_eq!(found, tmp.path());
}
#[test]
fn vault_dir_finds_marker_in_parent() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join(".relicario")).unwrap();
let subdir = tmp.path().join("sub/nested");
std::fs::create_dir_all(&subdir).unwrap();
let found = find_vault_dir_from(&subdir).unwrap();
assert_eq!(found, tmp.path());
}
#[test]
fn vault_dir_errors_when_missing() {
let tmp = TempDir::new().unwrap();
let err = find_vault_dir_from(tmp.path()).unwrap_err();
assert!(err.to_string().contains(".relicario"));
}
#[test]
fn iso8601_formats_fixed_timestamp() {
// 2026-04-19T00:00:00Z = 1776556800
assert_eq!(iso8601(1_776_556_800), "2026-04-19T00:00:00Z");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,151 @@
//! Unlocked-vault session: the shape every vault-mutating command works with.
//!
//! Holds the derived master key in `Zeroizing<[u8; 32]>` for the lifetime of a
//! CLI invocation. Drops it (via Zeroize) when the struct goes out of scope.
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use zeroize::Zeroizing;
use relicario_core::{
decrypt_item, decrypt_manifest, decrypt_settings,
derive_master_key, encrypt_item, encrypt_manifest, encrypt_settings,
imgsecret, Item, ItemId, KdfParams, Manifest, VaultSettings,
};
use crate::helpers::vault_dir;
/// A vault whose master key has been derived and is held in memory.
/// The key is wiped via `Zeroize` when this struct drops.
pub struct UnlockedVault {
root: PathBuf,
master_key: Zeroizing<[u8; 32]>,
}
impl UnlockedVault {
pub fn root(&self) -> &Path { &self.root }
pub fn key(&self) -> &Zeroizing<[u8; 32]> { &self.master_key }
/// Full interactive unlock flow: locate vault, prompt passphrase, locate
/// reference image, derive master key.
pub fn unlock_interactive() -> Result<Self> {
let root = vault_dir()?;
let salt = read_salt(&root)?;
let params = read_params(&root)?;
let image_path = get_image_path()?;
let image_bytes = fs::read(&image_path)
.with_context(|| format!("failed to read reference image {}", image_path.display()))?;
let image_secret = Zeroizing::new(imgsecret::extract(&image_bytes)?);
let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_PASSPHRASE") {
Zeroizing::new(p)
} else {
Zeroizing::new(
rpassword::prompt_password("Passphrase: ")
.context("failed to read passphrase")?
)
};
let master_key = derive_master_key(
passphrase.as_bytes(),
&*image_secret,
&salt,
&params,
)?;
Ok(Self { root, master_key })
}
pub fn manifest_path(&self) -> PathBuf { self.root.join("manifest.enc") }
pub fn settings_path(&self) -> PathBuf { self.root.join("settings.enc") }
pub fn item_path(&self, id: &ItemId) -> PathBuf {
self.root.join("items").join(format!("{}.enc", id.as_str()))
}
pub fn load_manifest(&self) -> Result<Manifest> {
let bytes = fs::read(self.manifest_path()).context("failed to read manifest.enc")?;
Ok(decrypt_manifest(&bytes, &self.master_key)?)
}
pub fn save_manifest(&self, manifest: &Manifest) -> Result<()> {
let bytes = encrypt_manifest(manifest, &self.master_key)?;
atomic_write(&self.manifest_path(), &bytes)
}
pub fn load_settings(&self) -> Result<VaultSettings> {
let bytes = fs::read(self.settings_path()).context("failed to read settings.enc")?;
Ok(decrypt_settings(&bytes, &self.master_key)?)
}
pub fn save_settings(&self, settings: &VaultSettings) -> Result<()> {
let bytes = encrypt_settings(settings, &self.master_key)?;
atomic_write(&self.settings_path(), &bytes)
}
pub fn load_item(&self, id: &ItemId) -> Result<Item> {
let bytes = fs::read(self.item_path(id))
.with_context(|| format!("failed to read item {}", id.as_str()))?;
Ok(decrypt_item(&bytes, &self.master_key)?)
}
pub fn save_item(&self, item: &Item) -> Result<()> {
let path = self.item_path(&item.id);
if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; }
let bytes = encrypt_item(item, &self.master_key)?;
atomic_write(&path, &bytes)
}
}
fn read_salt(root: &Path) -> Result<[u8; 32]> {
let data = fs::read(root.join(".relicario").join("salt"))
.context("failed to read .relicario/salt")?;
if data.len() != 32 { bail!("invalid salt length: {}", data.len()); }
let mut salt = [0u8; 32];
salt.copy_from_slice(&data);
Ok(salt)
}
fn read_params(root: &Path) -> Result<KdfParams> {
// params.json layout: { "format_version": 2, "kdf": { "argon2_m": ..., ... }, ... }
// We extract only the "kdf" sub-object and deserialize it as KdfParams.
#[derive(serde::Deserialize)]
struct ParamsFile {
kdf: KdfParams,
}
let s = fs::read_to_string(root.join(".relicario").join("params.json"))
.context("failed to read .relicario/params.json")?;
let pf: ParamsFile = serde_json::from_str(&s).context("failed to parse params.json")?;
Ok(pf.kdf)
}
/// Locate the reference image path via `RELICARIO_IMAGE` env var or interactive prompt.
pub fn get_image_path() -> Result<PathBuf> {
if let Ok(path) = std::env::var("RELICARIO_IMAGE") {
return Ok(PathBuf::from(path));
}
// Also accept <vault_root>/reference.jpg as a convention.
if let Ok(root) = vault_dir() {
let default = root.join("reference.jpg");
if default.exists() { return Ok(default); }
}
eprint!("Reference image path: ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
let trimmed = line.trim();
if trimmed.is_empty() { bail!("no reference image path provided"); }
Ok(PathBuf::from(trimmed))
}
/// Atomic write: write to <path>.tmp, then rename over <path>. Keeps the
/// vault file consistent if we crash mid-write.
fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
let mut tmp = path.as_os_str().to_owned();
tmp.push(".tmp");
let tmp = PathBuf::from(tmp);
fs::write(&tmp, data).with_context(|| format!("failed to write {}", tmp.display()))?;
fs::rename(&tmp, path).with_context(|| format!("failed to rename {}", path.display()))?;
Ok(())
}

View File

@@ -0,0 +1,45 @@
mod common;
use common::TestVault;
#[test]
fn attach_list_extract_round_trip() {
let v = TestVault::init();
v.run(&["add", "login", "--title", "thing",
"--username", "u", "--password", "p"]);
let payload_path = v.path().join("payload.txt");
std::fs::write(&payload_path, b"attached-bytes").unwrap();
let attach = v.run(&["attach", "thing", payload_path.to_str().unwrap()]);
assert!(attach.status.success(), "attach failed: {:?}", attach);
let list = v.run(&["attachments", "thing"]);
let stdout = String::from_utf8(list.stdout).unwrap();
assert!(stdout.contains("payload.txt"), "missing payload: {stdout}");
let aid = stdout.lines()
.find(|l| l.contains("payload.txt"))
.and_then(|l| l.split_whitespace().next())
.expect("aid token");
let out_path = v.path().join("extracted.txt");
let ex = v.run(&["extract", "thing", aid, "--out", out_path.to_str().unwrap()]);
assert!(ex.status.success(), "extract failed: {:?}", ex);
assert_eq!(std::fs::read(out_path).unwrap(), b"attached-bytes");
}
#[test]
fn attach_rejects_over_cap() {
let v = TestVault::init();
v.run(&["add", "login", "--title", "thing",
"--username", "u", "--password", "p"]);
v.run(&["settings", "attachment-cap", "--per-attachment-max-bytes", "10"]);
let big = v.path().join("big.bin");
std::fs::write(&big, vec![0u8; 100]).unwrap();
let out = v.run(&["attach", "thing", big.to_str().unwrap()]);
assert!(!out.status.success(), "expected failure; got {:?}", out);
assert!(String::from_utf8(out.stderr).unwrap().to_lowercase().contains("attachment"));
}

View File

@@ -0,0 +1,136 @@
mod common;
use assert_cmd::cargo::CommandCargoExt as _;
use common::TestVault;
#[test]
fn init_creates_expected_layout() {
let v = TestVault::init();
assert!(v.path().join(".relicario/salt").exists());
assert!(v.path().join(".relicario/params.json").exists());
assert!(v.path().join(".relicario/devices.json").exists());
assert!(v.path().join("manifest.enc").exists());
assert!(v.path().join("settings.enc").exists());
assert!(v.path().join("reference.jpg").exists());
assert!(v.path().join(".gitignore").exists());
assert!(v.path().join(".git").is_dir());
}
#[test]
fn init_params_json_is_format_v2() {
let v = TestVault::init();
let s = std::fs::read_to_string(v.path().join(".relicario/params.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(parsed["format_version"], 2);
assert_eq!(parsed["kdf"]["algorithm"], "argon2id-v0x13");
assert_eq!(parsed["aead"], "xchacha20poly1305");
}
#[test]
fn add_login_then_list_shows_it() {
let v = TestVault::init();
let out = v.run(&[
"add",
"login",
"--title",
"GitHub",
"--username",
"alice",
"--url",
"https://github.com",
"--password",
"hunter2",
]);
assert!(out.status.success(), "add failed: {:?}", out);
let out = v.run(&["list"]);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(stdout.contains("GitHub"), "list missing GitHub: {stdout}");
}
#[test]
fn get_masks_by_default_shows_with_flag() {
let v = TestVault::init();
v.run(&[
"add",
"login",
"--title",
"gmail",
"--username",
"u",
"--password",
"super-secret",
]);
let masked = v.run(&["get", "gmail"]);
let stdout = String::from_utf8(masked.stdout).unwrap();
assert!(stdout.contains("********"), "expected masked: {stdout}");
assert!(
!stdout.contains("super-secret"),
"leaked plaintext: {stdout}"
);
let shown = v.run(&["get", "gmail", "--show"]);
let stdout = String::from_utf8(shown.stdout).unwrap();
assert!(stdout.contains("super-secret"), "expected plaintext: {stdout}");
}
#[test]
fn rm_restore_purge_cycle() {
let v = TestVault::init();
v.run(&[
"add",
"login",
"--title",
"target",
"--username",
"u",
"--password",
"p",
]);
let rm = v.run(&["rm", "target"]);
assert!(rm.status.success());
let out = v.run(&["list"]);
assert!(!String::from_utf8(out.stdout).unwrap().contains("target"));
let out = v.run(&["list", "--trashed"]);
assert!(String::from_utf8(out.stdout).unwrap().contains("target"));
let restore = v.run(&["restore", "target"]);
assert!(restore.status.success());
let out = v.run(&["list"]);
assert!(String::from_utf8(out.stdout).unwrap().contains("target"));
let purge = v.run(&["purge", "target"]);
assert!(purge.status.success());
let out = v.run(&["list"]);
assert!(!String::from_utf8(out.stdout).unwrap().contains("target"));
}
#[test]
fn generate_random_and_bip39() {
let dir = tempfile::TempDir::new().unwrap();
let out = std::process::Command::cargo_bin("relicario")
.unwrap()
.current_dir(dir.path())
.args(["generate", "--length", "32"])
.output()
.unwrap();
assert!(out.status.success());
assert_eq!(
String::from_utf8(out.stdout).unwrap().trim().len(),
32
);
let out = std::process::Command::cargo_bin("relicario")
.unwrap()
.current_dir(dir.path())
.args(["generate", "--bip39", "--words", "5"])
.output()
.unwrap();
assert!(out.status.success());
let phrase = String::from_utf8(out.stdout).unwrap();
assert_eq!(phrase.trim().split(' ').count(), 5);
}

View File

@@ -0,0 +1,117 @@
//! Shared helpers for CLI integration tests.
//!
//! `TestVault::init()` spins up a fresh vault in a `TempDir` using
//! `RELICARIO_TEST_PASSPHRASE` as the escape hatch (bypasses TTY prompts).
//! Every `run()` / `run_with_input()` call sets both `RELICARIO_IMAGE` and
//! `RELICARIO_TEST_PASSPHRASE`, so vault-mutating commands unlock without
//! interactive input.
//!
//! Note for Task 23 implementers: commands that prompt for a *new item
//! password* (i.e. `edit` when changing a Login password) also use
//! `rpassword`. Plumb `RELICARIO_TEST_ITEM_PASSWORD` through `cmd_edit` in
//! main.rs, or use an item type / edit path that avoids the rpassword call.
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use assert_cmd::cargo::CommandCargoExt;
use tempfile::TempDir;
pub struct TestVault {
pub dir: TempDir,
pub reference_image: PathBuf,
pub passphrase: String,
}
impl TestVault {
pub fn init() -> Self {
let dir = TempDir::new().expect("tempdir");
let carrier = make_test_jpeg(400, 300);
let carrier_path = dir.path().join("carrier.jpg");
std::fs::write(&carrier_path, &carrier).unwrap();
let passphrase = "correct horse battery staple 2026".to_string();
let ref_path = dir.path().join("reference.jpg");
let mut cmd = Command::cargo_bin("relicario").unwrap();
cmd.current_dir(dir.path())
.env("RELICARIO_TEST_PASSPHRASE", &passphrase)
.args([
"init",
"--image",
carrier_path.to_str().unwrap(),
"--output",
ref_path.to_str().unwrap(),
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let out = cmd.output().unwrap();
assert!(
out.status.success(),
"init failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
Self {
dir,
reference_image: ref_path,
passphrase,
}
}
pub fn path(&self) -> &Path {
self.dir.path()
}
pub fn run(&self, args: &[&str]) -> std::process::Output {
let mut cmd = Command::cargo_bin("relicario").unwrap();
cmd.current_dir(self.dir.path())
.env("RELICARIO_IMAGE", &self.reference_image)
.env("RELICARIO_TEST_PASSPHRASE", &self.passphrase)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
cmd.output().unwrap()
}
pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output {
let mut cmd = Command::cargo_bin("relicario").unwrap();
cmd.current_dir(self.dir.path())
.env("RELICARIO_IMAGE", &self.reference_image)
.env("RELICARIO_TEST_PASSPHRASE", &self.passphrase)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().unwrap();
{
let stdin = child.stdin.as_mut().unwrap();
for line in extra {
writeln!(stdin, "{line}").unwrap();
}
}
child.wait_with_output().unwrap()
}
}
pub fn make_test_jpeg(w: u32, h: u32) -> Vec<u8> {
use image::codecs::jpeg::JpegEncoder;
use image::{ExtendedColorType, ImageBuffer, ImageEncoder, Rgb};
let img = ImageBuffer::from_fn(w, h, |x, y| {
Rgb([
((x * 7 + y * 13) % 256) as u8,
((x * 11 + y * 3) % 256) as u8,
((x * 5 + y * 17) % 256) as u8,
])
});
let mut out = Vec::new();
JpegEncoder::new_with_quality(&mut out, 92)
.write_image(img.as_raw(), w, h, ExtendedColorType::Rgb8)
.unwrap();
out
}

View File

@@ -0,0 +1,59 @@
mod common;
use common::TestVault;
#[test]
fn edit_password_captures_history() {
let v = TestVault::init();
v.run(&["add", "login", "--title", "bank",
"--username", "u", "--password", "first-pw"]);
// edit: accept defaults on title/group/tags/username/url, then change pw.
let out = run_edit_with_pw_change(&v, "bank", "second-pw");
assert!(out.status.success(), "edit failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr));
// Verify the edit commit exists in git log.
let log = std::process::Command::new("git")
.current_dir(v.path()).args(["log", "--oneline"])
.output().unwrap();
let log_str = String::from_utf8(log.stdout).unwrap();
assert!(log_str.contains("edit: bank"), "missing edit commit: {log_str}");
// And the item file has been re-written (there's a single items/<id>.enc).
let items_dir = v.path().join("items");
let entries: Vec<_> = std::fs::read_dir(&items_dir).unwrap()
.map(|e| e.unwrap().path()).collect();
assert_eq!(entries.len(), 1);
}
/// Drives the interactive `edit` flow end-to-end:
/// 1. passphrase via env var.
/// 2. blank lines for title, group, tags, username, url.
/// 3. "y" for "Change password?"
/// 4. new password via RELICARIO_TEST_ITEM_SECRET env var.
fn run_edit_with_pw_change(v: &TestVault, query: &str, new_pw: &str) -> std::process::Output {
use assert_cmd::cargo::CommandCargoExt;
use std::io::Write;
use std::process::{Command, Stdio};
let mut cmd = Command::cargo_bin("relicario").unwrap();
cmd.current_dir(v.path())
.env("RELICARIO_IMAGE", &v.reference_image)
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
.env("RELICARIO_TEST_ITEM_SECRET", new_pw)
.args(["edit", query])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().unwrap();
{
let stdin = child.stdin.as_mut().unwrap();
// title, group, tags, username, url (keep defaults), then yes-to-change-pw.
for line in ["", "", "", "", "", "y"] {
writeln!(stdin, "{line}").unwrap();
}
}
child.wait_with_output().unwrap()
}

View File

@@ -0,0 +1,23 @@
mod common;
use common::TestVault;
#[test]
fn settings_roundtrip_trash_retention() {
let v = TestVault::init();
let out = v.run(&["settings", "show"]);
assert!(String::from_utf8(out.stdout).unwrap().contains("trash_retention"));
let out = v.run(&["settings", "trash-retention", "--days", "60"]);
assert!(out.status.success(), "set failed: {:?}", out);
let out = v.run(&["settings", "show"]);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(stdout.contains("60"), "expected 60: {stdout}");
}
#[test]
fn settings_rejects_conflicting_retention_flags() {
let v = TestVault::init();
let out = v.run(&["settings", "trash-retention", "--days", "30", "--forever"]);
assert!(!out.status.success());
}

View File

@@ -0,0 +1,59 @@
mod common;
use assert_cmd::cargo::CommandCargoExt;
use std::process::Command;
use tempfile::TempDir;
#[test]
fn list_refuses_without_vault_marker() {
let dir = TempDir::new().unwrap();
// No .relicario/ in dir — list should bail with a friendly error.
let mut cmd = Command::cargo_bin("relicario").unwrap();
let out = cmd.current_dir(dir.path())
.env("RELICARIO_TEST_PASSPHRASE", "foo")
.arg("list")
.output()
.unwrap();
assert!(!out.status.success());
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(stderr.contains(".relicario"), "expected marker hint: {stderr}");
}
#[test]
fn get_finds_vault_in_parent_dir() {
let v = common::TestVault::init();
v.run(&["add", "login", "--title", "parent-test",
"--username", "u", "--password", "p"]);
// Create a nested subdir and run `list` from inside it.
let nested = v.path().join("a/b/c");
std::fs::create_dir_all(&nested).unwrap();
let mut cmd = Command::cargo_bin("relicario").unwrap();
cmd.current_dir(&nested)
.env("RELICARIO_IMAGE", &v.reference_image)
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
.arg("list");
let out = cmd.output().unwrap();
assert!(out.status.success(), "list from nested dir failed: {:?}", out);
assert!(String::from_utf8(out.stdout).unwrap().contains("parent-test"));
}
#[test]
fn v1_vault_is_rejected_with_clear_error() {
// Synthesize an on-disk v1 vault: .idfoto/ dir with old params.json.
// Since vault_dir detection uses .relicario/, the pre-rename dir name is
// naturally rejected without any compat shim. Confirm that.
let dir = TempDir::new().unwrap();
std::fs::create_dir(dir.path().join(".idfoto")).unwrap();
let mut cmd = Command::cargo_bin("relicario").unwrap();
let out = cmd.current_dir(dir.path())
.env("RELICARIO_TEST_PASSPHRASE", "foo")
.arg("list")
.output()
.unwrap();
assert!(!out.status.success());
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(stderr.contains(".relicario"), "expected relicario marker demand: {stderr}");
}

View File

@@ -12,7 +12,19 @@ argon2 = "0.5"
chacha20poly1305 = "0.10"
rand = "0.8"
sha2 = "0.10"
sha1 = "0.10"
hmac = "0.12"
ed25519-dalek = { version = "2", features = ["rand_core"] }
image = { version = "0.25", default-features = false, features = ["jpeg"] }
# Typed-item additions
zeroize = { version = "1", features = ["zeroize_derive", "serde"] }
zxcvbn = { version = "3", default-features = false }
bip39 = { version = "2", default-features = false, features = ["std"] }
unicode-normalization = "0.1"
chrono = { version = "0.4", default-features = false, features = ["serde", "clock", "wasmbind"] }
hex = "0.4"
url = { version = "2", features = ["serde"] }
getrandom = "0.2"
[dev-dependencies]

View File

@@ -0,0 +1,166 @@
//! Attachment refs (carried on Item) and summaries (carried in Manifest).
//!
//! Encryption helpers (`encrypt_attachment`, `decrypt_attachment`) are added
//! later in Task 22 once the crypto module is settled.
use serde::{Deserialize, Serialize};
use crate::ids::AttachmentId;
/// Reference to an attachment, carried on the Item record.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttachmentRef {
pub id: AttachmentId,
pub filename: String,
pub mime_type: String,
/// Plaintext size in bytes.
pub size: u64,
/// Unix-seconds when this attachment was added.
pub created: i64,
}
/// Compact summary of an attachment, carried in the Manifest so the popup
/// can show attachment indicators without decrypting the item file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttachmentSummary {
pub id: AttachmentId,
pub filename: String,
pub mime_type: String,
pub size: u64,
}
impl From<&AttachmentRef> for AttachmentSummary {
fn from(r: &AttachmentRef) -> Self {
Self {
id: r.id.clone(),
filename: r.filename.clone(),
mime_type: r.mime_type.clone(),
size: r.size,
}
}
}
use zeroize::Zeroizing;
use crate::crypto::{decrypt, encrypt};
use crate::error::{RelicarioError, Result};
/// Encrypted attachment with the AID derived from plaintext content.
#[derive(Debug)]
pub struct EncryptedAttachment {
pub id: AttachmentId,
pub bytes: Vec<u8>,
}
/// Encrypt raw attachment bytes, deriving the [`AttachmentId`] from `sha256(plaintext)`.
///
/// Returns [`RelicarioError::AttachmentTooLarge`] immediately if `plaintext.len() > max_bytes`,
/// before any crypto work is done.
///
/// ## Call-site adaptation
///
/// `crypto::encrypt` accepts `&[u8; 32]`; we coerce `&Zeroizing<[u8; 32]>` via
/// `&**master_key` (double-deref: `Zeroizing<[u8;32]>` → `[u8;32]` → `&[u8;32]`).
pub fn encrypt_attachment(
plaintext: &[u8],
master_key: &Zeroizing<[u8; 32]>,
max_bytes: u64,
) -> Result<EncryptedAttachment> {
if plaintext.len() as u64 > max_bytes {
return Err(RelicarioError::AttachmentTooLarge {
size: plaintext.len() as u64,
max: max_bytes,
});
}
let id = AttachmentId::from_plaintext(plaintext);
let bytes = encrypt(master_key, plaintext)?;
Ok(EncryptedAttachment { id, bytes })
}
/// Decrypt a blob produced by [`encrypt_attachment`], returning the plaintext
/// wrapped in [`Zeroizing`] so it is wiped on drop.
///
/// ## Call-site adaptation
///
/// `crypto::decrypt` accepts `&[u8; 32]`; we coerce via `&**master_key`.
pub fn decrypt_attachment(
encrypted: &[u8],
master_key: &Zeroizing<[u8; 32]>,
) -> Result<Zeroizing<Vec<u8>>> {
let plaintext = decrypt(master_key, encrypted)?;
Ok(Zeroizing::new(plaintext))
}
#[cfg(test)]
mod crypto_tests {
use super::*;
fn key() -> Zeroizing<[u8; 32]> {
Zeroizing::new([0x42u8; 32])
}
#[test]
fn attachment_round_trip() {
let plaintext = b"the quick brown fox jumps over the lazy dog";
let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap();
let dec = decrypt_attachment(&enc.bytes, &key()).unwrap();
assert_eq!(dec.as_slice(), plaintext);
}
#[test]
fn attachment_id_matches_sha256() {
let plaintext = b"hello world";
let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap();
assert_eq!(enc.id, AttachmentId::from_plaintext(plaintext));
}
#[test]
fn oversize_attachment_rejected() {
let plaintext = vec![0u8; 11_000_000];
let err = encrypt_attachment(&plaintext, &key(), 10 * 1024 * 1024);
assert!(matches!(err, Err(RelicarioError::AttachmentTooLarge { .. })));
}
#[test]
fn wrong_key_fails_with_opaque_decrypt() {
let plaintext = b"x";
let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap();
let wrong = Zeroizing::new([0u8; 32]);
let err = decrypt_attachment(&enc.bytes, &wrong);
assert!(matches!(err, Err(RelicarioError::Decrypt)));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attachment_ref_round_trip() {
let r = AttachmentRef {
id: AttachmentId("0123456789abcdef".into()),
filename: "doc.pdf".into(),
mime_type: "application/pdf".into(),
size: 12345,
created: 1_700_000_000,
};
let json = serde_json::to_string(&r).unwrap();
let parsed: AttachmentRef = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.filename, "doc.pdf");
assert_eq!(parsed.size, 12345);
}
#[test]
fn attachment_summary_from_ref() {
let r = AttachmentRef {
id: AttachmentId("aabb".into()),
filename: "x.txt".into(),
mime_type: "text/plain".into(),
size: 5,
created: 0,
};
let s: AttachmentSummary = (&r).into();
assert_eq!(s.filename, "x.txt");
assert_eq!(s.id, r.id);
}
}

View File

@@ -22,7 +22,7 @@
//! [version: 1 byte] [nonce: 24 bytes] [ciphertext + Poly1305 tag: variable]
//! ```
//!
//! - **Version byte** (`0x01`): allows future format changes without ambiguity.
//! - **Version byte** (`0x02`): allows future format changes without ambiguity.
//! Decryption rejects any version it does not recognize.
//! - **Nonce** (24 bytes): randomly generated per encryption via [`OsRng`].
//! Stored alongside the ciphertext so the decryptor does not need out-of-band
@@ -50,11 +50,13 @@ use chacha20poly1305::{
};
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
use zeroize::Zeroizing;
use crate::error::{RelicarioError, Result};
/// Current binary format version. Increment this if the ciphertext layout changes.
const VERSION_BYTE: u8 = 0x01;
pub const VERSION_BYTE: u8 = 0x02;
/// XChaCha20-Poly1305 nonce length: 192 bits = 24 bytes.
const NONCE_LEN: usize = 24;
@@ -121,12 +123,12 @@ pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
));
}
let version = data[0];
if version != VERSION_BYTE {
return Err(RelicarioError::Format(format!(
"unknown version byte: 0x{:02x}",
version
)));
let found = data[0];
if found != VERSION_BYTE {
return Err(RelicarioError::UnsupportedFormatVersion {
found,
expected: VERSION_BYTE,
});
}
let nonce = XNonce::from_slice(&data[1..1 + NONCE_LEN]);
@@ -207,7 +209,7 @@ pub fn derive_master_key(
image_secret: &[u8; 32],
salt: &[u8; 32],
params: &KdfParams,
) -> Result<[u8; 32]> {
) -> Result<Zeroizing<[u8; 32]>> {
let argon2_params = Params::new(
params.argon2_m,
params.argon2_t,
@@ -218,17 +220,24 @@ pub fn derive_master_key(
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
// Concatenate passphrase + image_secret as the password input.
// This ensures both factors contribute to the derived key: knowing only
// the passphrase (without the reference image) or only the image secret
// (without the passphrase) is insufficient to derive the correct master key.
let mut password = Vec::with_capacity(passphrase.len() + 32);
password.extend_from_slice(passphrase);
// Normalize passphrase to NFC. Invalid UTF-8 bytes pass through unchanged.
let nfc_passphrase: Vec<u8> = match std::str::from_utf8(passphrase) {
Ok(s) => s.nfc().collect::<String>().into_bytes(),
Err(_) => passphrase.to_vec(),
};
// Length-prefixed concatenation: [u64_be(len(passphrase))][passphrase]
// [u64_be(32)][image_secret]
// Eliminates the (passphrase, image_secret) boundary ambiguity (audit H1).
let mut password = Zeroizing::new(Vec::with_capacity(8 + nfc_passphrase.len() + 8 + 32));
password.extend_from_slice(&(nfc_passphrase.len() as u64).to_be_bytes());
password.extend_from_slice(&nfc_passphrase);
password.extend_from_slice(&32u64.to_be_bytes());
password.extend_from_slice(image_secret);
let mut output = [0u8; 32];
let mut output = Zeroizing::new([0u8; 32]);
argon2
.hash_password_into(&password, salt, &mut output)
.hash_password_into(password.as_slice(), salt, output.as_mut())
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
Ok(output)
@@ -256,7 +265,7 @@ mod tests {
let key1 = derive_master_key(passphrase, &image_secret, &salt, &params).unwrap();
let key2 = derive_master_key(passphrase, &image_secret, &salt, &params).unwrap();
assert_eq!(key1, key2);
assert_eq!(*key1, *key2);
}
#[test]
@@ -268,7 +277,7 @@ mod tests {
let key1 = derive_master_key(b"passphrase-one", &image_secret, &salt, &params).unwrap();
let key2 = derive_master_key(b"passphrase-two", &image_secret, &salt, &params).unwrap();
assert_ne!(key1, key2);
assert_ne!(*key1, *key2);
}
#[test]
@@ -283,7 +292,7 @@ mod tests {
let key1 = derive_master_key(passphrase, &image_secret1, &salt, &params).unwrap();
let key2 = derive_master_key(passphrase, &image_secret2, &salt, &params).unwrap();
assert_ne!(key1, key2);
assert_ne!(*key1, *key2);
}
#[test]
@@ -335,7 +344,77 @@ mod tests {
let expected_len = 1 + 24 + plaintext.len() + 16;
assert_eq!(ciphertext.len(), expected_len);
// Version byte must be 0x01
assert_eq!(ciphertext[0], 0x01);
// Version byte must be 0x02
assert_eq!(ciphertext[0], 0x02);
}
#[test]
fn length_prefix_eliminates_concatenation_ambiguity() {
// Without length-prefix: ("abc", [0x44, ...]) and ("abcD", [...]) could collide.
// With length-prefix: distinct inputs always yield distinct keys.
let salt = [0u8; 32];
let params = fast_params();
// Pair A: passphrase "abc", image_secret starts with 0x44
let mut img_a = [0u8; 32]; img_a[0] = 0x44;
let key_a = derive_master_key(b"abc", &img_a, &salt, &params).unwrap();
// Pair B: passphrase "abcD" (one extra char), image_secret starts with original byte 1
let mut img_b = [0u8; 32]; img_b[0] = 0x44; // same image
let key_b = derive_master_key(b"abcD", &img_b, &salt, &params).unwrap();
// With length-prefix, the keys MUST differ.
assert_ne!(*key_a, *key_b);
}
#[test]
fn nfc_normalization_collapses_unicode_forms() {
// "café" can be written as NFC (é = U+00E9) or NFD (e + U+0301).
// Both must produce the same key after NFC normalization.
let salt = [0u8; 32];
let img = [0u8; 32];
let params = fast_params();
let nfc = "caf\u{00e9}".as_bytes(); // é precomposed
let nfd = "cafe\u{0301}".as_bytes(); // e + combining acute
let key_nfc = derive_master_key(nfc, &img, &salt, &params).unwrap();
let key_nfd = derive_master_key(nfd, &img, &salt, &params).unwrap();
assert_eq!(*key_nfc, *key_nfd);
}
#[test]
fn master_key_is_zeroized_on_drop() {
// Smoke test: master_key returns a Zeroizing<[u8; 32]>, which compiles only if
// we wrap correctly. The drop wipe is verified by the zeroize crate's tests.
let salt = [0u8; 32];
let img = [0u8; 32];
let params = fast_params();
let key: zeroize::Zeroizing<[u8; 32]> = derive_master_key(b"x", &img, &salt, &params).unwrap();
assert_eq!(key.len(), 32);
}
#[test]
fn version_byte_is_0x02() {
assert_eq!(VERSION_BYTE, 0x02);
}
#[test]
fn decrypt_rejects_v1_blob_with_typed_error() {
// Construct a v1-style blob: [0x01][24 nonce bytes][16 tag bytes].
let mut blob = vec![0x01u8];
blob.extend_from_slice(&[0u8; 24]);
blob.extend_from_slice(&[0u8; 16]);
let key = Zeroizing::new([0u8; 32]);
let err = decrypt(&*key, &blob).expect_err("v1 blob should fail decrypt");
match err {
RelicarioError::UnsupportedFormatVersion { found, expected } => {
assert_eq!(found, 0x01);
assert_eq!(expected, 0x02);
}
other => panic!("expected UnsupportedFormatVersion, got {:?}", other),
}
}
}

View File

@@ -1,335 +0,0 @@
//! Vault data model: entries, manifest entries, and the manifest index.
//!
//! The vault stores credentials in two tiers:
//!
//! 1. **Individual entries** (`entries/<id>.enc`): each file contains a single
//! [`Entry`] encrypted with the master key. Only decrypted when the user
//! needs to read or edit a specific credential.
//!
//! 2. **Manifest** (`manifest.enc`): a single encrypted file containing a
//! [`Manifest`] -- a map from entry IDs to [`ManifestEntry`] summaries.
//! This lets the CLI list and search entries by decrypting only one file,
//! rather than decrypting every entry in the vault.
//!
//! ## Entry IDs
//!
//! Entry IDs are random 8-character lowercase hex strings (4 bytes of entropy,
//! ~4 billion possible values). This is sufficient for family-scale vaults while
//! keeping filenames short and filesystem-friendly.
//!
//! ## Serialization strategy
//!
//! All structs derive `Serialize`/`Deserialize` for JSON encoding. Optional fields
//! use `#[serde(skip_serializing_if = "Option::is_none")]` to keep the JSON compact
//! -- omitting null fields reduces ciphertext size and avoids leaking structural
//! information about which optional fields a credential uses.
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// A full credential entry stored encrypted in `entries/<id>.enc`.
///
/// Contains all sensitive data for a single credential. Each entry is encrypted
/// independently, so accessing one entry does not require decrypting others.
///
/// ## Fields
///
/// - `name`: human-readable label (e.g., "GitHub", "Work Email"). Required.
/// - `url`: the login URL. Optional; used for autofill matching in the browser extension.
/// - `username`: the account username or email. Optional.
/// - `password`: the credential password. Required (this is the core secret).
/// - `notes`: free-form text (e.g., security questions, recovery codes). Optional.
/// - `totp_secret`: base32-encoded TOTP secret for 2FA. Optional.
/// - `created_at`: ISO 8601 timestamp (or Unix seconds) when the entry was created.
/// - `updated_at`: ISO 8601 timestamp (or Unix seconds) of the last modification.
/// - `group`: optional group label for organizing entries (e.g. "work", "personal").
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entry {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
pub password: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub totp_secret: Option<String>,
/// Optional group for organizing entries (e.g. "work", "personal").
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// Summary metadata for a single entry, stored in the manifest.
///
/// This is a lightweight projection of [`Entry`] that contains only the
/// non-sensitive fields needed for listing and searching. The password,
/// notes, and TOTP secret are intentionally excluded so that listing
/// entries requires decrypting only the manifest, not every individual entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestEntry {
/// Human-readable label for display and search matching.
pub name: String,
/// Login URL for search matching and browser extension autofill.
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
/// Account username for display in entry listings.
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
/// Optional group for organizing entries (e.g. "work", "personal").
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
/// Timestamp of last modification, used for sorting and display.
pub updated_at: String,
}
/// The vault manifest -- an encrypted index mapping entry IDs to their metadata.
///
/// The manifest serves two purposes:
///
/// 1. **Efficient listing**: decrypting the single manifest file is enough to show
/// all entry names, URLs, and usernames without touching individual entry files.
/// 2. **Search**: the [`search`](Manifest::search) method performs case-insensitive
/// substring matching against entry names and URLs.
///
/// The `version` field allows future schema migrations if the manifest format evolves.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
/// Map from entry ID (8-char hex string) to entry metadata.
pub entries: HashMap<String, ManifestEntry>,
/// Schema version. Currently always `1`.
pub version: u32,
}
impl Manifest {
/// Create a new empty manifest with version 1.
pub fn new() -> Self {
Manifest {
entries: HashMap::new(),
version: 1,
}
}
/// Insert or update an entry in the manifest.
///
/// If an entry with the same ID already exists, it is overwritten.
/// This is used both for `add` (new entry) and `edit` (update existing).
pub fn add_entry(&mut self, id: String, entry: ManifestEntry) {
self.entries.insert(id, entry);
}
/// Remove an entry from the manifest by ID, returning its metadata if it existed.
pub fn remove_entry(&mut self, id: &str) -> Option<ManifestEntry> {
self.entries.remove(id)
}
/// Search entries by case-insensitive substring match against name and URL.
///
/// Returns a vector of `(id, entry)` pairs for all matching entries. An entry
/// matches if the query appears in its name or URL (case-insensitive).
pub fn search(&self, query: &str) -> Vec<(&String, &ManifestEntry)> {
let q = query.to_lowercase();
self.entries
.iter()
.filter(|(_, e)| {
e.name.to_lowercase().contains(&q)
|| e.url
.as_deref()
.map(|u| u.to_lowercase().contains(&q))
.unwrap_or(false)
})
.collect()
}
}
impl Default for Manifest {
fn default() -> Self {
Self::new()
}
}
/// Generate a random 8-character hex string to use as an entry ID.
///
/// Uses 4 random bytes (32 bits of entropy), producing IDs like `"a1b2c3d4"`.
/// This gives ~4 billion possible values, which is more than sufficient for
/// a family-scale vault (typically < 1000 entries).
pub fn generate_entry_id() -> String {
let mut rng = rand::thread_rng();
let bytes: [u8; 4] = rng.gen();
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn entry_serialization_round_trip() {
let entry = Entry {
name: "GitHub".to_string(),
url: Some("https://github.com".to_string()),
username: Some("alice".to_string()),
password: "s3cr3t".to_string(),
notes: None,
totp_secret: None,
group: None,
created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
};
let json = serde_json::to_string(&entry).unwrap();
let decoded: Entry = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.name, entry.name);
assert_eq!(decoded.url, entry.url);
assert_eq!(decoded.username, entry.username);
assert_eq!(decoded.password, entry.password);
assert_eq!(decoded.notes, entry.notes);
}
#[test]
fn manifest_add_and_lookup() {
let mut manifest = Manifest::new();
let me = ManifestEntry {
name: "GitHub".to_string(),
url: Some("https://github.com".to_string()),
username: Some("alice".to_string()),
group: None,
updated_at: "2024-01-01T00:00:00Z".to_string(),
};
manifest.add_entry("abc12345".to_string(), me);
assert!(manifest.entries.contains_key("abc12345"));
assert_eq!(manifest.entries["abc12345"].name, "GitHub");
let removed = manifest.remove_entry("abc12345");
assert!(removed.is_some());
assert!(!manifest.entries.contains_key("abc12345"));
}
#[test]
fn manifest_serialization_round_trip() {
let mut manifest = Manifest::new();
manifest.add_entry(
"deadbeef".to_string(),
ManifestEntry {
name: "Gmail".to_string(),
url: Some("https://mail.google.com".to_string()),
username: Some("user@gmail.com".to_string()),
group: None,
updated_at: "2024-06-01T00:00:00Z".to_string(),
},
);
let json = serde_json::to_string(&manifest).unwrap();
let decoded: Manifest = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.version, 1);
assert!(decoded.entries.contains_key("deadbeef"));
assert_eq!(decoded.entries["deadbeef"].name, "Gmail");
}
#[test]
fn generate_entry_id_is_8_hex_chars() {
let id = generate_entry_id();
assert_eq!(id.len(), 8);
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn manifest_search_case_insensitive() {
let mut manifest = Manifest::new();
manifest.add_entry(
"id001".to_string(),
ManifestEntry {
name: "GitHub Account".to_string(),
url: Some("https://github.com".to_string()),
username: None,
group: None,
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
);
manifest.add_entry(
"id002".to_string(),
ManifestEntry {
name: "Work Email".to_string(),
url: Some("https://mail.example.com".to_string()),
username: None,
group: None,
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
);
// partial name match, case-insensitive
let results = manifest.search("github");
assert_eq!(results.len(), 1);
assert_eq!(results[0].1.name, "GitHub Account");
// partial URL match
let results = manifest.search("mail.example");
assert_eq!(results.len(), 1);
assert_eq!(results[0].1.name, "Work Email");
// no match
let results = manifest.search("nonexistent");
assert_eq!(results.len(), 0);
}
#[test]
fn entry_deserializes_without_group_field() {
// JSON from an older vault that has no "group" key — must deserialize with group: None
let json = r#"{
"name": "OldEntry",
"url": "https://example.com",
"username": "bob",
"password": "hunter2",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}"#;
let entry: Entry = serde_json::from_str(json).expect("should deserialize without group field");
assert_eq!(entry.name, "OldEntry");
assert_eq!(entry.group, None);
}
#[test]
fn manifest_entry_deserializes_without_group_field() {
// JSON from an older manifest that has no "group" key — must deserialize with group: None
let json = r#"{
"name": "OldEntry",
"url": "https://example.com",
"username": "bob",
"updated_at": "2024-01-01T00:00:00Z"
}"#;
let me: ManifestEntry = serde_json::from_str(json)
.expect("should deserialize ManifestEntry without group field");
assert_eq!(me.name, "OldEntry");
assert_eq!(me.group, None);
}
#[test]
fn entry_with_group_round_trips() {
let entry = Entry {
name: "Work Laptop".to_string(),
url: None,
username: Some("alice@corp.example".to_string()),
password: "p@ssw0rd".to_string(),
notes: None,
totp_secret: None,
group: Some("work".to_string()),
created_at: "2024-03-15T00:00:00Z".to_string(),
updated_at: "2024-03-15T00:00:00Z".to_string(),
};
let json = serde_json::to_string(&entry).unwrap();
// The group field should be present in the JSON output
assert!(json.contains("\"group\""), "serialized JSON should contain group field");
assert!(json.contains("\"work\""), "serialized JSON should contain group value");
let decoded: Entry = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.name, "Work Laptop");
assert_eq!(decoded.group, Some("work".to_string()));
}
}

View File

@@ -10,7 +10,7 @@ use thiserror::Error;
/// All errors that can originate from relicario-core operations.
///
/// Variants are ordered roughly by the pipeline stage where they occur:
/// KDF -> encryption -> decryption -> format parsing -> entry lookup -> image
/// KDF -> encryption -> decryption -> format parsing -> item lookup -> image
/// steganography -> serialization -> device keys.
#[derive(Debug, Error)]
pub enum RelicarioError {
@@ -25,12 +25,8 @@ pub enum RelicarioError {
#[error("encryption failed: {0}")]
Encrypt(String),
/// Authenticated decryption failed. This means either the wrong master key
/// was used (wrong passphrase or wrong reference image) or the ciphertext
/// was tampered with / corrupted in transit or at rest. The error message is
/// intentionally vague to avoid leaking information about which factor was
/// wrong (passphrase vs. image).
#[error("decryption failed: wrong key or corrupted data")]
/// Authenticated decryption failed. Message intentionally opaque (audit M4).
#[error("decryption failed")]
Decrypt,
/// The binary ciphertext blob does not match the expected format (e.g.,
@@ -40,10 +36,20 @@ pub enum RelicarioError {
#[error("invalid vault format: {0}")]
Format(String),
/// A vault entry was looked up by ID but does not exist in the manifest.
/// The string payload is the missing entry ID.
#[error("entry not found: {0}")]
EntryNotFound(String),
#[error("unsupported vault format version: found 0x{found:02x}, expected 0x{expected:02x}")]
UnsupportedFormatVersion { found: u8, expected: u8 },
/// An item was looked up by ID but does not exist in the manifest.
#[error("item not found: {0}")]
ItemNotFound(String),
/// A passphrase failed the strength gate at vault creation (audit H3).
#[error("passphrase strength insufficient (score {score}/4)")]
WeakPassphrase { score: u8 },
/// An attachment exceeded the per-attachment cap from VaultSettings.
#[error("attachment too large: {size} bytes > {max} bytes max")]
AttachmentTooLarge { size: u64, max: u64 },
/// A general error from the image steganography subsystem (imgsecret).
/// Covers issues like failing to decode the carrier JPEG or failing to
@@ -84,3 +90,44 @@ pub enum RelicarioError {
/// Crate-wide result alias, reducing boilerplate in function signatures.
pub type Result<T> = std::result::Result<T, RelicarioError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decrypt_error_message_is_opaque() {
let err = RelicarioError::Decrypt;
assert_eq!(format!("{}", err), "decryption failed");
}
#[test]
fn weak_passphrase_carries_score() {
let err = RelicarioError::WeakPassphrase { score: 1 };
let s = format!("{}", err);
assert!(s.contains("passphrase"));
assert!(s.contains("strength"));
}
#[test]
fn attachment_too_large_reports_sizes() {
let err = RelicarioError::AttachmentTooLarge { size: 11_000_000, max: 10_485_760 };
let s = format!("{}", err);
assert!(s.contains("11000000"));
assert!(s.contains("10485760"));
}
#[test]
fn item_not_found_carries_id() {
let err = RelicarioError::ItemNotFound("abc123".to_string());
assert!(format!("{}", err).contains("abc123"));
}
#[test]
fn unsupported_format_version_reports_byte() {
let err = RelicarioError::UnsupportedFormatVersion { found: 0x01, expected: 0x02 };
let s = format!("{}", err);
assert!(s.contains("01") || s.contains("1"));
assert!(s.contains("02") || s.contains("2"));
}
}

View File

@@ -0,0 +1,269 @@
//! Password and passphrase generators. CSPRNG-only; rejection-sampled to
//! eliminate modulo bias. Strength rating via zxcvbn.
use bip39::{Language, Mnemonic};
use rand::distributions::{Distribution, Uniform};
use rand::rngs::OsRng;
use rand::RngCore;
use zeroize::Zeroizing;
use crate::error::{RelicarioError, Result};
use crate::settings::{Capitalization, CharClasses, GeneratorRequest, SymbolCharset};
const SAFE_SYMBOLS: &[u8] = b"!@#$%^&*-_=+";
const EXTENDED_SYMBOLS: &[u8] = b"!@#$%^&*-_=+~?.";
const LOWER: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
const UPPER: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const DIGITS: &[u8] = b"0123456789";
pub fn generate_password(req: &GeneratorRequest) -> Result<Zeroizing<String>> {
match req {
GeneratorRequest::Random { length, classes, symbol_charset } => {
random_password(*length, classes, symbol_charset)
}
GeneratorRequest::Bip39 { .. } => Err(RelicarioError::Format(
"use generate_passphrase() for BIP39 requests".into(),
)),
}
}
fn random_password(
length: u32,
classes: &CharClasses,
symbol_charset: &SymbolCharset,
) -> Result<Zeroizing<String>> {
if length == 0 || length > 128 {
return Err(RelicarioError::Format("length must be 1..=128".into()));
}
let mut charset: Vec<u8> = Vec::new();
if classes.lower { charset.extend_from_slice(LOWER); }
if classes.upper { charset.extend_from_slice(UPPER); }
if classes.digits { charset.extend_from_slice(DIGITS); }
if classes.symbols {
let symbols: &[u8] = match symbol_charset {
SymbolCharset::SafeOnly => SAFE_SYMBOLS,
SymbolCharset::Extended => EXTENDED_SYMBOLS,
SymbolCharset::Custom(s) => {
if !s.is_ascii() {
return Err(RelicarioError::Format(
"SymbolCharset::Custom must be ASCII-only".into(),
));
}
s.as_bytes()
}
};
charset.extend_from_slice(symbols);
}
if charset.is_empty() {
return Err(RelicarioError::Format("at least one character class required".into()));
}
let dist = Uniform::from(0..charset.len());
let mut rng = OsRng;
let bytes: Vec<u8> = (0..length).map(|_| charset[dist.sample(&mut rng)]).collect();
Ok(Zeroizing::new(String::from_utf8(bytes).expect("ascii-only charset")))
}
pub fn generate_passphrase(req: &GeneratorRequest) -> Result<Zeroizing<String>> {
match req {
GeneratorRequest::Bip39 { word_count, separator, capitalization } => {
bip39_passphrase(*word_count, separator, *capitalization)
}
GeneratorRequest::Random { .. } => Err(RelicarioError::Format(
"use generate_password() for Random requests".into(),
)),
}
}
fn bip39_passphrase(word_count: u32, separator: &str, cap: Capitalization) -> Result<Zeroizing<String>> {
if !matches!(word_count, 3..=12) {
return Err(RelicarioError::Format("word_count must be 3..=12".into()));
}
// bip39 v2 requires entropy 128256 bits in multiples of 32 bits (4 bytes).
// We always generate 128 bits (16 bytes) → 12 words, then take the first
// word_count words. This gives full-entropy sourcing even for short passphrases.
let mut entropy = Zeroizing::new([0u8; 16]);
OsRng.fill_bytes(entropy.as_mut_slice());
let m = Mnemonic::from_entropy_in(Language::English, entropy.as_slice())
.map_err(|e| RelicarioError::Format(format!("bip39: {e}")))?;
let words: Vec<String> = m.words().take(word_count as usize).map(|w| {
match cap {
Capitalization::Lower => w.to_ascii_lowercase(),
Capitalization::Upper => w.to_ascii_uppercase(),
Capitalization::FirstOfEach | Capitalization::Title => {
let mut chars = w.chars();
chars.next().map(|c| c.to_ascii_uppercase().to_string())
.unwrap_or_default() + chars.as_str()
}
Capitalization::Mixed => {
w.chars().enumerate().map(|(i, c)| {
if i % 2 == 0 { c.to_ascii_uppercase() } else { c }
}).collect()
}
}
}).collect();
Ok(Zeroizing::new(words.join(separator)))
}
/// Returns zxcvbn's 0-4 score (higher is stronger) and the estimated guesses.
#[derive(Debug, Clone, Copy)]
pub struct StrengthEstimate {
pub score: u8,
pub guesses_log10: f64,
}
pub fn rate_passphrase(p: &str) -> StrengthEstimate {
let est = zxcvbn::zxcvbn(p, &[]);
StrengthEstimate {
score: est.score().into(),
guesses_log10: est.guesses_log10(),
}
}
/// Strength gate at vault creation (audit H3): require score >= 3.
pub fn validate_passphrase_strength(p: &str) -> Result<()> {
let est = rate_passphrase(p);
if est.score < 3 {
return Err(RelicarioError::WeakPassphrase { score: est.score });
}
Ok(())
}
#[cfg(test)]
mod bip39_tests {
use super::*;
#[test]
fn bip39_default_is_5_space_separated_words() {
let req = GeneratorRequest::Bip39 {
word_count: 5,
separator: " ".into(),
capitalization: Capitalization::Lower,
};
let pw = generate_passphrase(&req).unwrap();
assert_eq!(pw.split(' ').count(), 5);
}
#[test]
fn bip39_dash_separator() {
let req = GeneratorRequest::Bip39 {
word_count: 4,
separator: "-".into(),
capitalization: Capitalization::Lower,
};
let pw = generate_passphrase(&req).unwrap();
assert_eq!(pw.split('-').count(), 4);
assert!(!pw.contains(' '));
}
#[test]
fn bip39_first_of_each_capitalizes() {
let req = GeneratorRequest::Bip39 {
word_count: 5,
separator: " ".into(),
capitalization: Capitalization::FirstOfEach,
};
let pw = generate_passphrase(&req).unwrap();
for word in pw.split(' ') {
let first = word.chars().next().unwrap();
assert!(first.is_ascii_uppercase(), "word {word} should start uppercase");
}
}
#[test]
fn bip39_rejects_bad_word_count() {
let req = GeneratorRequest::Bip39 {
word_count: 2,
separator: " ".into(),
capitalization: Capitalization::Lower,
};
assert!(generate_passphrase(&req).is_err());
}
#[test]
fn rate_passphrase_strong_one_passes_gate() {
// 6-word bip39 passphrase
let req = GeneratorRequest::Bip39 {
word_count: 6,
separator: " ".into(),
capitalization: Capitalization::Lower,
};
let pw = generate_passphrase(&req).unwrap();
assert!(validate_passphrase_strength(&pw).is_ok());
}
#[test]
fn rate_passphrase_weak_fails_gate() {
assert!(validate_passphrase_strength("password").is_err());
assert!(validate_passphrase_strength("12345678").is_err());
assert!(validate_passphrase_strength("hunter2").is_err());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn random_default_password_is_20_chars() {
let req = GeneratorRequest::default();
let pw = generate_password(&req).unwrap();
assert_eq!(pw.len(), 20);
}
#[test]
fn rejects_zero_length() {
let req = GeneratorRequest::Random {
length: 0,
classes: CharClasses { lower: true, upper: false, digits: false, symbols: false },
symbol_charset: SymbolCharset::SafeOnly,
};
assert!(generate_password(&req).is_err());
}
#[test]
fn rejects_no_classes() {
let req = GeneratorRequest::Random {
length: 8,
classes: CharClasses { lower: false, upper: false, digits: false, symbols: false },
symbol_charset: SymbolCharset::SafeOnly,
};
assert!(generate_password(&req).is_err());
}
#[test]
fn lower_only_password_uses_lowercase() {
let req = GeneratorRequest::Random {
length: 100,
classes: CharClasses { lower: true, upper: false, digits: false, symbols: false },
symbol_charset: SymbolCharset::SafeOnly,
};
let pw = generate_password(&req).unwrap();
assert!(pw.chars().all(|c| c.is_ascii_lowercase()));
}
#[test]
fn safe_symbols_excludes_quotes_and_brackets() {
let req = GeneratorRequest::Random {
length: 128,
classes: CharClasses { lower: false, upper: false, digits: false, symbols: true },
symbol_charset: SymbolCharset::SafeOnly,
};
let pw = generate_password(&req).unwrap();
for c in pw.chars() {
assert!(!matches!(c, '\'' | '"' | '`' | ',' | ';' | ':' | '{' | '}' | '[' | ']' | '<' | '>' | '(' | ')' | '|' | '\\' | '/' | '?'),
"safe charset must not include {c}");
}
}
#[test]
fn custom_charset_rejects_non_ascii() {
let req = GeneratorRequest::Random {
length: 8,
classes: CharClasses { lower: false, upper: false, digits: false, symbols: true },
symbol_charset: SymbolCharset::Custom("ñé".into()),
};
let err = generate_password(&req);
assert!(err.is_err(), "non-ASCII custom charset must be rejected");
}
}

View File

@@ -0,0 +1,124 @@
//! Random and content-addressed identifiers for items, fields, and attachments.
//!
//! - `ItemId` and `FieldId` are random 16-char hex strings (64 bits of entropy)
//! generated via `OsRng` (audit M8: bumped from the v1 8-char/32-bit format).
//! - `AttachmentId` is the first 16 hex chars of `sha256(plaintext)` —
//! content-addressed so identical plaintext blobs deduplicate naturally in git.
use rand::rngs::OsRng;
use rand::RngCore;
use sha2::{Digest, Sha256};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ItemId(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct FieldId(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct AttachmentId(pub String);
impl ItemId {
pub fn new() -> Self {
let mut bytes = [0u8; 8];
OsRng.fill_bytes(&mut bytes);
Self(hex::encode(bytes))
}
pub fn as_str(&self) -> &str { &self.0 }
}
impl Default for ItemId {
fn default() -> Self { Self::new() }
}
impl FieldId {
pub fn new() -> Self {
let mut bytes = [0u8; 8];
OsRng.fill_bytes(&mut bytes);
Self(hex::encode(bytes))
}
pub fn as_str(&self) -> &str { &self.0 }
}
impl Default for FieldId {
fn default() -> Self { Self::new() }
}
impl AttachmentId {
pub fn from_plaintext(plaintext: &[u8]) -> Self {
let digest = Sha256::digest(plaintext);
Self(hex::encode(&digest[..8]))
}
pub fn as_str(&self) -> &str { &self.0 }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn item_id_is_16_hex_chars() {
let id = ItemId::new();
assert_eq!(id.0.len(), 16);
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn item_ids_are_unique() {
let mut seen = std::collections::HashSet::new();
for _ in 0..10_000 {
assert!(seen.insert(ItemId::new().0));
}
}
#[test]
fn field_id_is_16_hex_chars() {
let id = FieldId::new();
assert_eq!(id.0.len(), 16);
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn field_ids_are_unique() {
let mut seen = std::collections::HashSet::new();
for _ in 0..10_000 {
assert!(seen.insert(FieldId::new().0));
}
}
#[test]
fn attachment_id_is_deterministic() {
let plaintext = b"hello world";
let a = AttachmentId::from_plaintext(plaintext);
let b = AttachmentId::from_plaintext(plaintext);
assert_eq!(a, b);
}
#[test]
fn attachment_id_changes_with_plaintext() {
let a = AttachmentId::from_plaintext(b"hello");
let b = AttachmentId::from_plaintext(b"world");
assert_ne!(a, b);
}
#[test]
fn attachment_id_is_16_hex_chars() {
let id = AttachmentId::from_plaintext(b"any bytes");
assert_eq!(id.0.len(), 16);
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn ids_serialize_as_bare_strings() {
let item = ItemId("abcdef0123456789".to_string());
let json = serde_json::to_string(&item).unwrap();
assert_eq!(json, "\"abcdef0123456789\"");
let parsed: ItemId = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, item);
}
}

View File

@@ -65,6 +65,11 @@ const QUANT_STEP: f64 = 50.0;
/// this cannot hold enough 8x8 blocks for reliable embedding.
const MIN_DIMENSION: u32 = 100;
/// Maximum image dimension (width or height) in pixels. Images larger than
/// this are rejected before full decode to prevent DoS via attacker-supplied
/// oversized JPEGs (audit M3).
pub const MAX_DIMENSION: u32 = 10_000;
/// Number of secret bits to embed: 256 bits = 32 bytes.
const SECRET_BITS: usize = 256;
@@ -112,6 +117,64 @@ const EMBED_POSITIONS: [(usize, usize); 12] = [
(2, 3), // zig-zag 14-17
];
// ─── Dimension guard ─────────────────────────────────────────────────────────
/// Walk JPEG markers until we hit an SOF (start-of-frame) marker, which
/// carries the image dimensions in bytes 5..=8 of its segment.
///
/// This peek does NOT decode any pixel data, so an oversized JPEG header is
/// rejected in O(marker-count) time without allocating a frame buffer.
fn peek_jpeg_dimensions(jpeg: &[u8]) -> Result<(u32, u32)> {
let mut i = 0;
while i + 1 < jpeg.len() {
if jpeg[i] != 0xFF {
i += 1;
continue;
}
let marker = jpeg[i + 1];
match marker {
0xD8 | 0xD9 => {
i += 2;
continue;
} // SOI / EOI
0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => {
// SOFn — height in [i+5..i+7], width in [i+7..i+9]
if i + 8 >= jpeg.len() {
return Err(RelicarioError::ImgSecret("truncated SOF marker".into()));
}
let height = u16::from_be_bytes([jpeg[i + 5], jpeg[i + 6]]) as u32;
let width = u16::from_be_bytes([jpeg[i + 7], jpeg[i + 8]]) as u32;
return Ok((width, height));
}
_ => {
if i + 3 >= jpeg.len() {
return Err(RelicarioError::ImgSecret("truncated marker segment".into()));
}
let seg_len = u16::from_be_bytes([jpeg[i + 2], jpeg[i + 3]]) as usize;
i += 2 + seg_len;
}
}
}
Err(RelicarioError::ImgSecret(
"no SOF marker found in JPEG".into(),
))
}
/// Reject JPEGs that claim dimensions exceeding [`MAX_DIMENSION`].
///
/// Called at the entry point of both `embed` and `extract` to prevent
/// attacker-supplied 32000×32000 images from wedging the WASM service worker
/// during the expensive DCT extraction pass (audit M3).
fn enforce_dimension_cap(jpeg: &[u8]) -> Result<()> {
let (w, h) = peek_jpeg_dimensions(jpeg)?;
if w > MAX_DIMENSION || h > MAX_DIMENSION {
return Err(RelicarioError::ImgSecret(format!(
"image dimensions {w}x{h} exceed {MAX_DIMENSION}x{MAX_DIMENSION} cap"
)));
}
Ok(())
}
// ─── YChannel ────────────────────────────────────────────────────────────────
/// The luminance (Y) channel of an image, stored as a flat array of f64 values.
@@ -601,6 +664,7 @@ fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u
/// or does not have enough blocks for reliable embedding.
/// - [`RelicarioError::ImgSecret`] if the image cannot be decoded or re-encoded.
pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
enforce_dimension_cap(carrier_jpeg)?;
let mut y = extract_y_channel(carrier_jpeg)?;
if (y.width as u32) < MIN_DIMENSION || (y.height as u32) < MIN_DIMENSION {
@@ -672,6 +736,7 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
/// - [`RelicarioError::ExtractionFailed`] if no valid secret could be recovered
/// (image was never watermarked, or was too heavily recompressed/cropped).
pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
enforce_dimension_cap(jpeg_bytes)?;
extract_with_crop_recovery(jpeg_bytes)
}
@@ -1015,6 +1080,30 @@ mod tests {
assert_eq!(extracted, secret);
}
#[test]
fn rejects_oversized_image_without_full_decode() {
// Synthesize a JPEG header claiming 20000x20000 dimensions.
// The actual pixel data is irrelevant — the dimension peek should bail out
// before decoding any pixels.
let jpeg = build_oversized_jpeg_header(20_000, 20_000);
let result = extract(&jpeg);
assert!(matches!(result, Err(RelicarioError::ImgSecret(ref msg)) if msg.contains("dimension")));
}
fn build_oversized_jpeg_header(width: u16, height: u16) -> Vec<u8> {
// SOI + APP0 JFIF + SOF0 declaring width/height + SOS with minimal data + EOI
let mut v = vec![0xFF, 0xD8]; // SOI
v.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x10]); // APP0
v.extend_from_slice(b"JFIF\0");
v.extend_from_slice(&[0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00]);
v.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x11, 0x08]); // SOF0
v.extend_from_slice(&height.to_be_bytes());
v.extend_from_slice(&width.to_be_bytes());
v.extend_from_slice(&[0x03, 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01]);
v.extend_from_slice(&[0xFF, 0xD9]); // EOI
v
}
#[test]
fn embed_extract_survives_10pct_crop() {
let jpeg = make_test_jpeg(400, 300);

View File

@@ -0,0 +1,497 @@
//! Item envelope, sections, and custom fields.
//!
//! `FieldKind` and `FieldValue` are kept as parallel enums (rather than collapsing
//! to a single tagged enum) so the kind can be queried without inspecting the value.
//! Validation invariant: kind and value's discriminants must match — enforced at
//! construction (`Field::new`) and during deserialization (`Field::validate`).
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use url::Url;
use zeroize::Zeroizing;
use crate::error::{RelicarioError, Result};
use crate::ids::{AttachmentId, FieldId};
use crate::item_types::TotpConfig;
use crate::time::MonthYear;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FieldKind {
Text,
Multiline,
Password,
Concealed,
Url,
Email,
Phone,
Date,
MonthYear,
Totp,
Reference,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum FieldValue {
Text(String),
Multiline(String),
Password(Zeroizing<String>),
Concealed(Zeroizing<String>),
Url(Url),
Email(String),
Phone(String),
Date(NaiveDate),
MonthYear(MonthYear),
Totp(TotpConfig),
Reference(AttachmentId),
}
impl FieldValue {
pub fn kind(&self) -> FieldKind {
match self {
FieldValue::Text(_) => FieldKind::Text,
FieldValue::Multiline(_) => FieldKind::Multiline,
FieldValue::Password(_) => FieldKind::Password,
FieldValue::Concealed(_) => FieldKind::Concealed,
FieldValue::Url(_) => FieldKind::Url,
FieldValue::Email(_) => FieldKind::Email,
FieldValue::Phone(_) => FieldKind::Phone,
FieldValue::Date(_) => FieldKind::Date,
FieldValue::MonthYear(_) => FieldKind::MonthYear,
FieldValue::Totp(_) => FieldKind::Totp,
FieldValue::Reference(_) => FieldKind::Reference,
}
}
/// True if this kind triggers field-history capture on update.
pub fn is_history_tracked(&self) -> bool {
matches!(self, FieldValue::Password(_) | FieldValue::Concealed(_) | FieldValue::Totp(_))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Field {
pub id: FieldId,
pub label: String,
pub kind: FieldKind,
pub value: FieldValue,
#[serde(default)]
pub hidden_by_default: bool,
}
impl Field {
/// Construct a field, deriving `kind` from `value`.
pub fn new(label: String, value: FieldValue) -> Self {
let kind = value.kind();
Self {
id: FieldId::new(),
label,
kind,
value,
hidden_by_default: matches!(kind, FieldKind::Password | FieldKind::Concealed),
}
}
/// Verify kind/value discriminants match. Called after deserialization.
pub fn validate(&self) -> Result<()> {
if self.kind != self.value.kind() {
return Err(RelicarioError::Format(format!(
"field {}: kind {:?} does not match value discriminant {:?}",
self.id.as_str(),
self.kind,
self.value.kind()
)));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Section {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub fields: Vec<Field>,
}
use std::collections::HashMap;
use crate::attachment::AttachmentRef;
use crate::ids::ItemId;
use crate::item_types::{ItemCore, ItemType};
use crate::time::now_unix;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldHistoryEntry {
pub value: Zeroizing<String>,
pub replaced_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Item {
pub id: ItemId,
pub title: String,
pub r#type: ItemType,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub favorite: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
pub created: i64,
pub modified: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub trashed_at: Option<i64>,
pub core: ItemCore,
#[serde(default)]
pub sections: Vec<Section>,
#[serde(default)]
pub attachments: Vec<AttachmentRef>,
#[serde(default)]
pub field_history: HashMap<FieldId, Vec<FieldHistoryEntry>>,
}
impl Item {
/// Construct a new Item from a typed core; auto-fills id, type, timestamps.
pub fn new(title: String, core: ItemCore) -> Self {
let now = now_unix();
let r#type = core.item_type();
Self {
id: ItemId::new(),
title,
r#type,
tags: Vec::new(),
favorite: false,
group: None,
notes: None,
created: now,
modified: now,
trashed_at: None,
core,
sections: Vec::new(),
attachments: Vec::new(),
field_history: HashMap::new(),
}
}
/// Replace a custom field's value, capturing the previous value into
/// field_history if the field's kind is history-tracked.
pub fn set_field_value(&mut self, field_id: &FieldId, new_value: FieldValue) -> Result<()> {
for section in &mut self.sections {
if let Some(field) = section.fields.iter_mut().find(|f| &f.id == field_id) {
if field.value.kind() != new_value.kind() {
return Err(RelicarioError::Format(format!(
"field {}: cannot change kind from {:?} to {:?}",
field.id.as_str(), field.value.kind(), new_value.kind()
)));
}
if field.value.is_history_tracked() {
let serialized = serialize_history_value(&field.value)?;
self.field_history
.entry(field.id.clone())
.or_default()
.push(FieldHistoryEntry { value: serialized, replaced_at: now_unix() });
}
field.value = new_value;
self.modified = now_unix();
return Ok(());
}
}
Err(RelicarioError::Format(format!("field {} not found", field_id.as_str())))
}
pub fn soft_delete(&mut self) {
self.trashed_at = Some(now_unix());
self.modified = now_unix();
}
pub fn restore(&mut self) {
self.trashed_at = None;
self.modified = now_unix();
}
pub fn is_trashed(&self) -> bool {
self.trashed_at.is_some()
}
pub fn prune_history(&mut self, retention: &crate::settings::HistoryRetention, now: i64) {
use crate::settings::HistoryRetention;
for history in self.field_history.values_mut() {
match retention {
HistoryRetention::Forever => {}
HistoryRetention::LastN(n) => {
let n = *n as usize;
if history.len() > n {
let drop_count = history.len() - n;
history.drain(..drop_count);
}
}
HistoryRetention::Days(d) => {
let cutoff = now - (*d as i64) * 86_400;
history.retain(|e| e.replaced_at > cutoff);
}
}
}
}
}
/// Serialize a FieldValue to the string form stored in field_history.
fn serialize_history_value(value: &FieldValue) -> Result<Zeroizing<String>> {
let s = match value {
FieldValue::Password(p) => Zeroizing::new(p.as_str().to_owned()),
FieldValue::Concealed(c) => Zeroizing::new(c.as_str().to_owned()),
FieldValue::Totp(cfg) => {
// Store the base32-encoded secret string for human-recognizability.
let s = base32_encode(&cfg.secret);
Zeroizing::new(s)
}
_ => return Err(RelicarioError::Format("not a history-tracked kind".into())),
};
Ok(s)
}
/// Minimal RFC 4648 base32 (no padding) for TOTP secret history serialization.
fn base32_encode(bytes: &[u8]) -> String {
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let mut out = String::new();
let mut buffer: u32 = 0;
let mut bits: u32 = 0;
for &b in bytes {
buffer = (buffer << 8) | (b as u32);
bits += 8;
while bits >= 5 {
let idx = ((buffer >> (bits - 5)) & 0x1f) as usize;
out.push(ALPHA[idx] as char);
bits -= 5;
}
}
if bits > 0 {
let idx = ((buffer << (5 - bits)) & 0x1f) as usize;
out.push(ALPHA[idx] as char);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn field_value_kind_matches() {
let v = FieldValue::Text("hello".into());
assert_eq!(v.kind(), FieldKind::Text);
}
#[test]
fn password_field_marked_history_tracked() {
assert!(FieldValue::Password(Zeroizing::new("x".into())).is_history_tracked());
assert!(FieldValue::Concealed(Zeroizing::new("x".into())).is_history_tracked());
assert!(FieldValue::Totp(TotpConfig::default()).is_history_tracked());
assert!(!FieldValue::Text("x".into()).is_history_tracked());
assert!(!FieldValue::Url(Url::parse("https://example.com").unwrap()).is_history_tracked());
}
#[test]
fn field_new_derives_kind_from_value() {
let f = Field::new("Password".into(), FieldValue::Password(Zeroizing::new("x".into())));
assert_eq!(f.kind, FieldKind::Password);
assert!(f.hidden_by_default);
}
#[test]
fn field_new_text_not_hidden() {
let f = Field::new("Username".into(), FieldValue::Text("alice".into()));
assert!(!f.hidden_by_default);
}
#[test]
fn field_validate_catches_kind_value_mismatch() {
let f = Field {
id: FieldId::new(),
label: "x".into(),
kind: FieldKind::Password,
value: FieldValue::Text("not actually a password".into()),
hidden_by_default: false,
};
assert!(f.validate().is_err());
}
#[test]
fn field_round_trips() {
let f = Field::new("Recovery code".into(), FieldValue::Concealed(Zeroizing::new("abcd-efgh".into())));
let json = serde_json::to_string(&f).unwrap();
let parsed: Field = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.label, "Recovery code");
assert_eq!(parsed.kind, FieldKind::Concealed);
parsed.validate().unwrap();
}
#[test]
fn section_round_trip() {
let s = Section {
name: Some("Recovery codes".into()),
fields: vec![
Field::new("code1".into(), FieldValue::Concealed(Zeroizing::new("abc".into()))),
Field::new("code2".into(), FieldValue::Concealed(Zeroizing::new("def".into()))),
],
};
let json = serde_json::to_string(&s).unwrap();
let parsed: Section = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name.as_deref(), Some("Recovery codes"));
assert_eq!(parsed.fields.len(), 2);
}
#[test]
fn new_item_has_timestamps_and_id() {
let core = ItemCore::SecureNote(crate::item_types::SecureNoteCore::default());
let item = Item::new("note".into(), core);
assert_eq!(item.id.0.len(), 16);
assert_eq!(item.r#type, ItemType::SecureNote);
assert!(item.created > 0);
assert_eq!(item.created, item.modified);
assert!(item.field_history.is_empty());
}
#[test]
fn soft_delete_and_restore_round_trip() {
let core = ItemCore::Login(crate::item_types::LoginCore::default());
let mut item = Item::new("login".into(), core);
assert!(!item.is_trashed());
item.soft_delete();
assert!(item.is_trashed());
item.restore();
assert!(!item.is_trashed());
}
#[test]
fn set_field_value_captures_history_for_password() {
let core = ItemCore::Login(crate::item_types::LoginCore::default());
let mut item = Item::new("login".into(), core);
let pw_field = Field::new("Password".into(), FieldValue::Password(Zeroizing::new("old".into())));
let pw_id = pw_field.id.clone();
item.sections.push(Section { name: None, fields: vec![pw_field] });
item.set_field_value(&pw_id, FieldValue::Password(Zeroizing::new("new".into()))).unwrap();
let hist = item.field_history.get(&pw_id).expect("history should exist");
assert_eq!(hist.len(), 1);
assert_eq!(hist[0].value.as_str(), "old");
}
#[test]
fn set_field_value_does_not_capture_history_for_text() {
let core = ItemCore::Login(crate::item_types::LoginCore::default());
let mut item = Item::new("login".into(), core);
let f = Field::new("nickname".into(), FieldValue::Text("a".into()));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
item.set_field_value(&fid, FieldValue::Text("b".into())).unwrap();
assert!(item.field_history.get(&fid).is_none_or(|v| v.is_empty()));
}
#[test]
fn set_field_value_rejects_kind_change() {
let core = ItemCore::Login(crate::item_types::LoginCore::default());
let mut item = Item::new("login".into(), core);
let f = Field::new("x".into(), FieldValue::Text("a".into()));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
let err = item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("p".into())));
assert!(err.is_err());
}
#[test]
fn item_serializes_with_minimal_optional_fields() {
let core = ItemCore::SecureNote(crate::item_types::SecureNoteCore::default());
let item = Item::new("note".into(), core);
let json = serde_json::to_string(&item).unwrap();
// No "trashed_at" or "group" or "notes" should appear when None
assert!(!json.contains("trashed_at"));
assert!(!json.contains("\"group\""));
}
#[test]
fn full_item_round_trip() {
let core = ItemCore::Login(crate::item_types::LoginCore {
username: Some("alice".into()),
password: Some(Zeroizing::new("hunter2".into())),
url: Some(Url::parse("https://github.com").unwrap()),
totp: None,
});
let mut item = Item::new("GitHub".into(), core);
item.tags = vec!["work".into()];
item.favorite = true;
item.notes = Some("notes".into());
let json = serde_json::to_string(&item).unwrap();
let parsed: Item = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.title, "GitHub");
assert_eq!(parsed.tags, vec!["work".to_string()]);
assert!(parsed.favorite);
match parsed.core {
ItemCore::Login(l) => {
assert_eq!(l.username.as_deref(), Some("alice"));
}
other => panic!("expected Login, got {:?}", other),
}
}
#[test]
fn prune_history_keeps_last_n() {
use crate::settings::HistoryRetention;
let core = ItemCore::Login(crate::item_types::LoginCore::default());
let mut item = Item::new("x".into(), core);
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
for i in 1..=5 {
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}")))).unwrap();
}
assert_eq!(item.field_history[&fid].len(), 5);
item.prune_history(&HistoryRetention::LastN(3), 0);
assert_eq!(item.field_history[&fid].len(), 3);
// Keeps the MOST RECENT 3
assert_eq!(item.field_history[&fid][0].value.as_str(), "v2");
}
#[test]
fn prune_history_drops_old_entries_by_days() {
use crate::settings::HistoryRetention;
let core = ItemCore::Login(crate::item_types::LoginCore::default());
let mut item = Item::new("x".into(), core);
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
let now = 1_000_000_000;
item.field_history.insert(fid.clone(), vec![
FieldHistoryEntry { value: Zeroizing::new("old".into()), replaced_at: now - 100 * 86_400 },
FieldHistoryEntry { value: Zeroizing::new("recent".into()), replaced_at: now - 86_400 },
]);
item.prune_history(&HistoryRetention::Days(30), now);
assert_eq!(item.field_history[&fid].len(), 1);
assert_eq!(item.field_history[&fid][0].value.as_str(), "recent");
}
#[test]
fn prune_history_forever_keeps_all() {
use crate::settings::HistoryRetention;
let core = ItemCore::Login(crate::item_types::LoginCore::default());
let mut item = Item::new("x".into(), core);
item.field_history.insert(FieldId::new(), vec![
FieldHistoryEntry { value: Zeroizing::new("a".into()), replaced_at: 0 },
FieldHistoryEntry { value: Zeroizing::new("b".into()), replaced_at: 0 },
]);
item.prune_history(&HistoryRetention::Forever, 1_000_000_000);
assert_eq!(item.field_history.values().next().unwrap().len(), 2);
}
}

View File

@@ -0,0 +1,68 @@
//! Card: number, holder, expiry (MonthYear), CVV, PIN, kind.
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::time::MonthYear;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CardCore {
#[serde(skip_serializing_if = "Option::is_none")]
pub number: Option<Zeroizing<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub holder: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expiry: Option<MonthYear>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cvv: Option<Zeroizing<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pin: Option<Zeroizing<String>>,
#[serde(default)]
pub kind: CardKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CardKind {
#[default]
Credit,
Debit,
Gift,
Loyalty,
Other,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn card_full_round_trip() {
let card = CardCore {
number: Some(Zeroizing::new("4111111111111111".into())),
holder: Some("Alice Doe".into()),
expiry: Some(MonthYear::new(12, 2030).unwrap()),
cvv: Some(Zeroizing::new("123".into())),
pin: Some(Zeroizing::new("0000".into())),
kind: CardKind::Credit,
};
let json = serde_json::to_string(&card).unwrap();
let parsed: CardCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.holder.as_deref(), Some("Alice Doe"));
assert_eq!(parsed.kind, CardKind::Credit);
assert_eq!(parsed.expiry, Some(MonthYear::new(12, 2030).unwrap()));
}
#[test]
fn card_kind_default_is_credit() {
let json = "{}";
let card: CardCore = serde_json::from_str(json).unwrap();
assert_eq!(card.kind, CardKind::Credit);
}
#[test]
fn card_kind_serializes_snake_case() {
let json = serde_json::to_string(&CardKind::Loyalty).unwrap();
assert_eq!(json, "\"loyalty\"");
}
}

View File

@@ -0,0 +1,40 @@
//! Document: filename + mime + pointer to the primary attachment blob.
use serde::{Deserialize, Serialize};
use crate::ids::AttachmentId;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocumentCore {
pub filename: String,
pub mime_type: String,
pub primary_attachment: AttachmentId,
}
impl Default for DocumentCore {
fn default() -> Self {
Self {
filename: String::new(),
mime_type: "application/octet-stream".into(),
primary_attachment: AttachmentId(String::new()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn document_round_trip() {
let doc = DocumentCore {
filename: "passport.pdf".into(),
mime_type: "application/pdf".into(),
primary_attachment: AttachmentId("0123456789abcdef".into()),
};
let json = serde_json::to_string(&doc).unwrap();
let parsed: DocumentCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.filename, "passport.pdf");
assert_eq!(parsed.primary_attachment.as_str(), "0123456789abcdef");
}
}

View File

@@ -0,0 +1,45 @@
//! Identity: name, address, phone, email, date-of-birth.
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct IdentityCore {
#[serde(skip_serializing_if = "Option::is_none")]
pub full_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub address: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date_of_birth: Option<NaiveDate>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identity_full_round_trip() {
let id = IdentityCore {
full_name: Some("Alice Doe".into()),
address: Some("123 Main St\nAnytown".into()),
phone: Some("+1-555-0100".into()),
email: Some("alice@example.com".into()),
date_of_birth: NaiveDate::from_ymd_opt(1990, 4, 18),
};
let json = serde_json::to_string(&id).unwrap();
let parsed: IdentityCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.full_name.as_deref(), Some("Alice Doe"));
assert_eq!(parsed.date_of_birth, NaiveDate::from_ymd_opt(1990, 4, 18));
}
#[test]
fn empty_identity_omits_all_fields() {
let id = IdentityCore::default();
let json = serde_json::to_string(&id).unwrap();
assert_eq!(json, "{}");
}
}

View File

@@ -0,0 +1,42 @@
//! Key: arbitrary key material (Zeroizing), label, public key, algorithm.
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct KeyCore {
pub key_material: Zeroizing<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub public_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub algorithm: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_round_trip() {
let k = KeyCore {
key_material: Zeroizing::new("-----BEGIN OPENSSH PRIVATE KEY-----\n...".into()),
label: Some("yubikey-backup".into()),
public_key: Some("ssh-ed25519 AAAAC3...".into()),
algorithm: Some("ed25519".into()),
};
let json = serde_json::to_string(&k).unwrap();
let parsed: KeyCore = serde_json::from_str(&json).unwrap();
assert!(parsed.key_material.starts_with("-----BEGIN"));
assert_eq!(parsed.algorithm.as_deref(), Some("ed25519"));
}
#[test]
fn empty_key_material_round_trips() {
let k = KeyCore::default();
let json = serde_json::to_string(&k).unwrap();
let parsed: KeyCore = serde_json::from_str(&json).unwrap();
assert!(parsed.key_material.is_empty());
}
}

View File

@@ -0,0 +1,63 @@
//! Login item core: username, password (Zeroizing), URL, optional TOTP.
use serde::{Deserialize, Serialize};
use url::Url;
use zeroize::Zeroizing;
use crate::item_types::TotpConfig;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LoginCore {
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<Zeroizing<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
pub totp: Option<TotpConfig>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_login_round_trips() {
let login = LoginCore::default();
let json = serde_json::to_string(&login).unwrap();
let parsed: LoginCore = serde_json::from_str(&json).unwrap();
assert!(parsed.username.is_none());
assert!(parsed.password.is_none());
}
#[test]
fn full_login_round_trips() {
let login = LoginCore {
username: Some("alice".into()),
password: Some(Zeroizing::new("hunter2".into())),
url: Some(Url::parse("https://github.com/login").unwrap()),
totp: None,
};
let json = serde_json::to_string(&login).unwrap();
let parsed: LoginCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.username.as_deref(), Some("alice"));
assert_eq!(parsed.password.as_deref().map(String::as_str), Some("hunter2"));
assert_eq!(parsed.url.as_ref().map(Url::as_str), Some("https://github.com/login"));
}
#[test]
fn omitted_fields_dont_appear_in_json() {
let login = LoginCore {
username: Some("alice".into()),
password: None,
url: None,
totp: None,
};
let json = serde_json::to_string(&login).unwrap();
assert!(!json.contains("password"));
assert!(!json.contains("url"));
assert!(!json.contains("totp"));
assert!(json.contains("alice"));
}
}

View File

@@ -0,0 +1,127 @@
//! Per-type "core" structs for typed items.
//!
//! Each variant lives in its own submodule. The `ItemCore` enum + match
//! exhaustiveness is the extension mechanism — adding a new variant later
//! means: create the submodule, add the enum variant, fix the match arms
//! the compiler points at, register the popup form (Plan 1C).
use serde::{Deserialize, Serialize};
pub mod login;
pub mod secure_note;
pub mod identity;
pub mod card;
pub mod key;
pub mod document;
pub mod totp;
pub use login::LoginCore;
pub use secure_note::SecureNoteCore;
pub use identity::IdentityCore;
pub use card::{CardCore, CardKind};
pub use key::KeyCore;
pub use document::DocumentCore;
pub use totp::{TotpCore, TotpConfig, TotpAlgorithm, TotpKind, compute_totp_code};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ItemType {
Login,
SecureNote,
Identity,
Card,
Key,
Document,
Totp,
}
// INVARIANT: no *Core struct may have a field serialized as "type" —
// that key is reserved for serde's internal tag. Use "kind" for
// type-discriminant fields within core structs (CardKind, TotpKind).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ItemCore {
Login(LoginCore),
SecureNote(SecureNoteCore),
Identity(IdentityCore),
Card(CardCore),
Key(KeyCore),
Document(DocumentCore),
Totp(TotpCore),
}
impl ItemCore {
pub fn item_type(&self) -> ItemType {
match self {
ItemCore::Login(_) => ItemType::Login,
ItemCore::SecureNote(_) => ItemType::SecureNote,
ItemCore::Identity(_) => ItemType::Identity,
ItemCore::Card(_) => ItemType::Card,
ItemCore::Key(_) => ItemType::Key,
ItemCore::Document(_) => ItemType::Document,
ItemCore::Totp(_) => ItemType::Totp,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn item_type_serializes_snake_case() {
let json = serde_json::to_string(&ItemType::SecureNote).unwrap();
assert_eq!(json, "\"secure_note\"");
}
#[test]
fn item_core_login_round_trip_via_tag() {
use zeroize::Zeroizing;
let core = ItemCore::Login(LoginCore {
username: Some("alice".into()),
password: Some(Zeroizing::new("hunter2".into())),
url: None,
totp: None,
});
let json = serde_json::to_string(&core).unwrap();
// Tag-based: outer object has "type": "login"
assert!(json.contains("\"type\":\"login\""));
let parsed: ItemCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.item_type(), ItemType::Login);
}
#[test]
fn item_core_secure_note_round_trip_via_tag() {
use zeroize::Zeroizing;
let core = ItemCore::SecureNote(SecureNoteCore { body: Zeroizing::new("hello".into()) });
let json = serde_json::to_string(&core).unwrap();
assert!(json.contains("\"type\":\"secure_note\""));
let parsed: ItemCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.item_type(), ItemType::SecureNote);
}
#[test]
fn item_core_round_trips_for_all_seven_types() {
use crate::ids::AttachmentId;
let cores = vec![
ItemCore::Login(LoginCore::default()),
ItemCore::SecureNote(SecureNoteCore::default()),
ItemCore::Identity(IdentityCore::default()),
ItemCore::Card(CardCore::default()),
ItemCore::Key(KeyCore::default()),
ItemCore::Document(DocumentCore {
filename: "x".into(),
mime_type: "text/plain".into(),
primary_attachment: AttachmentId("0123456789abcdef".into()),
}),
ItemCore::Totp(TotpCore::default()),
];
for core in cores {
let expected_type = core.item_type();
let json = serde_json::to_string(&core).unwrap();
let parsed: ItemCore = serde_json::from_str(&json).expect("round-trip failed");
assert_eq!(parsed.item_type(), expected_type);
}
}
}

View File

@@ -0,0 +1,30 @@
//! Secure note: just a multiline body, Zeroizing.
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SecureNoteCore {
pub body: Zeroizing<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secure_note_round_trips() {
let note = SecureNoteCore { body: Zeroizing::new("a multi\nline note".into()) };
let json = serde_json::to_string(&note).unwrap();
let parsed: SecureNoteCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.body.as_str(), "a multi\nline note");
}
#[test]
fn empty_body_round_trips() {
let note = SecureNoteCore::default();
let json = serde_json::to_string(&note).unwrap();
let parsed: SecureNoteCore = serde_json::from_str(&json).unwrap();
assert!(parsed.body.is_empty());
}
}

View File

@@ -0,0 +1,170 @@
//! TOTP: standalone 2FA item type. Also reused as TotpConfig field on Login.
use hmac::{Hmac, Mac};
use sha1::Sha1 as HmacSha1;
use sha2::{Sha256 as HmacSha256, Sha512 as HmacSha512};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::error::{RelicarioError, Result};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TotpCore {
pub config: TotpConfig,
#[serde(skip_serializing_if = "Option::is_none")]
pub issuer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TotpConfig {
/// Raw bytes of the TOTP secret (decoded from base32 when imported).
pub secret: Zeroizing<Vec<u8>>,
pub algorithm: TotpAlgorithm,
pub digits: u8,
pub period_seconds: u32,
pub kind: TotpKind,
}
impl Default for TotpConfig {
fn default() -> Self {
Self {
secret: Zeroizing::new(Vec::new()),
algorithm: TotpAlgorithm::Sha1,
digits: 6,
period_seconds: 30,
kind: TotpKind::Totp,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TotpAlgorithm {
#[default]
Sha1,
Sha256,
Sha512,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TotpKind {
Totp,
Hotp { counter: u64 },
Steam,
}
impl Default for TotpKind {
fn default() -> Self { TotpKind::Totp }
}
/// Compute a TOTP/HOTP/Steam code for `config` at the given Unix timestamp.
///
/// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`.
/// For HOTP: uses the `counter` carried in the variant.
pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result<String> {
let counter = match config.kind {
TotpKind::Totp => now_unix_seconds / config.period_seconds as u64,
TotpKind::Hotp { counter } => counter,
TotpKind::Steam => now_unix_seconds / config.period_seconds as u64,
};
let counter_bytes = counter.to_be_bytes();
let hmac_out: Vec<u8> = match config.algorithm {
TotpAlgorithm::Sha1 => {
let mut mac = Hmac::<HmacSha1>::new_from_slice(&config.secret)
.map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?;
mac.update(&counter_bytes);
mac.finalize().into_bytes().to_vec()
}
TotpAlgorithm::Sha256 => {
let mut mac = Hmac::<HmacSha256>::new_from_slice(&config.secret)
.map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?;
mac.update(&counter_bytes);
mac.finalize().into_bytes().to_vec()
}
TotpAlgorithm::Sha512 => {
let mut mac = Hmac::<HmacSha512>::new_from_slice(&config.secret)
.map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?;
mac.update(&counter_bytes);
mac.finalize().into_bytes().to_vec()
}
};
let offset = (hmac_out[hmac_out.len() - 1] & 0x0F) as usize;
let truncated = ((hmac_out[offset] as u32 & 0x7F) << 24)
| ((hmac_out[offset + 1] as u32) << 16)
| ((hmac_out[offset + 2] as u32) << 8)
| (hmac_out[offset + 3] as u32);
let modulus = 10u32.pow(config.digits as u32);
Ok(format!("{:0width$}", truncated % modulus, width = config.digits as usize))
}
#[cfg(test)]
mod compute_tests {
use super::*;
#[test]
fn rfc6238_sha1_vector_59() {
let cfg = TotpConfig {
secret: Zeroizing::new(b"12345678901234567890".to_vec()),
algorithm: TotpAlgorithm::Sha1,
digits: 8,
period_seconds: 30,
kind: TotpKind::Totp,
};
assert_eq!(compute_totp_code(&cfg, 59).unwrap(), "94287082");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn totp_default_is_sha1_6_30_totp() {
let cfg = TotpConfig::default();
assert_eq!(cfg.algorithm, TotpAlgorithm::Sha1);
assert_eq!(cfg.digits, 6);
assert_eq!(cfg.period_seconds, 30);
assert_eq!(cfg.kind, TotpKind::Totp);
}
#[test]
fn totp_round_trip() {
let core = TotpCore {
config: TotpConfig {
secret: Zeroizing::new(vec![0x12, 0x34, 0x56]),
algorithm: TotpAlgorithm::Sha256,
digits: 8,
period_seconds: 60,
kind: TotpKind::Totp,
},
issuer: Some("github".into()),
label: Some("alice@github".into()),
};
let json = serde_json::to_string(&core).unwrap();
let parsed: TotpCore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.config.digits, 8);
assert_eq!(parsed.config.algorithm, TotpAlgorithm::Sha256);
assert_eq!(parsed.issuer.as_deref(), Some("github"));
}
#[test]
fn hotp_carries_counter() {
let cfg = TotpConfig { kind: TotpKind::Hotp { counter: 42 }, ..TotpConfig::default() };
let json = serde_json::to_string(&cfg).unwrap();
let parsed: TotpConfig = serde_json::from_str(&json).unwrap();
match parsed.kind {
TotpKind::Hotp { counter } => assert_eq!(counter, 42),
other => panic!("expected Hotp, got {:?}", other),
}
}
#[test]
fn steam_kind_serializes() {
let cfg = TotpConfig { kind: TotpKind::Steam, ..TotpConfig::default() };
let json = serde_json::to_string(&cfg).unwrap();
assert!(json.contains("steam"));
}
}

View File

@@ -10,17 +10,22 @@
//!
//! ## Modules
//!
//! - [`error`] -- The unified error type ([`RelicarioError`]) used across the crate.
//! - [`crypto`] -- Argon2id key derivation and XChaCha20-Poly1305 authenticated
//! encryption. This is the low-level "encrypt bytes / decrypt bytes" layer.
//! - [`entry`] -- The vault data model: [`Entry`] (full credential),
//! [`ManifestEntry`] (searchable index metadata), and [`Manifest`] (the entry
//! index that lets you list/search without decrypting every entry).
//! - [`vault`] -- Typed wrappers around [`crypto`] that serialize structs to JSON
//! before encrypting, and deserialize after decrypting.
//! - [`imgsecret`] -- DCT-based steganography for embedding and extracting a
//! 256-bit secret in a JPEG image. This is the novel component that provides the
//! second authentication factor.
//! - [`error`] The unified error type ([`RelicarioError`]).
//! - [`crypto`] Argon2id KDF (length-prefixed inputs, Zeroizing output) and
//! XChaCha20-Poly1305 AEAD with VERSION_BYTE 0x02.
//! - [`ids`] — `ItemId`, `FieldId`, and content-addressed `AttachmentId`.
//! - [`time`] — unix-seconds + `MonthYear` for card expiries.
//! - [`item_types`] — Per-type cores (`LoginCore`, `SecureNoteCore`, etc.) and the
//! `ItemCore`/`ItemType` enums.
//! - [`item`] — `Item` envelope, `Field`, `FieldKind`, `FieldValue`, `Section`,
//! `FieldHistoryEntry`.
//! - [`attachment`] — `AttachmentRef`, `AttachmentSummary`, encrypt/decrypt helpers.
//! - [`manifest`] — Browse-without-decrypt index (schema_version 2).
//! - [`settings`] — Vault-level retention, generator defaults, attachment caps.
//! - [`generators`] — CSPRNG password + BIP39 passphrase generators; zxcvbn
//! strength gate.
//! - [`vault`] — Typed encrypt/decrypt wrappers (Item, Manifest, VaultSettings).
//! - [`imgsecret`] — DCT-based steganography for the second auth factor.
//!
//! ## Crypto pipeline
//!
@@ -36,12 +41,39 @@ pub mod error;
pub use error::{RelicarioError, Result};
pub mod crypto;
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams};
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE};
pub mod entry;
pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};
pub mod ids;
pub use ids::{AttachmentId, FieldId, ItemId};
pub mod time;
pub use time::{now_unix, MonthYear};
pub mod item_types;
pub use item_types::{ItemCore, ItemType};
pub mod item;
pub use item::{Field, FieldHistoryEntry, FieldKind, FieldValue, Item, Section};
pub mod attachment;
pub use attachment::{decrypt_attachment, encrypt_attachment, AttachmentRef, AttachmentSummary, EncryptedAttachment};
pub mod manifest;
pub use manifest::{Manifest, ManifestEntry, MANIFEST_SCHEMA_VERSION};
pub mod settings;
pub use settings::{
AttachmentCaps, Capitalization, CharClasses, GeneratorRequest, HistoryRetention,
SymbolCharset, TrashRetention, VaultSettings,
};
pub mod generators;
pub use generators::{generate_passphrase, generate_password, rate_passphrase, validate_passphrase_strength, StrengthEstimate};
pub mod vault;
pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest};
pub use vault::{
decrypt_item, decrypt_manifest, decrypt_settings,
encrypt_item, encrypt_manifest, encrypt_settings,
};
pub mod imgsecret;

View File

@@ -0,0 +1,159 @@
//! New typed-item manifest. Lives next to the old entry.rs Manifest
//! during this rewrite; entry.rs is deleted in Task 25.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::attachment::AttachmentSummary;
use crate::ids::ItemId;
use crate::item::Item;
use crate::item_types::ItemType;
pub const MANIFEST_SCHEMA_VERSION: u32 = 2;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub schema_version: u32,
pub items: HashMap<ItemId, ManifestEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestEntry {
pub id: ItemId,
pub r#type: ItemType,
pub title: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub favorite: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon_hint: Option<String>,
pub modified: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub trashed_at: Option<i64>,
#[serde(default)]
pub attachment_summaries: Vec<AttachmentSummary>,
}
impl Manifest {
pub fn new() -> Self {
Self { schema_version: MANIFEST_SCHEMA_VERSION, items: HashMap::new() }
}
pub fn upsert(&mut self, item: &Item) {
let entry = ManifestEntry::from_item(item);
self.items.insert(item.id.clone(), entry);
}
pub fn remove(&mut self, id: &ItemId) -> Option<ManifestEntry> {
self.items.remove(id)
}
pub fn get(&self, id: &ItemId) -> Option<&ManifestEntry> {
self.items.get(id)
}
/// Case-insensitive substring match on title and tags.
pub fn search(&self, query: &str) -> Vec<&ManifestEntry> {
let q = query.to_lowercase();
self.items
.values()
.filter(|e| {
e.title.to_lowercase().contains(&q)
|| e.tags.iter().any(|t| t.to_lowercase().contains(&q))
})
.collect()
}
}
impl Default for Manifest {
fn default() -> Self { Self::new() }
}
impl ManifestEntry {
pub fn from_item(item: &Item) -> Self {
Self {
id: item.id.clone(),
r#type: item.r#type,
title: item.title.clone(),
tags: item.tags.clone(),
favorite: item.favorite,
group: item.group.clone(),
icon_hint: derive_icon_hint(item),
modified: item.modified,
trashed_at: item.trashed_at,
attachment_summaries: item.attachments.iter().map(Into::into).collect(),
}
}
}
/// Derive an icon hint string from an item — for Login items, this is the URL hostname.
fn derive_icon_hint(item: &Item) -> Option<String> {
use crate::item_types::ItemCore;
match &item.core {
ItemCore::Login(l) => l.url.as_ref().and_then(|u| u.host_str().map(str::to_owned)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::item_types::{ItemCore, LoginCore, SecureNoteCore};
#[test]
fn empty_manifest_has_schema_v2() {
let m = Manifest::new();
assert_eq!(m.schema_version, MANIFEST_SCHEMA_VERSION);
assert!(m.items.is_empty());
}
#[test]
fn upsert_and_search() {
let mut m = Manifest::new();
let mut item = Item::new("GitHub".into(), ItemCore::Login(LoginCore::default()));
item.tags = vec!["work".into()];
m.upsert(&item);
let results = m.search("github");
assert_eq!(results.len(), 1);
let by_tag = m.search("work");
assert_eq!(by_tag.len(), 1);
}
#[test]
fn icon_hint_is_login_url_host() {
use url::Url;
let mut m = Manifest::new();
let core = ItemCore::Login(LoginCore {
url: Some(Url::parse("https://api.github.com/login").unwrap()),
..Default::default()
});
let item = Item::new("X".into(), core);
m.upsert(&item);
let entry = m.items.values().next().unwrap();
assert_eq!(entry.icon_hint.as_deref(), Some("api.github.com"));
}
#[test]
fn icon_hint_is_none_for_non_login() {
let mut m = Manifest::new();
let item = Item::new("note".into(), ItemCore::SecureNote(SecureNoteCore::default()));
m.upsert(&item);
let entry = m.items.values().next().unwrap();
assert!(entry.icon_hint.is_none());
}
#[test]
fn manifest_round_trips() {
let mut m = Manifest::new();
let item = Item::new("X".into(), ItemCore::SecureNote(SecureNoteCore::default()));
m.upsert(&item);
let json = serde_json::to_string(&m).unwrap();
let parsed: Manifest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.schema_version, MANIFEST_SCHEMA_VERSION);
assert_eq!(parsed.items.len(), 1);
}
}

View File

@@ -0,0 +1,184 @@
//! Vault-level settings: trash retention, history retention, generator
//! defaults, attachment caps, autofill TOFU acks.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VaultSettings {
pub trash_retention: TrashRetention,
pub field_history_retention: HistoryRetention,
pub generator_defaults: GeneratorRequest,
pub attachment_caps: AttachmentCaps,
/// hostname → unix-seconds first-acked
#[serde(default)]
pub autofill_origin_acks: HashMap<String, i64>,
}
impl Default for VaultSettings {
fn default() -> Self {
Self {
trash_retention: TrashRetention::Days(30),
field_history_retention: HistoryRetention::Forever,
generator_defaults: GeneratorRequest::default(),
attachment_caps: AttachmentCaps::default(),
autofill_origin_acks: HashMap::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum TrashRetention {
Days(u32),
Forever,
}
impl TrashRetention {
pub fn should_purge(&self, trashed_at: i64, now: i64) -> bool {
match self {
TrashRetention::Forever => false,
TrashRetention::Days(d) => now - trashed_at > (*d as i64) * 86_400,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum HistoryRetention {
LastN(u32),
Days(u32),
Forever,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum GeneratorRequest {
Bip39 {
word_count: u32,
separator: String,
capitalization: Capitalization,
},
Random {
length: u32,
classes: CharClasses,
symbol_charset: SymbolCharset,
},
}
impl Default for GeneratorRequest {
fn default() -> Self {
GeneratorRequest::Random {
length: 20,
classes: CharClasses { lower: true, upper: true, digits: true, symbols: true },
symbol_charset: SymbolCharset::SafeOnly,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Capitalization {
Lower,
Upper,
FirstOfEach,
Title,
Mixed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct CharClasses {
pub lower: bool,
pub upper: bool,
pub digits: bool,
pub symbols: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum SymbolCharset {
SafeOnly,
Extended,
Custom(String),
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct AttachmentCaps {
pub per_attachment_max_bytes: u64,
pub per_item_max_count: u32,
pub per_vault_soft_cap_bytes: u64,
pub per_vault_hard_cap_bytes: u64,
}
impl Default for AttachmentCaps {
fn default() -> Self {
Self {
per_attachment_max_bytes: 10 * 1024 * 1024,
per_item_max_count: 20,
per_vault_soft_cap_bytes: 100 * 1024 * 1024,
per_vault_hard_cap_bytes: 500 * 1024 * 1024,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_spec() {
let s = VaultSettings::default();
assert!(matches!(s.trash_retention, TrashRetention::Days(30)));
assert!(matches!(s.field_history_retention, HistoryRetention::Forever));
assert_eq!(s.attachment_caps.per_attachment_max_bytes, 10 * 1024 * 1024);
assert_eq!(s.attachment_caps.per_item_max_count, 20);
}
#[test]
fn trash_retention_purges_after_days() {
let r = TrashRetention::Days(30);
let now = 1_000_000_000;
let recently_trashed = now - 29 * 86_400;
let long_trashed = now - 31 * 86_400;
assert!(!r.should_purge(recently_trashed, now));
assert!(r.should_purge(long_trashed, now));
}
#[test]
fn trash_retention_forever_never_purges() {
let r = TrashRetention::Forever;
assert!(!r.should_purge(0, 1_000_000_000));
}
#[test]
fn settings_round_trip() {
let s = VaultSettings::default();
let json = serde_json::to_string(&s).unwrap();
let parsed: VaultSettings = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.attachment_caps.per_attachment_max_bytes,
s.attachment_caps.per_attachment_max_bytes);
}
#[test]
fn random_generator_default_is_20_safe() {
match VaultSettings::default().generator_defaults {
GeneratorRequest::Random { length, classes, symbol_charset } => {
assert_eq!(length, 20);
assert!(classes.lower && classes.upper && classes.digits && classes.symbols);
assert!(matches!(symbol_charset, SymbolCharset::SafeOnly));
}
_ => panic!("expected Random default"),
}
}
#[test]
fn symbol_charset_custom_round_trips() {
let c = SymbolCharset::Custom("!@#".into());
let json = serde_json::to_string(&c).unwrap();
let parsed: SymbolCharset = serde_json::from_str(&json).unwrap();
match parsed {
SymbolCharset::Custom(s) => assert_eq!(s, "!@#"),
other => panic!("expected Custom, got {:?}", other),
}
}
}

View File

@@ -0,0 +1,63 @@
//! Time helpers and the `MonthYear` type used for card expiries.
use serde::{Deserialize, Serialize};
/// Current Unix timestamp in seconds.
pub fn now_unix() -> i64 {
chrono::Utc::now().timestamp()
}
/// Month + year (1-12 / e.g. 2026). Used for card expiries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct MonthYear {
pub month: u8,
pub year: u16,
}
impl MonthYear {
pub fn new(month: u8, year: u16) -> Result<Self, &'static str> {
if !(1..=12).contains(&month) {
return Err("month must be 1..=12");
}
if year < 2000 || year > 2099 {
return Err("year must be 2000..=2099");
}
Ok(Self { month, year })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn now_unix_is_positive_and_recent() {
let t = now_unix();
assert!(t > 1_700_000_000); // after late 2023
assert!(t < 4_000_000_000); // before 2096
}
#[test]
fn month_year_constructor_rejects_bad_month() {
assert!(MonthYear::new(0, 2026).is_err());
assert!(MonthYear::new(13, 2026).is_err());
assert!(MonthYear::new(1, 2026).is_ok());
assert!(MonthYear::new(12, 2026).is_ok());
}
#[test]
fn month_year_constructor_rejects_bad_year() {
assert!(MonthYear::new(1, 1999).is_err());
assert!(MonthYear::new(1, 2100).is_err());
assert!(MonthYear::new(1, 2000).is_ok());
assert!(MonthYear::new(1, 2099).is_ok());
}
#[test]
fn month_year_round_trips_through_json() {
let my = MonthYear::new(7, 2030).unwrap();
let json = serde_json::to_string(&my).unwrap();
let parsed: MonthYear = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, my);
}
}

View File

@@ -1,150 +1,90 @@
//! Typed encryption/decryption wrappers for vault entries and manifests.
//! Typed wrappers around `crypto::{encrypt, decrypt}` for the new typed-item
//! data model. Each function does JSON-serialize → encrypt or decrypt → JSON-parse.
//!
//! This module bridges the gap between the raw bytes-in/bytes-out layer in
//! [`crate::crypto`] and the typed data model in [`crate::entry`]. Each function
//! follows the same pattern:
//!
//! - **Encrypt**: serialize the struct to JSON via serde, then encrypt the JSON
//! bytes with [`crate::crypto::encrypt`].
//! - **Decrypt**: decrypt the ciphertext with [`crate::crypto::decrypt`], then
//! deserialize the resulting JSON bytes back into the typed struct.
//!
//! ## Why a single master key
//!
//! All entries and the manifest are encrypted under the same `master_key`. This is
//! simpler than a per-entry subkey hierarchy and sufficient for family-scale vaults
//! (typically < 1000 entries). The security properties are equivalent: an attacker
//! who compromises the master key can decrypt everything regardless of whether
//! subkeys exist, and the vault's threat model already assumes the master key is
//! the single point of trust (protected by the two-factor KDF).
//! v1 helpers (encrypt_entry / decrypt_entry / encrypt_manifest with the old
//! Manifest type) are intentionally NOT carried forward. The CLI rewrite in
//! Plan 1B switches to the new helpers.
use crate::crypto;
use crate::entry::{Entry, Manifest};
use zeroize::Zeroizing;
use crate::crypto::{decrypt, encrypt};
use crate::error::Result;
use crate::item::Item;
use crate::manifest::Manifest;
use crate::settings::VaultSettings;
/// Serialize an [`Entry`] to JSON and encrypt it under the master key.
///
/// The resulting bytes are written to `entries/<id>.enc` by the CLI.
///
/// # Errors
///
/// - [`crate::RelicarioError::Json`] if JSON serialization fails (should not happen
/// with well-formed Entry structs).
/// - [`crate::RelicarioError::Encrypt`] if the underlying AEAD operation fails.
pub fn encrypt_entry(master_key: &[u8; 32], entry: &Entry) -> Result<Vec<u8>> {
let json = serde_json::to_vec(entry)?;
crypto::encrypt(master_key, &json)
pub fn encrypt_item(item: &Item, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
let json = serde_json::to_vec(item)?;
let plaintext = Zeroizing::new(json);
encrypt(master_key, plaintext.as_slice())
}
/// Decrypt an entry blob and deserialize it back into an [`Entry`].
///
/// # Errors
///
/// - [`crate::RelicarioError::Decrypt`] if the master key is wrong or the data is
/// tampered.
/// - [`crate::RelicarioError::Format`] if the ciphertext blob has an invalid header.
/// - [`crate::RelicarioError::Json`] if the decrypted JSON is malformed.
pub fn decrypt_entry(master_key: &[u8; 32], data: &[u8]) -> Result<Entry> {
let json = crypto::decrypt(master_key, data)?;
let entry: Entry = serde_json::from_slice(&json)?;
Ok(entry)
pub fn decrypt_item(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<Item> {
let plaintext = decrypt(master_key, encrypted)?;
let plaintext = Zeroizing::new(plaintext);
let item: Item = serde_json::from_slice(&plaintext)?;
Ok(item)
}
/// Serialize a [`Manifest`] to JSON and encrypt it under the master key.
///
/// The resulting bytes are written to `manifest.enc` by the CLI.
///
/// # Errors
///
/// Same as [`encrypt_entry`].
pub fn encrypt_manifest(master_key: &[u8; 32], manifest: &Manifest) -> Result<Vec<u8>> {
pub fn encrypt_manifest(manifest: &Manifest, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
let json = serde_json::to_vec(manifest)?;
crypto::encrypt(master_key, &json)
let plaintext = Zeroizing::new(json);
encrypt(master_key, plaintext.as_slice())
}
/// Decrypt a manifest blob and deserialize it back into a [`Manifest`].
///
/// # Errors
///
/// Same as [`decrypt_entry`].
pub fn decrypt_manifest(master_key: &[u8; 32], data: &[u8]) -> Result<Manifest> {
let json = crypto::decrypt(master_key, data)?;
let manifest: Manifest = serde_json::from_slice(&json)?;
pub fn decrypt_manifest(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<Manifest> {
let plaintext = decrypt(master_key, encrypted)?;
let plaintext = Zeroizing::new(plaintext);
let manifest: Manifest = serde_json::from_slice(&plaintext)?;
Ok(manifest)
}
pub fn encrypt_settings(settings: &VaultSettings, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
let json = serde_json::to_vec(settings)?;
let plaintext = Zeroizing::new(json);
encrypt(master_key, plaintext.as_slice())
}
pub fn decrypt_settings(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<VaultSettings> {
let plaintext = decrypt(master_key, encrypted)?;
let plaintext = Zeroizing::new(plaintext);
let settings: VaultSettings = serde_json::from_slice(&plaintext)?;
Ok(settings)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entry::ManifestEntry;
use crate::item_types::{ItemCore, SecureNoteCore};
fn test_key_a() -> [u8; 32] {
[0x42u8; 32]
}
fn key() -> Zeroizing<[u8; 32]> { Zeroizing::new([0x33u8; 32]) }
fn test_key_b() -> [u8; 32] {
[0x99u8; 32]
}
fn sample_entry() -> Entry {
Entry {
name: "GitHub".to_string(),
url: Some("https://github.com".to_string()),
username: Some("alice".to_string()),
password: "secret123".to_string(),
notes: None,
totp_secret: None,
group: None,
created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
}
#[test]
fn item_round_trip() {
let item = Item::new("note".into(), ItemCore::SecureNote(SecureNoteCore {
body: Zeroizing::new("hello".into()),
}));
let bytes = encrypt_item(&item, &key()).unwrap();
let decoded = decrypt_item(&bytes, &key()).unwrap();
assert_eq!(decoded.title, "note");
}
#[test]
fn entry_encrypt_decrypt_round_trip() {
let key = test_key_a();
let entry = sample_entry();
let ciphertext = encrypt_entry(&key, &entry).unwrap();
let decoded = decrypt_entry(&key, &ciphertext).unwrap();
assert_eq!(decoded.name, "GitHub");
assert_eq!(decoded.password, "secret123");
assert_eq!(decoded.username, Some("alice".to_string()));
fn manifest_round_trip() {
let mut m = Manifest::new();
let item = Item::new("x".into(), ItemCore::SecureNote(SecureNoteCore::default()));
m.upsert(&item);
let bytes = encrypt_manifest(&m, &key()).unwrap();
let decoded = decrypt_manifest(&bytes, &key()).unwrap();
assert_eq!(decoded.items.len(), 1);
}
#[test]
fn manifest_encrypt_decrypt_round_trip() {
let key = test_key_a();
let mut manifest = Manifest::new();
manifest.add_entry(
"deadbeef".to_string(),
ManifestEntry {
name: "GitHub".to_string(),
url: Some("https://github.com".to_string()),
username: Some("alice".to_string()),
group: None,
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
);
let ciphertext = encrypt_manifest(&key, &manifest).unwrap();
let decoded = decrypt_manifest(&key, &ciphertext).unwrap();
assert_eq!(decoded.version, 1);
assert!(decoded.entries.contains_key("deadbeef"));
assert_eq!(decoded.entries["deadbeef"].name, "GitHub");
}
#[test]
fn entry_wrong_key_fails() {
let key_a = test_key_a();
let key_b = test_key_b();
let entry = sample_entry();
let ciphertext = encrypt_entry(&key_a, &entry).unwrap();
let result = decrypt_entry(&key_b, &ciphertext);
assert!(result.is_err());
fn settings_round_trip() {
let s = VaultSettings::default();
let bytes = encrypt_settings(&s, &key()).unwrap();
let decoded = decrypt_settings(&bytes, &key()).unwrap();
assert_eq!(decoded.attachment_caps.per_attachment_max_bytes,
s.attachment_caps.per_attachment_max_bytes);
}
}

View File

@@ -0,0 +1,52 @@
//! Attachment encrypt/decrypt + content-addressed AID + cap enforcement.
use relicario_core::{
AttachmentId, RelicarioError,
crypto::KdfParams,
decrypt_attachment, derive_master_key, encrypt_attachment,
};
use zeroize::Zeroizing;
fn fast_params() -> KdfParams { KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 } }
fn make_key() -> Zeroizing<[u8; 32]> {
derive_master_key(b"x", &[0u8; 32], &[0u8; 32], &fast_params()).unwrap()
}
#[test]
fn attachment_round_trip_5kb() {
let plaintext: Vec<u8> = (0..5000u32).map(|i| (i & 0xff) as u8).collect();
let key = make_key();
let enc = encrypt_attachment(&plaintext, &key, 10 * 1024 * 1024).unwrap();
assert_eq!(enc.id, AttachmentId::from_plaintext(&plaintext));
let dec = decrypt_attachment(&enc.bytes, &key).unwrap();
assert_eq!(&*dec, &plaintext);
}
#[test]
fn identical_plaintexts_yield_identical_aids() {
let plaintext = b"hello world";
let key = make_key();
let a = encrypt_attachment(plaintext, &key, 1024).unwrap();
let b = encrypt_attachment(plaintext, &key, 1024).unwrap();
assert_eq!(a.id, b.id);
// (Bytes will differ because nonce is random per-encryption — that's expected.)
}
#[test]
fn cap_enforcement_at_exact_max() {
let plaintext = vec![0u8; 1024];
let key = make_key();
// Exactly at max — should pass
let _ = encrypt_attachment(&plaintext, &key, 1024).unwrap();
// One byte over — should fail
let err = encrypt_attachment(&plaintext, &key, 1023);
match err {
Err(RelicarioError::AttachmentTooLarge { size, max }) => {
assert_eq!(size, 1024);
assert_eq!(max, 1023);
}
other => panic!("expected AttachmentTooLarge, got {other:?}"),
}
}

View File

@@ -0,0 +1,63 @@
//! Field history end-to-end: capture on update, prune by retention policy,
//! survive encrypt/decrypt round-trip.
use relicario_core::{
Field, FieldValue, HistoryRetention, Item, ItemCore, Section,
crypto::KdfParams,
derive_master_key, decrypt_item, encrypt_item,
};
use relicario_core::item_types::LoginCore;
use zeroize::Zeroizing;
fn key() -> Zeroizing<[u8; 32]> {
derive_master_key(b"x", &[0u8; 32], &[0u8; 32], &KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }).unwrap()
}
#[test]
fn password_field_history_captured_on_update() {
let mut item = Item::new("login".into(), ItemCore::Login(LoginCore::default()));
let f = Field::new("password".into(), FieldValue::Password(Zeroizing::new("v0".into())));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap();
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v2".into()))).unwrap();
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v3".into()))).unwrap();
let hist = item.field_history.get(&fid).expect("history exists");
assert_eq!(hist.len(), 3);
assert_eq!(hist[0].value.as_str(), "v0");
assert_eq!(hist[2].value.as_str(), "v2");
}
#[test]
fn prune_last_n_keeps_most_recent() {
let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default()));
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
for i in 1..=10 {
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}")))).unwrap();
}
item.prune_history(&HistoryRetention::LastN(3), 0);
let hist = &item.field_history[&fid];
assert_eq!(hist.len(), 3);
// Most recent 3: v7, v8, v9 (v10's predecessor v9 was the latest captured)
assert!(hist.last().unwrap().value.as_str().starts_with('v'));
}
#[test]
fn history_survives_encrypt_decrypt() {
let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default()));
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap();
let blob = encrypt_item(&item, &key()).unwrap();
let decoded = decrypt_item(&blob, &key()).unwrap();
let hist = decoded.field_history.get(&fid).expect("history survived");
assert_eq!(hist.len(), 1);
assert_eq!(hist[0].value.as_str(), "v0");
}

View File

@@ -0,0 +1,54 @@
//! Format v2 invariants: VERSION_BYTE = 0x02, v1 blobs are rejected with
//! UnsupportedFormatVersion, length-prefix construction guarantees domain
//! separation.
use relicario_core::{
RelicarioError,
crypto::{KdfParams, VERSION_BYTE},
decrypt, derive_master_key, encrypt,
};
use zeroize::Zeroizing;
fn fast_params() -> KdfParams { KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 } }
#[test]
fn version_byte_is_2() {
assert_eq!(VERSION_BYTE, 0x02);
}
#[test]
fn fresh_ciphertext_starts_with_0x02() {
let key = Zeroizing::new([0u8; 32]);
// encrypt(key: &[u8; 32], plaintext: &[u8])
let ct = encrypt(&key, b"hello").unwrap();
assert_eq!(ct[0], 0x02);
}
#[test]
fn v1_blob_is_rejected_with_unsupported_format_version() {
// v1 layout: [0x01][24 nonce bytes][16 tag bytes]
let mut blob = vec![0x01u8];
blob.extend_from_slice(&[0u8; 24 + 16]);
let key = Zeroizing::new([0u8; 32]);
// decrypt(key: &[u8; 32], data: &[u8])
let err = decrypt(&key, &blob);
match err {
Err(RelicarioError::UnsupportedFormatVersion { found, expected }) => {
assert_eq!(found, 0x01);
assert_eq!(expected, 0x02);
}
other => panic!("expected UnsupportedFormatVersion, got {other:?}"),
}
}
#[test]
fn length_prefix_distinguishes_concat_collisions() {
let salt = [0u8; 32];
let img = [0x44u8; 32];
let p1 = b"abc";
let p2 = b"abcD"; // Pre-length-prefix, ("abc", [0x44, ...]) and ("abcD", ...)
// could be made to collide. With length-prefix they cannot.
let k1 = derive_master_key(p1, &img, &salt, &fast_params()).unwrap();
let k2 = derive_master_key(p2, &img, &salt, &fast_params()).unwrap();
assert_ne!(*k1, *k2);
}

View File

@@ -0,0 +1,89 @@
//! Generator integration tests — unbiased sampling (smoke), BIP39 sanity,
//! zxcvbn strength gate.
//!
//! # Note on length cap
//!
//! `generate_password` enforces `length <= 128`. The task originally specified
//! `length: 10_000` in a single call, but that would error at runtime.
//!
//! We use **Option 1 (aggregation)**: call `generate_password` 80 times with
//! `length: 128` to gather 10,240 characters total, then aggregate per-class
//! counts before asserting proportions. The ±5pp tolerance is unchanged because
//! sample size is the same (~10k chars).
use relicario_core::{
Capitalization, CharClasses, GeneratorRequest, SymbolCharset,
generate_passphrase, generate_password, validate_passphrase_strength,
};
#[test]
fn random_password_class_balance_is_reasonable() {
// Aggregate 80 × 128 = 10,240 chars so we have enough for tight statistics.
// (generate_password caps at length 128, so we cannot do a single 10,000-char call.)
let req = GeneratorRequest::Random {
length: 128,
classes: CharClasses { lower: true, upper: true, digits: true, symbols: true },
symbol_charset: SymbolCharset::SafeOnly,
};
let mut lower = 0usize;
let mut upper = 0usize;
let mut digits = 0usize;
let mut total = 0usize;
for _ in 0..80 {
let pw = generate_password(&req).unwrap();
lower += pw.chars().filter(|c| c.is_ascii_lowercase()).count();
upper += pw.chars().filter(|c| c.is_ascii_uppercase()).count();
digits += pw.chars().filter(|c| c.is_ascii_digit()).count();
total += pw.len();
}
let symbols = total - lower - upper - digits;
// Charset sizes: lower 26 + upper 26 + digits 10 + safe_symbols 12 = 74
// Expected proportions: 26/74 ≈ 35.1%, 10/74 ≈ 13.5%, 12/74 ≈ 16.2%
// Allow ±5pp slop.
let t = total as f64;
let assert_pct = |label: &str, actual: usize, expected_pct: f64| {
let pct = (actual as f64) / t * 100.0;
assert!(
(pct - expected_pct).abs() < 5.0,
"{label}: actual {pct:.1}% vs expected {expected_pct:.1}%"
);
};
assert_pct("lower", lower, 26.0 / 74.0 * 100.0);
assert_pct("upper", upper, 26.0 / 74.0 * 100.0);
assert_pct("digits", digits, 10.0 / 74.0 * 100.0);
assert_pct("symbols", symbols, 12.0 / 74.0 * 100.0);
}
#[test]
fn bip39_5_word_passphrase_passes_zxcvbn_gate() {
let req = GeneratorRequest::Bip39 {
word_count: 5,
separator: " ".into(),
capitalization: Capitalization::Lower,
};
let pw = generate_passphrase(&req).unwrap();
validate_passphrase_strength(&pw).expect("5-word bip39 should pass score >= 3");
}
#[test]
fn common_weak_passphrases_fail_gate() {
for weak in &["password", "12345678", "letmein", "qwertyui", "hunter2"] {
assert!(
validate_passphrase_strength(weak).is_err(),
"expected '{weak}' to fail gate"
);
}
}
#[test]
fn random_passwords_are_unique_across_calls() {
let req = GeneratorRequest::default();
let mut seen = std::collections::HashSet::new();
for _ in 0..1000 {
let pw = generate_password(&req).unwrap();
assert!(seen.insert(pw.as_str().to_owned()));
}
}

View File

@@ -1,153 +1,111 @@
use relicario_core::{
decrypt_entry, decrypt_manifest, derive_master_key, encrypt_entry, encrypt_manifest,
generate_entry_id, Entry, KdfParams, Manifest, ManifestEntry,
};
use rand::RngCore;
//! End-to-end integration tests for the typed-item core.
fn make_test_jpeg(width: u32, height: u32) -> Vec<u8> {
use image::codecs::jpeg::JpegEncoder;
use image::{ImageBuffer, ImageEncoder, Rgb};
let img = ImageBuffer::from_fn(width, height, |x, y| {
Rgb([
((x * 7 + y * 13) % 256) as u8,
((x * 11 + y * 3) % 256) as u8,
((x * 5 + y * 17) % 256) as u8,
])
});
let mut buf = Vec::new();
let encoder = JpegEncoder::new_with_quality(&mut buf, 92);
encoder
.write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgb8)
.unwrap();
buf
}
use relicario_core::{
crypto::KdfParams,
derive_master_key, encrypt_item, decrypt_item,
encrypt_manifest, decrypt_manifest,
encrypt_settings, decrypt_settings,
Field, FieldValue, Item, ItemCore, Manifest, Section, VaultSettings,
};
use relicario_core::item_types::{LoginCore, SecureNoteCore};
use url::Url;
use zeroize::Zeroizing;
fn fast_params() -> KdfParams {
KdfParams {
argon2_m: 256,
argon2_t: 1,
argon2_p: 1,
}
KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }
}
#[test]
fn full_vault_workflow() {
// 1. Generate carrier JPEG
let carrier = make_test_jpeg(400, 300);
fn full_workflow_login_and_note() {
let salt = [0xAAu8; 32];
let img = [0xBBu8; 32];
let key = derive_master_key(b"correct horse battery staple", &img, &salt, &fast_params()).unwrap();
// 2. Generate random image_secret and embed
let mut image_secret = [0u8; 32];
rand::thread_rng().fill_bytes(&mut image_secret);
let stego = relicario_core::imgsecret::embed(&carrier, &image_secret).unwrap();
// 3. Extract and verify
let extracted = relicario_core::imgsecret::extract(&stego).unwrap();
assert_eq!(extracted, image_secret, "extracted image_secret must match embedded");
// 4. Derive master_key with fast params
let passphrase = b"test-passphrase-long-enough";
let mut salt = [0u8; 32];
rand::thread_rng().fill_bytes(&mut salt);
let params = fast_params();
let master_key = derive_master_key(passphrase, &image_secret, &salt, &params).unwrap();
// 5. Create and encrypt an Entry
let entry = Entry {
name: "GitHub".to_string(),
url: Some("https://github.com".to_string()),
username: Some("alice".to_string()),
password: "supersecret123!".to_string(),
notes: Some("my main account".to_string()),
totp_secret: None,
group: None,
created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
};
let encrypted = encrypt_entry(&master_key, &entry).unwrap();
// 6. Decrypt and verify fields match
let decrypted = decrypt_entry(&master_key, &encrypted).unwrap();
assert_eq!(decrypted.name, "GitHub");
assert_eq!(decrypted.password, "supersecret123!");
assert_eq!(decrypted.username, Some("alice".to_string()));
assert_eq!(decrypted.url, Some("https://github.com".to_string()));
assert_eq!(decrypted.notes, Some("my main account".to_string()));
// 7. Wrong passphrase -> different key -> decrypt fails
let wrong_key = derive_master_key(b"wrong-passphrase-entirely", &image_secret, &salt, &params).unwrap();
assert!(
decrypt_entry(&wrong_key, &encrypted).is_err(),
"decryption with wrong passphrase must fail"
);
// 8. Wrong image_secret -> different key -> decrypt fails
let mut wrong_secret = [0u8; 32];
rand::thread_rng().fill_bytes(&mut wrong_secret);
// Make sure it's actually different
if wrong_secret == image_secret {
wrong_secret[0] ^= 0xFF;
}
let wrong_key2 = derive_master_key(passphrase, &wrong_secret, &salt, &params).unwrap();
assert!(
decrypt_entry(&wrong_key2, &encrypted).is_err(),
"decryption with wrong image_secret must fail"
);
// 9. Manifest round-trip
let entry_id = generate_entry_id();
let mut manifest = Manifest::new();
manifest.add_entry(
entry_id.clone(),
ManifestEntry {
name: "GitHub".to_string(),
url: Some("https://github.com".to_string()),
username: Some("alice".to_string()),
group: None,
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
);
let settings = VaultSettings::default();
let manifest_enc = encrypt_manifest(&master_key, &manifest).unwrap();
let manifest_dec = decrypt_manifest(&master_key, &manifest_enc).unwrap();
// Add a Login
let login = Item::new("GitHub".into(), ItemCore::Login(LoginCore {
username: Some("alice".into()),
password: Some(Zeroizing::new("hunter2".into())),
url: Some(Url::parse("https://github.com").unwrap()),
totp: None,
}));
manifest.upsert(&login);
let login_blob = encrypt_item(&login, &key).unwrap();
assert_eq!(manifest_dec.version, 1);
assert!(manifest_dec.entries.contains_key(&entry_id));
assert_eq!(manifest_dec.entries[&entry_id].name, "GitHub");
// Add a SecureNote
let note = Item::new("recovery".into(), ItemCore::SecureNote(SecureNoteCore {
body: Zeroizing::new("recovery codes go here".into()),
}));
manifest.upsert(&note);
let note_blob = encrypt_item(&note, &key).unwrap();
// Encrypt manifest + settings
let manifest_blob = encrypt_manifest(&manifest, &key).unwrap();
let settings_blob = encrypt_settings(&settings, &key).unwrap();
// Decrypt + verify
let m = decrypt_manifest(&manifest_blob, &key).unwrap();
assert_eq!(m.items.len(), 2);
let l: Item = decrypt_item(&login_blob, &key).unwrap();
let n: Item = decrypt_item(&note_blob, &key).unwrap();
let s: VaultSettings = decrypt_settings(&settings_blob, &key).unwrap();
assert_eq!(l.title, "GitHub");
assert_eq!(n.title, "recovery");
assert_eq!(s.attachment_caps.per_attachment_max_bytes, 10 * 1024 * 1024);
}
#[test]
fn two_factor_independence() {
let mut salt = [0u8; 32];
rand::thread_rng().fill_bytes(&mut salt);
let params = fast_params();
// Same passphrase, different image_secret → different keys.
let salt = [0u8; 32];
let img_a = [0x01u8; 32];
let img_b = [0x02u8; 32];
let passphrase_a = b"passphrase-alpha";
let passphrase_b = b"passphrase-bravo";
let key_a = derive_master_key(b"same-passphrase", &img_a, &salt, &fast_params()).unwrap();
let key_b = derive_master_key(b"same-passphrase", &img_b, &salt, &fast_params()).unwrap();
assert_ne!(*key_a, *key_b);
let mut image_secret_a = [0u8; 32];
rand::thread_rng().fill_bytes(&mut image_secret_a);
let mut image_secret_b = [0u8; 32];
rand::thread_rng().fill_bytes(&mut image_secret_b);
// Ensure they differ
if image_secret_a == image_secret_b {
image_secret_b[0] ^= 0xFF;
}
// 1. (passphrase_A, image_A)
let key_aa = derive_master_key(passphrase_a, &image_secret_a, &salt, &params).unwrap();
// 2. (passphrase_B, image_A) -> different from #1
let key_ba = derive_master_key(passphrase_b, &image_secret_a, &salt, &params).unwrap();
assert_ne!(key_aa, key_ba, "different passphrase must produce different key");
// 3. (passphrase_A, image_B) -> different from #1
let key_ab = derive_master_key(passphrase_a, &image_secret_b, &salt, &params).unwrap();
assert_ne!(key_aa, key_ab, "different image_secret must produce different key");
// 4. (passphrase_B, image_B) -> different from all above
let key_bb = derive_master_key(passphrase_b, &image_secret_b, &salt, &params).unwrap();
assert_ne!(key_bb, key_aa, "key_bb must differ from key_aa");
assert_ne!(key_bb, key_ba, "key_bb must differ from key_ba");
assert_ne!(key_bb, key_ab, "key_bb must differ from key_ab");
// Different passphrase, same image_secret → different keys.
let key_c = derive_master_key(b"other-passphrase", &img_a, &salt, &fast_params()).unwrap();
assert_ne!(*key_a, *key_c);
}
#[test]
fn field_history_persists_through_round_trip() {
let salt = [0u8; 32];
let img = [0u8; 32];
let key = derive_master_key(b"x", &img, &salt, &fast_params()).unwrap();
let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default()));
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
let fid = f.id.clone();
item.sections.push(Section { name: None, fields: vec![f] });
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap();
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v2".into()))).unwrap();
let blob = encrypt_item(&item, &key).unwrap();
let decoded = decrypt_item(&blob, &key).unwrap();
let hist = decoded.field_history.get(&fid).unwrap();
assert_eq!(hist.len(), 2);
assert_eq!(hist[0].value.as_str(), "v0");
assert_eq!(hist[1].value.as_str(), "v1");
}
#[test]
fn wrong_key_fails_with_opaque_decrypt() {
use relicario_core::RelicarioError;
let salt = [0u8; 32];
let img = [0u8; 32];
let right = derive_master_key(b"correct", &img, &salt, &fast_params()).unwrap();
let wrong = derive_master_key(b"wrong", &img, &salt, &fast_params()).unwrap();
let item = Item::new("x".into(), ItemCore::SecureNote(SecureNoteCore::default()));
let blob = encrypt_item(&item, &right).unwrap();
let err = decrypt_item(&blob, &wrong);
assert!(matches!(err, Err(RelicarioError::Decrypt)));
}

View File

@@ -10,11 +10,10 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
relicario-core = { path = "../relicario-core" }
wasm-bindgen = "0.2"
js-sys = "0.3"
serde-wasm-bindgen = "0.6"
serde_json = "1"
hmac = "0.12"
sha1 = "0.10"
data-encoding = "2"
serde = { version = "1", features = ["derive"] }
zeroize = "1"
getrandom = { version = "0.2", features = ["js"] }
[dev-dependencies]

View File

@@ -1,364 +1,282 @@
//! WASM bindings for the relicario password manager.
//! WASM bindings for relicario.
//!
//! This crate wraps [`relicario_core`] for use in a Chrome MV3 browser extension via
//! `wasm-bindgen`. Every function marked `#[wasm_bindgen]` is callable from
//! JavaScript after loading the compiled `.wasm` module.
//!
//! All crypto operations run entirely in the browser -- the extension never sends
//! secrets to any server. The TOTP function lets the extension generate live 6-digit
//! authenticator codes without a separate authenticator app.
//!
//! ## Design notes
//!
//! - Functions accept and return `Vec<u8>`, `&[u8]`, and `String` -- wasm-bindgen
//! handles the JS ↔ Rust marshalling automatically (typed arrays for bytes, strings
//! for JSON).
//! - Errors are mapped to `JsValue` strings so they surface as thrown exceptions in JS.
//! - `generate_password` and `generate_entry_id` use `js_sys::Math::random()` because
//! `OsRng`/`getrandom` requires special WASM configuration. `Math.random()` is
//! sufficient for these non-security-critical operations (password character selection
//! and identifier generation).
//! The bridge exposes an opaque `SessionHandle` API: the master key is held
//! entirely in WASM linear memory, wrapped in `Zeroizing<[u8; 32]>`, and
//! looked up per call via a u32 handle. JS cannot read key bytes.
mod session;
use wasm_bindgen::prelude::*;
use relicario_core::crypto::{self, KdfParams};
use relicario_core::entry::Entry;
use relicario_core::vault;
use relicario_core::imgsecret;
use relicario_core::{derive_master_key, imgsecret, KdfParams};
use hmac::{Hmac, Mac};
use sha1::Sha1;
/// Derive a 256-bit master key from a passphrase, image secret, salt, and KDF parameters.
///
/// The `params_json` argument is a JSON object with fields `argon2_m`, `argon2_t`,
/// and `argon2_p` (matching [`KdfParams`]). Example:
///
/// ```json
/// {"argon2_m": 65536, "argon2_t": 3, "argon2_p": 4}
/// ```
///
/// Returns a 32-byte `Uint8Array` in JavaScript.
/// Handle type returned from `unlock`. Backed by a `u32`; opaque to JS.
#[wasm_bindgen]
pub fn derive_master_key(
pub struct SessionHandle(u32);
#[wasm_bindgen]
impl SessionHandle {
#[wasm_bindgen(getter)]
pub fn value(&self) -> u32 { self.0 }
}
#[wasm_bindgen]
pub fn unlock(
passphrase: &str,
image_secret: &[u8],
image_bytes: &[u8],
salt: &[u8],
params_json: &str,
) -> Result<Vec<u8>, JsValue> {
let params: KdfParams =
serde_json::from_str(params_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
let image_secret: &[u8; 32] = image_secret
.try_into()
.map_err(|_| JsValue::from_str("image_secret must be exactly 32 bytes"))?;
let salt: &[u8; 32] = salt
.try_into()
.map_err(|_| JsValue::from_str("salt must be exactly 32 bytes"))?;
let key = crypto::derive_master_key(passphrase.as_bytes(), image_secret, salt, &params)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(key.to_vec())
) -> Result<SessionHandle, JsError> {
let params: KdfParams = serde_json::from_str(params_json)
.map_err(|e| JsError::new(&format!("params: {e}")))?;
let image_secret = imgsecret::extract(image_bytes)
.map_err(|e| JsError::new(&e.to_string()))?;
let salt_arr: &[u8; 32] = salt.try_into()
.map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, &params)
.map_err(|e| JsError::new(&e.to_string()))?;
let handle = session::insert(master_key);
Ok(SessionHandle(handle))
}
/// Encrypt arbitrary plaintext bytes under a 256-bit key using XChaCha20-Poly1305.
///
/// Returns the ciphertext as a `Uint8Array` in the format:
/// `version(1) || nonce(24) || ciphertext+tag`.
#[wasm_bindgen]
pub fn encrypt(plaintext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue> {
let key: &[u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
crypto::encrypt(key, plaintext).map_err(|e| JsValue::from_str(&e.to_string()))
pub fn lock(handle: &SessionHandle) -> bool {
session::remove(handle.0)
}
// Subsequent wasm_bindgen fns added in Tasks 19-21.
use serde_wasm_bindgen::Serializer;
use relicario_core::{
decrypt_item, decrypt_manifest, decrypt_settings,
encrypt_item, encrypt_manifest, encrypt_settings,
Item, Manifest, VaultSettings,
};
fn need_key(handle: &SessionHandle) -> Result<(), JsError> {
if session::with(handle.0, |_| ()).is_some() { Ok(()) }
else { Err(JsError::new("invalid or locked session handle")) }
}
fn js_value_for<T: serde::Serialize>(v: &T) -> Result<JsValue, JsError> {
let ser = Serializer::new().serialize_maps_as_objects(true);
v.serialize(&ser).map_err(|e| JsError::new(&e.to_string()))
}
/// Decrypt a ciphertext blob produced by [`encrypt`], returning the original plaintext.
///
/// Returns the plaintext as a `Uint8Array`. Throws if the key is wrong or the data
/// has been tampered with.
#[wasm_bindgen]
pub fn decrypt(ciphertext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue> {
let key: &[u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
crypto::decrypt(key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))
pub fn manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
need_key(handle)?;
let out = session::with(handle.0, |k| decrypt_manifest(encrypted, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))?;
js_value_for(&out)
}
/// Extract the 32-byte steganographic secret from a JPEG image.
///
/// Returns a 32-byte `Uint8Array` containing the embedded secret.
/// Throws if the image is not a valid JPEG or the secret cannot be recovered.
#[wasm_bindgen]
pub fn extract_image_secret(jpeg_bytes: &[u8]) -> Result<Vec<u8>, JsValue> {
let secret =
imgsecret::extract(jpeg_bytes).map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(secret.to_vec())
pub fn manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Result<Vec<u8>, JsError> {
need_key(handle)?;
let m: Manifest = serde_json::from_str(manifest_json)
.map_err(|e| JsError::new(&format!("manifest json: {e}")))?;
session::with(handle.0, |k| encrypt_manifest(&m, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))
}
/// Embed a 256-bit secret into a carrier JPEG image.
#[wasm_bindgen]
pub fn embed_image_secret(carrier_jpeg: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsValue> {
let secret: [u8; 32] = secret
.try_into()
.map_err(|_| JsValue::from_str("secret must be exactly 32 bytes"))?;
relicario_core::imgsecret::embed(carrier_jpeg, &secret)
.map_err(|e| JsValue::from_str(&e.to_string()))
pub fn item_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
need_key(handle)?;
let out = session::with(handle.0, |k| decrypt_item(encrypted, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))?;
js_value_for(&out)
}
/// Encrypt an [`Entry`] (given as a JSON string) under the master key.
///
/// The `entry_json` must deserialize into an [`Entry`] struct. Returns the
/// ciphertext as a `Uint8Array`.
#[wasm_bindgen]
pub fn encrypt_entry(entry_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue> {
let key: &[u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
let entry: Entry =
serde_json::from_str(entry_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
vault::encrypt_entry(key, &entry).map_err(|e| JsValue::from_str(&e.to_string()))
pub fn item_encrypt(handle: &SessionHandle, item_json: &str) -> Result<Vec<u8>, JsError> {
need_key(handle)?;
let item: Item = serde_json::from_str(item_json)
.map_err(|e| JsError::new(&format!("item json: {e}")))?;
session::with(handle.0, |k| encrypt_item(&item, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))
}
/// Decrypt an entry ciphertext blob and return the entry as a JSON string.
///
/// Throws if the key is wrong, the data is tampered, or the decrypted JSON is malformed.
#[wasm_bindgen]
pub fn decrypt_entry(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue> {
let key: &[u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
let entry =
vault::decrypt_entry(key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))?;
serde_json::to_string(&entry).map_err(|e| JsValue::from_str(&e.to_string()))
pub fn settings_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
need_key(handle)?;
let out = session::with(handle.0, |k| decrypt_settings(encrypted, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))?;
js_value_for(&out)
}
/// Encrypt a [`Manifest`] (given as a JSON string) under the master key.
///
/// Returns the ciphertext as a `Uint8Array`.
#[wasm_bindgen]
pub fn encrypt_manifest(manifest_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue> {
let key: &[u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
let manifest: relicario_core::entry::Manifest =
serde_json::from_str(manifest_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
vault::encrypt_manifest(key, &manifest).map_err(|e| JsValue::from_str(&e.to_string()))
pub fn settings_encrypt(handle: &SessionHandle, settings_json: &str) -> Result<Vec<u8>, JsError> {
need_key(handle)?;
let s: VaultSettings = serde_json::from_str(settings_json)
.map_err(|e| JsError::new(&format!("settings json: {e}")))?;
session::with(handle.0, |k| encrypt_settings(&s, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))
}
/// Decrypt a manifest ciphertext blob and return the manifest as a JSON string.
///
/// Throws if the key is wrong, the data is tampered, or the decrypted JSON is malformed.
// ── Task 20: attachment / generator / imgsecret / ID / TOTP bridges ─────────
use relicario_core::{decrypt_attachment, encrypt_attachment, FieldId, ItemId};
#[wasm_bindgen]
pub fn decrypt_manifest(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue> {
let key: &[u8; 32] = key
.try_into()
.map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
let manifest = vault::decrypt_manifest(key, ciphertext)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
serde_json::to_string(&manifest).map_err(|e| JsValue::from_str(&e.to_string()))
pub struct EncryptedAttachment {
aid: String,
bytes: Vec<u8>,
}
/// Generate a 6-digit TOTP code per RFC 6238.
///
/// # Arguments
///
/// - `secret_base32`: the shared secret encoded in base32 (with or without padding).
/// - `timestamp_secs`: the current Unix timestamp in seconds.
///
/// # Algorithm
///
/// 1. Decode the base32 secret.
/// 2. Compute the time step: `T = timestamp_secs / 30`.
/// 3. Compute `HMAC-SHA1(secret, T as big-endian u64)`.
/// 4. Dynamic truncation: extract a 4-byte segment from the HMAC output at an
/// offset determined by the last nibble.
/// 5. Mask the high bit, take modulo 10^6, and zero-pad to 6 digits.
///
/// Returns a 6-character string like `"287082"`.
#[wasm_bindgen]
pub fn generate_totp(secret_base32: &str, timestamp_secs: u64) -> Result<String, JsValue> {
generate_totp_inner(secret_base32, timestamp_secs)
.map_err(|e| JsValue::from_str(&e.to_string()))
impl EncryptedAttachment {
#[wasm_bindgen(getter)] pub fn aid(&self) -> String { self.aid.clone() }
#[wasm_bindgen(getter)] pub fn bytes(&self) -> Vec<u8> { self.bytes.clone() }
}
/// Inner TOTP implementation that returns a standard Result for testability
/// (avoids depending on JsValue in native tests).
fn generate_totp_inner(
secret_base32: &str,
timestamp_secs: u64,
) -> std::result::Result<String, String> {
// Normalize: strip whitespace, uppercase, remove padding for lenient decode,
// then re-pad to a multiple of 8 for strict base32.
let cleaned: String = secret_base32
.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>()
.to_uppercase()
.trim_end_matches('=')
.to_string();
// Re-pad to a multiple of 8 characters (base32 requirement).
let padded = {
let remainder = cleaned.len() % 8;
if remainder == 0 {
cleaned
} else {
let pad_count = 8 - remainder;
format!("{}{}", cleaned, "=".repeat(pad_count))
}
};
let secret = data_encoding::BASE32
.decode(padded.as_bytes())
.map_err(|e| format!("invalid base32 secret: {}", e))?;
// Time step: T = floor(timestamp / 30)
let time_step = timestamp_secs / 30;
// HMAC-SHA1(secret, time_step as big-endian u64)
type HmacSha1 = Hmac<Sha1>;
let mut mac =
HmacSha1::new_from_slice(&secret).map_err(|e| format!("HMAC init failed: {}", e))?;
mac.update(&time_step.to_be_bytes());
let result = mac.finalize().into_bytes();
// Dynamic truncation per RFC 4226 section 5.4
let offset = (result[19] & 0x0F) as usize;
let code = ((result[offset] as u32 & 0x7F) << 24)
| ((result[offset + 1] as u32) << 16)
| ((result[offset + 2] as u32) << 8)
| (result[offset + 3] as u32);
// 6-digit code, zero-padded
Ok(format!("{:06}", code % 1_000_000))
}
/// Generate a random password of the given length.
///
/// Uses `js_sys::Math::random()` for randomness (not cryptographically secure,
/// but sufficient for password character selection). The character set includes
/// uppercase, lowercase, digits, and common symbols.
///
/// This function is only available in WASM -- it will panic in native builds
/// because `js_sys::Math::random()` requires a JS runtime.
#[wasm_bindgen]
pub fn generate_password(length: u32) -> String {
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{}|;:,.<>?";
(0..length)
.map(|_| {
let idx = (js_sys::Math::random() * CHARSET.len() as f64) as usize;
CHARSET[idx % CHARSET.len()] as char
})
.collect()
pub fn attachment_encrypt(
handle: &SessionHandle,
plaintext: &[u8],
max_bytes: u64,
) -> Result<EncryptedAttachment, JsError> {
need_key(handle)?;
let enc = session::with(handle.0, |k| encrypt_attachment(plaintext, k, max_bytes))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(EncryptedAttachment { aid: enc.id.as_str().to_owned(), bytes: enc.bytes })
}
/// Generate a random 8-character hex string for use as an entry ID.
///
/// Uses `js_sys::Math::random()` for randomness. Entry IDs are not
/// security-sensitive -- they are just opaque identifiers.
///
/// This function is only available in WASM -- it will panic in native builds
/// because `js_sys::Math::random()` requires a JS runtime.
#[wasm_bindgen]
pub fn generate_entry_id() -> String {
(0..4)
.map(|_| {
let byte = (js_sys::Math::random() * 256.0) as u8;
format!("{:02x}", byte)
})
.collect()
pub fn attachment_decrypt(
handle: &SessionHandle,
encrypted: &[u8],
) -> Result<Vec<u8>, JsError> {
need_key(handle)?;
let plain = session::with(handle.0, |k| decrypt_attachment(encrypted, k))
.unwrap()
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(plain.to_vec())
}
#[wasm_bindgen] pub fn new_item_id() -> String { ItemId::new().as_str().to_owned() }
#[wasm_bindgen] pub fn new_field_id() -> String { FieldId::new().as_str().to_owned() }
use relicario_core::{
generate_passphrase as core_generate_passphrase,
generate_password as core_generate_password,
rate_passphrase as core_rate_passphrase,
GeneratorRequest,
};
#[wasm_bindgen]
pub fn generate_password(request_json: &str) -> Result<String, JsError> {
let req: GeneratorRequest = serde_json::from_str(request_json)
.map_err(|e| JsError::new(&format!("generator request: {e}")))?;
let out = core_generate_password(&req).map_err(|e| JsError::new(&e.to_string()))?;
Ok(out.as_str().to_owned())
}
#[wasm_bindgen]
pub fn generate_passphrase(request_json: &str) -> Result<String, JsError> {
let req: GeneratorRequest = serde_json::from_str(request_json)
.map_err(|e| JsError::new(&format!("generator request: {e}")))?;
let out = core_generate_passphrase(&req).map_err(|e| JsError::new(&e.to_string()))?;
Ok(out.as_str().to_owned())
}
#[wasm_bindgen]
pub fn rate_passphrase(p: &str) -> Result<JsValue, JsError> {
let est = core_rate_passphrase(p);
js_value_for(&serde_json::json!({
"score": est.score,
"guesses_log10": est.guesses_log10,
}))
}
#[wasm_bindgen]
pub fn extract_image_secret(image_bytes: &[u8]) -> Result<Vec<u8>, JsError> {
let s = imgsecret::extract(image_bytes).map_err(|e| JsError::new(&e.to_string()))?;
Ok(s.to_vec())
}
#[wasm_bindgen]
pub fn embed_image_secret(carrier: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsError> {
let s: &[u8; 32] = secret.try_into()
.map_err(|_| JsError::new("secret must be exactly 32 bytes"))?;
imgsecret::embed(carrier, s).map_err(|e| JsError::new(&e.to_string()))
}
use relicario_core::item_types::{TotpConfig, compute_totp_code};
#[wasm_bindgen]
pub struct TotpCode {
code: String,
expires_at: u64,
}
#[wasm_bindgen]
impl TotpCode {
#[wasm_bindgen(getter)] pub fn code(&self) -> String { self.code.clone() }
#[wasm_bindgen(getter)] pub fn expires_at(&self) -> u64 { self.expires_at }
}
#[wasm_bindgen]
pub fn totp_compute(
config_json: &str,
now_unix_seconds: u64,
) -> Result<TotpCode, JsError> {
let cfg: TotpConfig = serde_json::from_str(config_json)
.map_err(|e| JsError::new(&format!("totp config: {e}")))?;
let code = compute_totp_code(&cfg, now_unix_seconds)
.map_err(|e| JsError::new(&e.to_string()))?;
let period = cfg.period_seconds as u64;
let expires_at = ((now_unix_seconds / period) + 1) * period;
Ok(TotpCode { code, expires_at })
}
#[cfg(test)]
mod tests {
mod session_tests {
use super::*;
use zeroize::Zeroizing;
#[test]
fn totp_rfc6238_test_vector() {
// secret = "12345678901234567890" ASCII, time = 59, expected = "287082"
let secret_b32 = data_encoding::BASE32.encode(b"12345678901234567890");
let result = generate_totp_inner(&secret_b32, 59).unwrap();
assert_eq!(result, "287082");
fn insert_then_remove_clears_entry() {
session::clear();
let h = session::insert(Zeroizing::new([0x11u8; 32]));
assert_ne!(h, 0);
assert!(session::remove(h));
assert!(!session::remove(h)); // second remove false
}
#[test]
fn totp_rfc6238_test_vector_2() {
// time = 1111111109, expected = "081804"
let secret_b32 = data_encoding::BASE32.encode(b"12345678901234567890");
let result = generate_totp_inner(&secret_b32, 1111111109).unwrap();
assert_eq!(result, "081804");
fn with_yields_key_only_while_session_lives() {
session::clear();
let h = session::insert(Zeroizing::new([0x22u8; 32]));
let byte = session::with(h, |k| k[0]);
assert_eq!(byte, Some(0x22));
session::remove(h);
let byte = session::with(h, |k| k[0]);
assert_eq!(byte, None);
}
#[test]
fn totp_rfc6238_test_vector_3() {
// time = 1234567890, expected = "005924"
let secret_b32 = data_encoding::BASE32.encode(b"12345678901234567890");
let result = generate_totp_inner(&secret_b32, 1234567890).unwrap();
assert_eq!(result, "005924");
}
#[test]
fn totp_invalid_base32_fails() {
let result = generate_totp_inner("not-valid-base32!!!", 1000);
assert!(result.is_err());
}
#[test]
fn derive_key_via_wasm_wrapper() {
let params = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#;
let key =
derive_master_key("test-passphrase", &[0x42u8; 32], &[0x01u8; 32], params).unwrap();
assert_eq!(key.len(), 32);
let key2 =
derive_master_key("test-passphrase", &[0x42u8; 32], &[0x01u8; 32], params).unwrap();
assert_eq!(key, key2);
}
#[test]
fn encrypt_decrypt_via_wasm_wrapper() {
let key = [0xABu8; 32];
let ciphertext = encrypt(b"hello wasm", &key).unwrap();
let decrypted = decrypt(&ciphertext, &key).unwrap();
assert_eq!(decrypted, b"hello wasm");
}
#[test]
fn embed_then_extract_round_trip() {
use image::codecs::jpeg::JpegEncoder;
use image::{ImageBuffer, ImageEncoder, Rgb};
let img = ImageBuffer::from_fn(400, 300, |x, y| {
Rgb([
((x * 7 + y * 13) % 256) as u8,
((x * 11 + y * 3) % 256) as u8,
((x * 5 + y * 17) % 256) as u8,
])
});
let mut jpeg_buf = Vec::new();
let encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, 92);
encoder.write_image(img.as_raw(), 400, 300, image::ExtendedColorType::Rgb8).unwrap();
let secret = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04,
0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C,
0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14,
0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1Cu8];
let stego = embed_image_secret(&jpeg_buf, &secret).unwrap();
let extracted = extract_image_secret(&stego).unwrap();
assert_eq!(extracted, secret);
}
#[test]
fn encrypt_entry_decrypt_entry_round_trip() {
let key = [0xABu8; 32];
let entry_json = r#"{"name":"Test","password":"secret","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}"#;
let ciphertext = encrypt_entry(entry_json, &key).unwrap();
let result = decrypt_entry(&ciphertext, &key).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["name"], "Test");
assert_eq!(parsed["password"], "secret");
fn manifest_round_trip_via_handle() {
use relicario_core::{Manifest, decrypt_manifest};
session::clear();
let h = session::insert(Zeroizing::new([0x55u8; 32]));
let handle = SessionHandle(h);
let key = Zeroizing::new([0x55u8; 32]);
let empty = Manifest::new();
let bytes = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap();
assert!(!bytes.is_empty());
// Decrypt via core directly (avoids js-sys on native).
let parsed: Manifest = decrypt_manifest(&bytes, &key).unwrap();
assert_eq!(parsed.items.len(), 0);
// Random nonces mean two encryptions of the same plaintext differ.
let bytes2 = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap();
assert_ne!(bytes, bytes2, "nonces must differ");
}
}

View File

@@ -0,0 +1,41 @@
//! Opaque session-handle bridge. The master key never leaves WASM linear
//! memory; JS receives only a u32 handle that it passes back on every
//! subsequent call.
use std::cell::RefCell;
use std::collections::HashMap;
use zeroize::Zeroizing;
thread_local! {
static SESSIONS: RefCell<HashMap<u32, Zeroizing<[u8; 32]>>> = RefCell::new(HashMap::new());
static NEXT_HANDLE: RefCell<u32> = const { RefCell::new(1) };
}
pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 {
let handle = NEXT_HANDLE.with(|n| {
let mut n = n.borrow_mut();
let h = *n;
*n = n.wrapping_add(1);
if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle
h
});
SESSIONS.with(|s| { s.borrow_mut().insert(handle, key); });
handle
}
pub fn with<F, R>(handle: u32, f: F) -> Option<R>
where
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
{
SESSIONS.with(|s| s.borrow().get(&handle).map(f))
}
pub fn remove(handle: u32) -> bool {
SESSIONS.with(|s| s.borrow_mut().remove(&handle).is_some())
}
/// For tests only — empty the table and wipe all sessions.
#[cfg(test)]
pub fn clear() {
SESSIONS.with(|s| s.borrow_mut().clear());
}

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];
const credResp = await chrome.runtime.sendMessage({
type: 'get_credentials',
id,
});
if (credResp?.ok) {
chrome.runtime.sendMessage({
type: 'fill_credentials',
username: credResp.data.username,
password: credResp.data.password,
});
}
await handleSingleCandidate(candidates[0][0]);
} else {
// Multiple matches — show inline picker.
showPicker(icon, candidates);
showPicker(passwordField, candidates);
}
});
}
/// Show a small dropdown picker below the icon for selecting among multiple candidates.
/// Fetch credentials for a single item and either fill immediately or
/// display the TOFU ack hint.
async function handleSingleCandidate(id: ItemId): Promise<void> {
const credResp = await chrome.runtime.sendMessage({
type: 'get_credentials',
id,
}) as Response;
if (!credResp?.ok) return;
const data = (credResp as CredentialsResponse).data;
if ('requires_ack' in data && data.requires_ack) {
showAckHint(data.hostname);
return;
}
// Discriminated union: must be the {username, password} variant here.
if ('username' in data && 'password' in data) {
fillFields(data.username, data.password);
}
}
/// Render a dropdown picker below the password field for selecting among
/// multiple candidates. The picker lives in its own closed Shadow DOM.
function showPicker(
anchor: HTMLElement,
candidates: Array<[string, ManifestEntry]>,
anchor: HTMLInputElement,
candidates: Array<[ItemId, ManifestEntry]>,
): void {
// Remove any existing picker.
document.querySelectorAll('.relicario-picker').forEach(el => el.remove());
closeOverlay();
const surface = createShadowHost();
currentOverlay = surface;
const { host, root } = surface;
const rect = anchor.getBoundingClientRect();
host.style.cssText = [
'position: fixed',
`top: ${rect.bottom + 4}px`,
`left: ${rect.right - 180}px`,
'z-index: 2147483647',
].join('; ');
const picker = document.createElement('div');
picker.className = 'relicario-picker';
Object.assign(picker.style, {
position: 'absolute',
right: '0',
top: '100%',
marginTop: '4px',
background: '#161b22',
border: '1px solid #30363d',
borderRadius: '6px',
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
zIndex: '9999999',
minWidth: '180px',
maxHeight: '200px',
overflowY: 'auto',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '12px',
});
picker.style.cssText = [
'background: #161b22', 'border: 1px solid #30363d',
'border-radius: 6px', 'box-shadow: 0 4px 12px rgba(0,0,0,0.4)',
'min-width: 180px', 'max-height: 200px', 'overflow-y: auto',
"font-family: 'JetBrains Mono', monospace", 'font-size: 12px',
].join('; ');
for (const [id, entry] of candidates) {
const row = document.createElement('div');
row.textContent = `${entry.name}${entry.username ? ` (${entry.username})` : ''}`;
Object.assign(row.style, {
padding: '8px 12px',
cursor: 'pointer',
color: '#c9d1d9',
borderBottom: '1px solid #21262d',
});
const label = entry.title + (/* user hint */ '');
row.textContent = label;
row.style.cssText = [
'padding: 8px 12px', 'cursor: pointer', 'color: #c9d1d9',
'border-bottom: 1px solid #21262d',
].join('; ');
row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; });
row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; });
row.addEventListener('click', async (e) => {
e.stopPropagation();
picker.remove();
const credResp = await chrome.runtime.sendMessage({
type: 'get_credentials',
id,
});
if (credResp?.ok) {
chrome.runtime.sendMessage({
type: 'fill_credentials',
username: credResp.data.username,
password: credResp.data.password,
});
}
closeOverlay();
await handleSingleCandidate(id);
});
picker.appendChild(row);
}
anchor.parentElement?.appendChild(picker);
root.appendChild(picker);
// Close picker on outside click.
const closeHandler = (e: MouseEvent) => {
if (!picker.contains(e.target as Node) && e.target !== anchor) {
picker.remove();
// Close picker on outside click (scoped to document; shadow root blocks
// composedPath for closed mode but the host element still shows up).
const closeHandler = (e: MouseEvent): void => {
if (e.target !== host) {
closeOverlay();
document.removeEventListener('click', closeHandler);
}
};
setTimeout(() => document.addEventListener('click', closeHandler), 0);
}
/// TOFU origin-ack hint: credentials exist for this host but the user has
/// never explicitly acknowledged autofill here. Instruct them to open
/// relicario to confirm — we do not (and cannot) fill until ack-autofill
/// has been called from the popup.
function showAckHint(hostname: string): void {
closeOverlay();
const surface = createShadowHost();
currentOverlay = surface;
const { host, root } = surface;
host.style.cssText = [
'position: fixed', 'top: 16px', 'right: 16px',
'z-index: 2147483647',
].join('; ');
const hint = document.createElement('div');
hint.style.cssText = [
'font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace',
'font-size: 12px', 'color: #c9d1d9', 'background: #161b22',
'border: 1px solid #30363d', 'border-radius: 6px',
'padding: 10px 14px', 'box-shadow: 0 4px 12px rgba(0,0,0,0.4)',
'max-width: 320px', 'line-height: 1.5',
].join('; ');
const title = document.createElement('div');
title.style.cssText = 'font-weight: 700; margin-bottom: 4px; color: #58a6ff;';
title.textContent = 'relicario';
hint.appendChild(title);
const body = document.createElement('div');
body.appendChild(document.createTextNode('First autofill on '));
const hostSpan = document.createElement('strong');
hostSpan.textContent = hostname;
body.appendChild(hostSpan);
body.appendChild(document.createTextNode(' — open relicario to confirm.'));
hint.appendChild(body);
const close = document.createElement('div');
close.textContent = '✕';
close.style.cssText = [
'position: absolute', 'top: 6px', 'right: 8px',
'cursor: pointer', 'color: #8b949e', 'font-size: 14px',
].join('; ');
close.addEventListener('click', closeOverlay);
hint.style.position = 'relative';
hint.appendChild(close);
root.appendChild(hint);
// Auto-dismiss after 8 seconds
setTimeout(() => {
if (currentOverlay === surface) closeOverlay();
}, 8000);
}

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,
};
}
// --- 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 ---
// Single router-state object shared by all messages for this SW instance.
const state: RouterState = {
manifest: null,
gitHost: null,
wasm: null,
};
chrome.runtime.onMessage.addListener(
(request: Request, _sender: chrome.runtime.MessageSender, sendResponse: (response: Response) => void) => {
handleMessage(request)
.then(sendResponse)
.catch((err: Error) => sendResponse({ ok: false, error: err.message }));
// Return true to indicate async response.
return true;
(request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => {
(async () => {
if (!state.wasm) {
// eslint-disable-next-line no-console
console.log('[relicario sw] initializing WASM on first message');
state.wasm = await initWasm();
}
return route(request, state, sender);
})()
.then((r) => {
if (!r.ok) {
// eslint-disable-next-line no-console
console.warn(`[relicario sw] ${request.type} -> error:`, r.error);
}
sendResponse(r);
})
.catch((err: Error) => {
// eslint-disable-next-line no-console
console.error(`[relicario sw] ${request.type} threw:`, err);
sendResponse({ ok: false, error: err.message });
});
return true; // async response
},
);
async function handleMessage(req: Request): Promise<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,25 +1,65 @@
/// Type declarations for the relicario WASM module produced by wasm-pack.
// Ambient module declarations for the WASM glue code.
// The module specifier must exactly match what's used in import statements.
// 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 default function init(input?: string | URL): Promise<void>;
export function derive_master_key(
export class SessionHandle {
readonly value: number;
free(): void;
}
export class EncryptedAttachment {
readonly aid: string;
readonly bytes: Uint8Array;
free(): void;
}
export class TotpCode {
readonly code: string;
readonly expires_at: bigint;
free(): void;
}
export function unlock(
passphrase: string,
image_secret: Uint8Array,
image_bytes: Uint8Array,
salt: Uint8Array,
params_json: string,
): Uint8Array;
export function encrypt(plaintext: Uint8Array, key: Uint8Array): Uint8Array;
export function decrypt(ciphertext: Uint8Array, key: Uint8Array): Uint8Array;
export function extract_image_secret(jpeg_bytes: Uint8Array): Uint8Array;
export function embed_image_secret(carrier_jpeg: Uint8Array, secret: Uint8Array): Uint8Array;
export function encrypt_entry(entry_json: string, key: Uint8Array): Uint8Array;
export function decrypt_entry(ciphertext: Uint8Array, key: Uint8Array): string;
export function encrypt_manifest(manifest_json: string, key: Uint8Array): Uint8Array;
export function decrypt_manifest(ciphertext: Uint8Array, key: Uint8Array): string;
export function generate_totp(secret_base32: string, timestamp_secs: bigint): string;
export function generate_password(length: number): string;
export function generate_entry_id(): string;
): SessionHandle;
export function lock(handle: SessionHandle): boolean;
export function manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
export function manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array;
export function item_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
export function item_encrypt(handle: SessionHandle, item_json: string): Uint8Array;
export function settings_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
export function settings_encrypt(handle: SessionHandle, settings_json: string): Uint8Array;
export function attachment_encrypt(
handle: SessionHandle,
plaintext: Uint8Array,
max_bytes: bigint,
): EncryptedAttachment;
export function attachment_decrypt(handle: SessionHandle, encrypted: Uint8Array): Uint8Array;
export function new_item_id(): string;
export function new_field_id(): string;
export function generate_password(request_json: string): string;
export function generate_passphrase(request_json: string): string;
export function rate_passphrase(p: string): { score: number; guesses_log10: number };
export function extract_image_secret(image_bytes: Uint8Array): Uint8Array;
export function embed_image_secret(carrier: Uint8Array, secret: Uint8Array): Uint8Array;
export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode;
export default function init(module_or_path?: unknown): Promise<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'],
},
});