/// 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, TotpConfig, AttachmentRef } 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'; import * as devices from '../devices'; // --- 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); // Resolve the TotpConfig from whichever carrier the item type uses. // Login items hold TOTP as an optional subfield on LoginCore; the standalone // Totp item type carries it as TotpCore.config (required). let cfg: TotpConfig | null = null; if (item.core.type === 'login' && item.core.totp) { cfg = item.core.totp; } else if (item.core.type === 'totp') { cfg = item.core.config; } if (!cfg) return { ok: false, error: 'no_totp' }; const now = Math.floor(Date.now() / 1000); const code = state.wasm.totp_compute(JSON.stringify(cfg), 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 'generate_passphrase': { const passphrase = state.wasm.generate_passphrase(JSON.stringify(msg.request)); return { ok: true, data: { passphrase } }; } 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_vault_settings': { const handle = session.getCurrent(); if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle); return { ok: true, data: { settings } }; } case 'update_vault_settings': { const handle = session.getCurrent(); if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; await vault.encryptAndWriteSettings( state.gitHost, handle, msg.settings, 'settings: update vault-level config', ); 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 }; } case 'upload_attachment': { try { const handle = session.getCurrent(); if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; // Look up the per-attachment size cap from vault settings (if any). let maxBytes = BigInt(Number.MAX_SAFE_INTEGER); try { const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle); const cap = settings.attachment_caps?.per_attachment_max_bytes; if (cap !== undefined) maxBytes = BigInt(cap); } catch { // If settings are unavailable, proceed uncapped. } const plaintext = new Uint8Array(msg.bytes); // Cap enforcement layering: // - per_attachment_max_bytes: enforced here via WASM (defense-in-depth) // - per_item_max_count, per_vault_*_cap_bytes: enforced client-side in // the popup (Task 7's attachments-disclosure component does this). const encrypted = state.wasm.attachment_encrypt(handle, plaintext, maxBytes); // encrypted: EncryptedAttachment — exposes .aid (string) and .bytes (Uint8Array) const aid: string = encrypted.aid; const encBytes: Uint8Array = encrypted.bytes; // Duplicate-id check: same content hash → same id → no-op re-upload. let existingRef: AttachmentRef | undefined; try { const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.itemId); existingRef = item.attachments.find((a) => a.id === aid); } catch { // Item not yet persisted or not found — will fail at addAttachmentToItem below. } if (existingRef) { return { ok: true, data: { attachment: existingRef } }; } // Store the encrypted blob. const blobPath = `attachments/${aid}.bin`; await state.gitHost.putBlob(blobPath, encBytes, `attach: ${msg.filename}`); // Build the AttachmentRef and append it to the item + manifest. const ref: AttachmentRef = { id: aid, filename: msg.filename, mime_type: msg.mimeType, size: plaintext.length, created: Math.floor(Date.now() / 1000), }; await vault.addAttachmentToItem(state.gitHost, handle, msg.itemId, ref); return { ok: true, data: { attachment: ref } }; } catch (e) { console.error('[relicario] upload_attachment failed', e); return { ok: false, error: 'upload_failed' }; } } case 'download_attachment': { try { const handle = session.getCurrent(); if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; // Fetch the item to recover the filename + mime type from the AttachmentRef. const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.itemId); const ref = item.attachments.find((a) => a.id === msg.attachmentId); if (!ref) return { ok: false, error: 'attachment_not_found' }; const blobPath = `attachments/${ref.id}.bin`; const encBytes = await state.gitHost.getBlob(blobPath); const decrypted = state.wasm.attachment_decrypt(handle, encBytes); return { ok: true, data: { bytes: decrypted.buffer.slice( decrypted.byteOffset, decrypted.byteOffset + decrypted.byteLength, ) as ArrayBuffer, filename: ref.filename, mimeType: ref.mime_type, }, }; } catch (e) { console.error('[relicario] download_attachment failed', e); return { ok: false, error: 'download_failed' }; } } case 'list_devices': { if (!state.gitHost) return { ok: false, error: 'vault_locked' }; const list = await devices.readDevices(state.gitHost); return { ok: true, data: { devices: list } }; } case 'add_device': { if (!state.gitHost) return { ok: false, error: 'vault_locked' }; const device = { name: msg.name, public_key: msg.public_key, added_at: Math.floor(Date.now() / 1000), }; await devices.addDevice(state.gitHost, device); return { ok: true }; } case 'revoke_device': { if (!state.gitHost) return { ok: false, error: 'vault_locked' }; await devices.revokeDevice(state.gitHost, msg.name); return { ok: true }; } // Handlers for these cases are added in Tasks 4–5. case 'list_trashed': case 'restore_item': case 'purge_item': case 'purge_all_trash': case 'get_field_history': return { ok: false, error: 'not_implemented' }; } } // --- 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; } }