/// 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' }; // 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 { 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; } }