From 20144e8e02ecd55a75e399c2abb8f147ab94dec5 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 19:55:50 -0400 Subject: [PATCH] feat(ext/sw): rewire flat handler onto typed-item vault + SessionHandle --- extension/src/service-worker/index.ts | 393 ++++++++------------------ 1 file changed, 114 insertions(+), 279 deletions(-) diff --git a/extension/src/service-worker/index.ts b/extension/src/service-worker/index.ts index 78f5ffa..1a4bc42 100644 --- a/extension/src/service-worker/index.ts +++ b/extension/src/service-worker/index.ts @@ -1,33 +1,18 @@ /// 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. +/// Transitional slice-3 shape: keeps the flat onMessage listener but uses +/// the new typed-item vault + SessionHandle. The router split lands in +/// slice 4. import type { Request, Response } from '../shared/messages'; -import type { Manifest, VaultConfig, SetupState, RelicarioSettings } from '../shared/types'; -import { DEFAULT_SETTINGS } from '../shared/types'; +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 } from './git-host'; -import { base64ToUint8Array } from './git-host'; +import { createGitHost, base64ToUint8Array } from './git-host'; import * as vault from './vault'; +import * as session from './session'; -// --- State held in memory (cleared on lock or service worker restart) --- - -let masterKey: Uint8Array | null = null; -let manifest: Manifest | null = null; -let gitHost: GitHost | null = null; -let wasmReady = false; -// Cache TOTP secrets by entry ID to avoid re-fetching the entry every second -const totpSecretCache: Map = new Map(); - -// --- WASM initialization --- - -// Chrome MV3 uses service workers which do NOT support dynamic import(). -// Firefox MV3 uses background scripts which DO support dynamic import(). -// We detect the environment at runtime and use the appropriate loading strategy. +// --- WASM module load --- // @ts-ignore TS2307 — resolved by webpack alias / copy import initDefault, { initSync } from '../../wasm/relicario_wasm.js'; @@ -46,23 +31,26 @@ async function initWasm(): Promise { && self instanceof (SWGlobalScope as unknown as typeof EventTarget); if (isServiceWorker) { - // Chrome: fetch WASM binary and instantiate synchronously const wasmResponse = await fetch(chrome.runtime.getURL('relicario_wasm_bg.wasm')); const wasmBytes = await wasmResponse.arrayBuffer(); initSync({ module: new WebAssembly.Module(wasmBytes) }); } else { - // Firefox: background script — async init works const wasmUrl = chrome.runtime.getURL('relicario_wasm_bg.wasm'); await initDefault(wasmUrl); } vault.setWasm(wasmBindings); wasm = wasmBindings; - wasmReady = true; return wasm; } -// --- Storage helpers --- +// --- 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'); @@ -77,21 +65,15 @@ async function loadImageBase64(): Promise { async function loadSetupState(): Promise { const config = await loadConfig(); const imageBase64 = await loadImageBase64(); - return { - config, - imageBase64, - isConfigured: config !== null && imageBase64 !== null, - }; + return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null }; } -// --- Settings & blacklist helpers --- - -async function loadSettings(): Promise { +async function loadSettings(): Promise { const result = await chrome.storage.local.get('relicarioSettings'); - return (result.relicarioSettings as RelicarioSettings) ?? { ...DEFAULT_SETTINGS }; + return (result.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS }; } -async function saveSettings(settings: RelicarioSettings): Promise { +async function saveSettings(settings: DeviceSettings): Promise { await chrome.storage.local.set({ relicarioSettings: settings }); } @@ -111,204 +93,109 @@ function ensureGitHost(config: VaultConfig): GitHost { return gitHost; } -// --- Message handler --- +// --- Message handler (flat; router split in slice 4) --- 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; }, ); async function handleMessage(req: Request): Promise { switch (req.type) { - // --- Auth --- - case 'is_unlocked': - return { ok: true, data: { unlocked: masterKey !== null } }; + 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 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); + const handle = w.unlock(req.passphrase, imageBytes, meta.salt, meta.paramsJson); + session.setCurrent(handle); + // Clear passphrase from scope best-effort. + // (JS strings are immutable; the message object goes out of scope after return.) + (req as { passphrase: string }).passphrase = ''; + manifest = await vault.fetchAndDecryptManifest(git, handle); return { ok: true }; } case 'lock': - masterKey = null; + session.clearCurrent(); manifest = null; - totpSecretCache.clear(); + totpConfigCache.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 'list_items': { + if (!manifest) return { ok: false, error: 'vault_locked' }; + const items = vault.listItems(manifest, req.group); + return { ok: true, data: { items } }; } - 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 '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 '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' }; - } + case 'add_item': { + const handle = session.getCurrent(); + if (!handle || !gitHost || !manifest) return { ok: false, error: 'vault_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}`, - ); + 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_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}`, - ); - + 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_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); - } - + 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. + const item = await vault.fetchAndDecryptItem(gitHost, handle, req.id); 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 } }; + 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 }; } - // --- 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); + const handle = session.getCurrent(); + if (!handle || !gitHost) return { ok: false, error: 'vault_locked' }; + manifest = await vault.fetchAndDecryptManifest(gitHost, handle); return { ok: true }; } - // --- Setup --- - case 'get_setup_state': { - const state = await loadSetupState(); - return { ok: true, data: state }; + return { ok: true, data: await loadSetupState() }; } case 'save_setup': { @@ -316,53 +203,32 @@ async function handleMessage(req: Request): Promise { 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 '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(req.length); + const password = w.generate_password(JSON.stringify(req.request)); 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 'get_settings': + return { ok: true, data: { settings: await loadSettings() } }; case 'update_settings': { const current = await loadSettings(); - const updated = { ...current, ...req.settings }; - await saveSettings(updated); + await saveSettings({ ...current, ...req.settings }); return { ok: true }; } - case 'get_blacklist': { - const blacklist = await loadBlacklist(); - return { ok: true, data: { blacklist } }; - } + case 'get_blacklist': + return { ok: true, data: { blacklist: await loadBlacklist() } }; case 'remove_blacklist': { const bl = await loadBlacklist(); @@ -370,72 +236,41 @@ async function handleMessage(req: Request): Promise { 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 }; + // 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}` }; } - - // --- 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}` }; } } + +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; } +}