/// 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'; import * as sessionTimer from '../session-timer'; // --- 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 'register_this_device': { if (!state.gitHost) return { ok: false, error: 'vault_locked' }; const keypair = JSON.parse(state.wasm.generate_device_keypair()) as { public_key_hex: string; private_key_base64: string; }; await chrome.storage.local.set({ device_name: msg.name, device_private_key: keypair.private_key_base64, }); await devices.addDevice(state.gitHost, { name: msg.name, public_key: keypair.public_key_hex, added_at: Math.floor(Date.now() / 1000), }); 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 }; } case 'list_trashed': { if (!state.manifest) return { ok: false, error: 'vault_locked' }; const items = vault.listTrashed(state.manifest); return { ok: true, data: { items } }; } case 'restore_item': { const handle = session.getCurrent(); if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; await vault.restoreItem(state.gitHost, handle, state.manifest, msg.id); return { ok: true }; } case 'purge_item': { const handle = session.getCurrent(); if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; await vault.purgeItem(state.gitHost, msg.id, state.manifest); await vault.encryptAndWriteManifest( state.gitHost, handle, state.manifest, `manifest: purge ${state.manifest.items[msg.id]?.title ?? msg.id}`, ); return { ok: true }; } case 'purge_all_trash': { const handle = session.getCurrent(); if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; const result = await vault.purgeAllTrash(state.gitHost, handle, state.manifest); return { ok: true, data: result }; } case 'get_field_history': { const handle = session.getCurrent(); if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.id); const history = state.wasm.get_field_history(JSON.stringify(item)); return { ok: true, data: { history } }; } case 'get_session_config': return { ok: true, data: { config: sessionTimer.getConfig() } }; case 'update_session_config': { sessionTimer.setConfig(msg.config); sessionTimer.resetTimer(); await chrome.storage.local.set({ session_timeout: msg.config }); return { ok: true }; } case 'export_backup': { if (!state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; try { const blob = await vault.fetchVaultStateForBackup(state.gitHost, state.manifest); let reference_jpg: string | null = null; if (msg.includeImage) { const stored = await chrome.storage.local.get('imageBase64'); const b64 = stored.imageBase64 as string | undefined; if (!b64) return { ok: false, error: 'no reference image stored locally' }; reference_jpg = b64; } const inputJson = JSON.stringify({ salt: blob.salt_b64, params_json: blob.params_json, devices_json: blob.devices_json, manifest_enc: blob.manifest_enc_b64, settings_enc: blob.settings_enc_b64, items: blob.items.map(i => ({ id: i.id, ciphertext: i.ciphertext_b64 })), attachments: blob.attachments.map(a => ({ item_id: a.item_id, attachment_id: a.attachment_id, ciphertext: a.ciphertext_b64 })), reference_jpg, git_archive: null, // Extension never bundles git history. }); const bytes: Uint8Array = state.wasm.pack_backup_json(inputJson, msg.passphrase); return { ok: true, data: { bytes: bytes.buffer } }; } catch (e) { return { ok: false, error: (e as Error).message }; } } case 'restore_backup': { try { const bytes = new Uint8Array(msg.bytes); const outJson: string = state.wasm.unpack_backup_json(bytes, msg.passphrase); const out = JSON.parse(outJson) as { salt: string; params_json: string; devices_json: string; manifest_enc: string; settings_enc: string; items: Array<{ id: string; ciphertext: string }>; attachments: Array<{ item_id: string; attachment_id: string; ciphertext: string }>; reference_jpg: string | null; }; // Build a GitHost for the new remote. const newHost = createGitHost( msg.newRemote.hostType, msg.newRemote.hostUrl, msg.newRemote.repoPath, msg.newRemote.apiToken, ); // Refuse if the remote already has a vault. try { const meta = await vault.fetchVaultMeta(newHost); if (meta.salt && meta.paramsJson) { return { ok: false, error: 'remote already contains a relicario vault' }; } } catch { // No vault present — expected for a fresh remote. } // Write the layout via writeFileCreateOnly. Refuses to clobber. const b64 = (s: string) => Uint8Array.from(atob(s), c => c.charCodeAt(0)); await newHost.writeFileCreateOnly('.relicario/salt', b64(out.salt), 'restore: salt'); await newHost.writeFileCreateOnly('.relicario/params.json', new TextEncoder().encode(out.params_json), 'restore: params.json'); await newHost.writeFileCreateOnly('.relicario/devices.json', new TextEncoder().encode(out.devices_json), 'restore: devices.json'); await newHost.writeFileCreateOnly('manifest.enc', b64(out.manifest_enc), 'restore: manifest.enc'); await newHost.writeFileCreateOnly('settings.enc', b64(out.settings_enc), 'restore: settings.enc'); for (const it of out.items) { await newHost.writeFileCreateOnly( `items/${it.id}.enc`, b64(it.ciphertext), `restore: item ${it.id}`, ); } // Translate canonical envelope keys (/) back to the // extension's flat layout (attachments/.bin). The aid is // already content-addressed and globally unique; the item_id segment // is recorded only in the manifest's attachment_summaries. for (const a of out.attachments) { await newHost.writeFileCreateOnly( `attachments/${a.attachment_id}.bin`, b64(a.ciphertext), `restore: attachment ${a.attachment_id}`, ); } // Update local config so subsequent unlocks work. const cfg = { hostType: msg.newRemote.hostType, hostUrl: msg.newRemote.hostUrl, repoPath: msg.newRemote.repoPath, apiToken: msg.newRemote.apiToken, }; const storageUpdate: Record = { vaultConfig: cfg }; if (out.reference_jpg) { storageUpdate.imageBase64 = out.reference_jpg; } await chrome.storage.local.set(storageUpdate); // Make sure the SW's gitHost cache picks up the new config. state.gitHost = newHost; state.manifest = null; // user must unlock to populate return { ok: true, data: { summary: { itemCount: out.items.length, attachmentCount: out.attachments.length, hasImage: out.reference_jpg != null, }, }, }; } catch (e) { return { ok: false, error: (e as Error).message }; } } case 'parse_lastpass_csv': { try { const json: string = state.wasm.parse_lastpass_csv_json(new Uint8Array(msg.bytes)); const parsed = JSON.parse(json) as { items: Item[]; warnings: Array<{ row: number; title?: string; message: string }>; }; return { ok: true, data: parsed }; } catch (e) { return { ok: false, error: (e as Error).message }; } } case 'import_lastpass_commit': { const handle = session.getCurrent(); if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; if (msg.items.length === 0) return { ok: false, error: 'no items to import' }; try { const total = msg.items.length; for (let i = 0; i < msg.items.length; i++) { const item = msg.items[i]; // Items arrive with IDs already minted by the WASM bridge. We // overwrite that with a fresh extension-generated ID so the SW // remains the single ID-issuance authority for new items in the // remote — same pattern as `add_item`. const id = state.wasm.new_item_id(); const reIdItem: Item = { ...item, id }; await vault.encryptAndWriteItem( state.gitHost, handle, id, reIdItem, `import: ${reIdItem.title} (${i + 1}/${total})`, ); state.manifest.items[id] = itemToManifestEntry(reIdItem); } await vault.encryptAndWriteManifest( state.gitHost, handle, state.manifest, `manifest: import ${total} items from LastPass`, ); return { ok: true, data: { summary: { itemCount: total } } }; } catch (e) { return { ok: false, error: (e as Error).message }; } } } } // --- 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; } }