From 533bfd5bea13f38fd5d90fbbb1c08a77293a10fa Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 20:10:34 -0400 Subject: [PATCH] feat(ext/sw): router/popup-only handlers --- .../src/service-worker/router/popup-only.ts | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 extension/src/service-worker/router/popup-only.ts diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts new file mode 100644 index 0000000..8480913 --- /dev/null +++ b/extension/src/service-worker/router/popup-only.ts @@ -0,0 +1,268 @@ +/// Popup-callable message handlers. +/// +/// Every export here assumes the router has already verified sender identity +/// via sender.url === popup.html (or setup.html for save_setup). + +import type { PopupMessage, Response } from '../../shared/messages'; +import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings } from '../../shared/types'; +import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types'; +import type { GitHost } from '../git-host'; +import { createGitHost, base64ToUint8Array } from '../git-host'; +import * as vault from '../vault'; +import * as session from '../session'; + +// --- Shared ambient state owned by the SW module --- +// +// The router keeps these on a single `state` object and injects it into the +// handler so testing can mock them without reaching for globals. + +export interface PopupState { + manifest: Manifest | null; + gitHost: GitHost | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + wasm: any; +} + +export async function handle( + msg: PopupMessage, + state: PopupState, + sender: chrome.runtime.MessageSender, +): Promise { + void sender; // unused in most branches; retained for symmetry with content-callable + switch (msg.type) { + case 'is_unlocked': + return { ok: true, data: { unlocked: session.getCurrent() !== null } }; + + case 'unlock': { + const w = state.wasm; + const config = await loadConfig(); + if (!config) return { ok: false, error: 'Extension not configured. Run setup first.' }; + const imageB64 = await loadImageBase64(); + if (!imageB64) return { ok: false, error: 'Reference image not set. Run setup first.' }; + + const imageBytes = base64ToUint8Array(imageB64); + if (!state.gitHost) state.gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken); + const meta = await vault.fetchVaultMeta(state.gitHost); + + const handle = w.unlock(msg.passphrase, imageBytes, meta.salt, meta.paramsJson); + session.setCurrent(handle); + (msg as { passphrase: string }).passphrase = ''; + + state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle); + return { ok: true }; + } + + case 'lock': + session.clearCurrent(); + state.manifest = null; + return { ok: true }; + + case 'list_items': { + if (!state.manifest) return { ok: false, error: 'vault_locked' }; + return { ok: true, data: { items: vault.listItems(state.manifest, msg.group) } }; + } + + case 'get_item': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; + const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id); + return { ok: true, data: { item } }; + } + + case 'add_item': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; + const id = state.wasm.new_item_id(); + const item: Item = { ...msg.item, id }; + await vault.encryptAndWriteItem(state.gitHost, handle, id, item, `add: ${item.title}`); + state.manifest.items[id] = itemToManifestEntry(item); + await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: add ${item.title}`); + return { ok: true, data: { id } }; + } + + case 'update_item': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; + await vault.encryptAndWriteItem(state.gitHost, handle, msg.id, msg.item, `update: ${msg.item.title}`); + state.manifest.items[msg.id] = itemToManifestEntry(msg.item); + await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: update ${msg.item.title}`); + return { ok: true }; + } + + case 'delete_item': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; + const entry = state.manifest.items[msg.id]; + if (!entry) return { ok: false, error: 'item_not_found' }; + const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id); + const now = Math.floor(Date.now() / 1000); + const updated: Item = { ...item, trashed_at: now, modified: now }; + await vault.encryptAndWriteItem(state.gitHost, handle, msg.id, updated, `trash: ${entry.title}`); + state.manifest.items[msg.id] = { ...entry, trashed_at: now, modified: now }; + await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: trash ${entry.title}`); + return { ok: true }; + } + + case 'get_totp': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; + const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id); + if (item.core.type !== 'login' || !item.core.totp) { + return { ok: false, error: 'no_totp' }; + } + const now = Math.floor(Date.now() / 1000); + const code = state.wasm.totp_compute(JSON.stringify(item.core.totp), BigInt(now)); + return { ok: true, data: { code: code.code, expires_at: code.expires_at } }; + } + + case 'sync': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; + state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle); + return { ok: true }; + } + + case 'get_setup_state': + return { ok: true, data: await loadSetupState() }; + + case 'save_setup': { + await chrome.storage.local.set({ + vaultConfig: msg.config, + imageBase64: msg.imageBase64, + }); + state.gitHost = null; + return { ok: true }; + } + + case 'rate_passphrase': + return { ok: true, data: state.wasm.rate_passphrase(msg.passphrase) }; + + case 'generate_password': { + const password = state.wasm.generate_password(JSON.stringify(msg.request)); + return { ok: true, data: { password } }; + } + + case 'fill_credentials': + return handleFillCredentials(msg, state); + + case 'ack_autofill_origin': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; + const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle); + const acks = { ...(settings.autofill_origin_acks ?? {}), [msg.hostname]: Math.floor(Date.now() / 1000) }; + const updated = { ...settings, autofill_origin_acks: acks }; + await vault.encryptAndWriteSettings(state.gitHost, handle, updated, `settings: ack origin ${msg.hostname}`); + return { ok: true }; + } + + case 'get_settings': + return { ok: true, data: { settings: await loadDeviceSettings() } }; + + case 'update_settings': { + const current = await loadDeviceSettings(); + await saveDeviceSettings({ ...current, ...msg.settings }); + return { ok: true }; + } + + case 'get_blacklist': + return { ok: true, data: { blacklist: await loadBlacklist() } }; + + case 'remove_blacklist': { + const bl = await loadBlacklist(); + await saveBlacklist(bl.filter((h) => h !== msg.hostname)); + return { ok: true }; + } + } +} + +// --- fill_credentials with captured-tab verification (audit M5) --- + +async function handleFillCredentials( + msg: Extract, + state: PopupState, +): Promise { + const handle = session.getCurrent(); + if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; + + let tab: chrome.tabs.Tab; + try { tab = await chrome.tabs.get(msg.capturedTabId); } + catch { return { ok: false, error: 'captured_tab_gone' }; } + + const currentHost = safeHostname(tab.url ?? ''); + const capturedHost = safeHostname(msg.capturedUrl); + if (!currentHost || !capturedHost || currentHost !== capturedHost) { + return { ok: false, error: 'tab_navigated' }; + } + + const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id); + if (item.core.type !== 'login') return { ok: false, error: 'not_a_login' }; + const itemHost = safeHostname(item.core.url ?? ''); + if (!itemHost || itemHost !== currentHost) return { ok: false, error: 'origin_mismatch' }; + + await chrome.tabs.sendMessage(msg.capturedTabId, { + type: 'fill_credentials', + username: item.core.username ?? '', + password: item.core.password ?? '', + }); + return { ok: true }; +} + +// --- chrome.storage.local helpers (module-scoped so all handlers share) --- + +async function loadConfig(): Promise { + const r = await chrome.storage.local.get('vaultConfig'); + return (r.vaultConfig as VaultConfig) ?? null; +} + +async function loadImageBase64(): Promise { + const r = await chrome.storage.local.get('imageBase64'); + return (r.imageBase64 as string) ?? null; +} + +async function loadSetupState(): Promise { + const config = await loadConfig(); + const imageBase64 = await loadImageBase64(); + return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null }; +} + +async function loadDeviceSettings(): Promise { + const r = await chrome.storage.local.get('relicarioSettings'); + return (r.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS }; +} + +async function saveDeviceSettings(s: DeviceSettings): Promise { + await chrome.storage.local.set({ relicarioSettings: s }); +} + +async function loadBlacklist(): Promise { + const r = await chrome.storage.local.get('captureBlacklist'); + return (r.captureBlacklist as string[]) ?? []; +} + +async function saveBlacklist(list: string[]): Promise { + await chrome.storage.local.set({ captureBlacklist: list }); +} + +// --- Manifest entry derivation (duplicated from index; kept here to keep handler self-contained) --- + +function itemToManifestEntry(item: Item) { + return { + id: item.id, + type: item.type, + title: item.title, + tags: item.tags, + favorite: item.favorite, + group: item.group, + icon_hint: (item.core.type === 'login' && item.core.url) + ? safeHostname(item.core.url) : undefined, + modified: item.modified, + trashed_at: item.trashed_at, + attachment_summaries: item.attachments.map((a) => ({ + id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size, + })), + }; +} + +function safeHostname(url: string): string | undefined { + try { return new URL(url).hostname; } catch { return undefined; } +}