diff --git a/extension/src/service-worker/index.ts b/extension/src/service-worker/index.ts index 8381ae5..f21e7c4 100644 --- a/extension/src/service-worker/index.ts +++ b/extension/src/service-worker/index.ts @@ -1,18 +1,10 @@ -/// Background script entry point for the relicario browser extension. -/// -/// Transitional slice-3 shape: keeps the flat onMessage listener but uses -/// the new typed-item vault + SessionHandle. The router split lands in -/// slice 4. +/// 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 { 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 type { RouterState } from './router/index'; +import { route } from './router/index'; import * as vault from './vault'; -import * as session from './session'; - -// --- WASM module load --- // @ts-ignore TS2307 — resolved by webpack alias / copy import initDefault, { initSync } from '../../wasm/relicario_wasm.js'; @@ -44,247 +36,21 @@ async function initWasm(): Promise { return wasm; } -// --- In-memory vault state (cleared on lock or SW restart) --- - -let manifest: Manifest | null = null; -let gitHost: GitHost | null = null; -const totpConfigCache: Map = new Map(); - -// --- chrome.storage.local helpers --- - -async function loadConfig(): Promise { - const result = await chrome.storage.local.get('vaultConfig'); - return (result.vaultConfig as VaultConfig) ?? null; -} - -async function loadImageBase64(): Promise { - const result = await chrome.storage.local.get('imageBase64'); - return (result.imageBase64 as string) ?? null; -} - -async function loadSetupState(): Promise { - const config = await loadConfig(); - const imageBase64 = await loadImageBase64(); - return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null }; -} - -async function loadSettings(): Promise { - const result = await chrome.storage.local.get('relicarioSettings'); - return (result.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS }; -} - -async function saveSettings(settings: DeviceSettings): Promise { - await chrome.storage.local.set({ relicarioSettings: settings }); -} - -async function loadBlacklist(): Promise { - const result = await chrome.storage.local.get('captureBlacklist'); - return (result.captureBlacklist as string[]) ?? []; -} - -async function saveBlacklist(list: string[]): Promise { - await chrome.storage.local.set({ captureBlacklist: list }); -} - -function ensureGitHost(config: VaultConfig): GitHost { - if (!gitHost) { - gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken); - } - return gitHost; -} - -// --- Message handler (flat; router split in slice 4) --- +// 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) + (request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => { + (async () => { + if (!state.wasm) state.wasm = await initWasm(); + return route(request, state, sender); + })() .then(sendResponse) .catch((err: Error) => sendResponse({ ok: false, error: err.message })); - return true; + return true; // async response }, ); - -async function handleMessage(req: Request): Promise { - switch (req.type) { - case 'is_unlocked': - return { ok: true, data: { unlocked: session.getCurrent() !== null } }; - - case 'unlock': { - const w = await initWasm(); - const config = await loadConfig(); - if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' }; - const imageB64 = await loadImageBase64(); - if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' }; - - const imageBytes = base64ToUint8Array(imageB64); - const git = ensureGitHost(config); - const meta = await vault.fetchVaultMeta(git); - - const handle = w.unlock(req.passphrase, imageBytes, meta.salt, meta.paramsJson); - session.setCurrent(handle); - // Best-effort scope clearing. JS strings are immutable so this only - // nulls the one reference on `req`; wasm-bindgen already copied the - // string into WASM linear memory before `unlock` returned. Left in - // as a marker — future work may consider a direct-stream KDF that - // never materializes the string, or a `Zeroizing` wrapper on the - // WASM-side incoming buffer. - (req as { passphrase: string }).passphrase = ''; - - manifest = await vault.fetchAndDecryptManifest(git, handle); - return { ok: true }; - } - - case 'lock': - session.clearCurrent(); - manifest = null; - totpConfigCache.clear(); - return { ok: true }; - - case 'list_items': { - if (!manifest) return { ok: false, error: 'vault_locked' }; - const items = vault.listItems(manifest, req.group); - return { ok: true, data: { items } }; - } - - case 'get_item': { - const handle = session.getCurrent(); - if (!handle || !gitHost) return { ok: false, error: 'vault_locked' }; - const item = await vault.fetchAndDecryptItem(gitHost, handle, req.id); - return { ok: true, data: { item } }; - } - - case 'add_item': { - const handle = session.getCurrent(); - if (!handle || !gitHost || !manifest) return { ok: false, error: 'vault_locked' }; - const w = await initWasm(); - const id = w.new_item_id(); - const item: Item = { ...req.item, id }; - - await vault.encryptAndWriteItem(gitHost, handle, id, item, `add: ${item.title}`); - manifest.items[id] = itemToManifestEntry(item); - await vault.encryptAndWriteManifest(gitHost, handle, manifest, `manifest: add ${item.title}`); - return { ok: true, data: { id } }; - } - - case 'update_item': { - const handle = session.getCurrent(); - if (!handle || !gitHost || !manifest) return { ok: false, error: 'vault_locked' }; - await vault.encryptAndWriteItem(gitHost, handle, req.id, req.item, `update: ${req.item.title}`); - manifest.items[req.id] = itemToManifestEntry(req.item); - await vault.encryptAndWriteManifest(gitHost, handle, manifest, `manifest: update ${req.item.title}`); - totpConfigCache.delete(req.id); - return { ok: true }; - } - - case 'delete_item': { - const handle = session.getCurrent(); - if (!handle || !gitHost || !manifest) return { ok: false, error: 'vault_locked' }; - const entry = manifest.items[req.id]; - if (!entry) return { ok: false, error: 'item_not_found' }; - // Soft-delete: fetch the item, set trashed_at, write it back. - // TODO(slice-4): not atomic across the two git writes. If the manifest - // write fails after the item write, next sync restores the live manifest - // and the trashed item re-appears. Consider manifest-first write order - // or a retry/rollback pass when router-splitting this handler. - const item = await vault.fetchAndDecryptItem(gitHost, handle, req.id); - const now = Math.floor(Date.now() / 1000); - const updated: Item = { ...item, trashed_at: now, modified: now }; - await vault.encryptAndWriteItem(gitHost, handle, req.id, updated, `trash: ${entry.title}`); - manifest.items[req.id] = { ...entry, trashed_at: now, modified: now }; - await vault.encryptAndWriteManifest(gitHost, handle, manifest, `manifest: trash ${entry.title}`); - return { ok: true }; - } - - case 'sync': { - const handle = session.getCurrent(); - if (!handle || !gitHost) return { ok: false, error: 'vault_locked' }; - manifest = await vault.fetchAndDecryptManifest(gitHost, handle); - return { ok: true }; - } - - case 'get_setup_state': { - return { ok: true, data: await loadSetupState() }; - } - - case 'save_setup': { - await chrome.storage.local.set({ - vaultConfig: req.config, - imageBase64: req.imageBase64, - }); - gitHost = null; - return { ok: true }; - } - - case 'rate_passphrase': { - const w = await initWasm(); - return { ok: true, data: w.rate_passphrase(req.passphrase) }; - } - - case 'generate_password': { - const w = await initWasm(); - const password = w.generate_password(JSON.stringify(req.request)); - return { ok: true, data: { password } }; - } - - case 'get_settings': - return { ok: true, data: { settings: await loadSettings() } }; - - case 'update_settings': { - const current = await loadSettings(); - await saveSettings({ ...current, ...req.settings }); - return { ok: true }; - } - - case 'get_blacklist': - return { ok: true, data: { blacklist: await loadBlacklist() } }; - - case 'remove_blacklist': { - const bl = await loadBlacklist(); - await saveBlacklist(bl.filter((h) => h !== req.hostname)); - return { ok: true }; - } - - // Slice 4 / 5 will wire these up properly (currently placeholders so the build passes): - case 'get_totp': - case 'fill_credentials': - case 'ack_autofill_origin': - case 'get_autofill_candidates': - case 'get_credentials': - case 'check_credential': - case 'blacklist_site': - return { ok: false, error: 'not_implemented_yet' }; - - default: { - const exhaustive: never = req; - return { ok: false, error: `unknown_message_type: ${(exhaustive as { type: string }).type}` }; - } - } -} - -/// TS mirror of the Rust core's `ManifestEntry::from_item` -/// (see crates/relicario-core/src/manifest.rs). These two derivations must -/// stay in sync — if the Rust side learns to handle e.g. trailing-whitespace -/// URL parsing differently, this function drifts and the manifest diverges -/// between CLI-written and extension-written items. A future WASM helper -/// `item_to_manifest_entry(handle, item_json)` would eliminate the duplication. -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; } -}