/// Service worker entry point for the idfoto Chrome extension. /// /// Loads the WASM module, manages vault state (master key, manifest, git host), /// and routes all messages from the popup and content scripts. import type { Request, Response } from '../shared/messages'; import type { Manifest, VaultConfig, SetupState } from '../shared/types'; import type { GitHost } from './git-host'; import { createGitHost } from './git-host'; import { base64ToUint8Array } from './git-host'; import * as vault from './vault'; // --- 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; // --- WASM initialization --- // We use a dynamic import so webpack treats idfoto_wasm.js as a separate chunk. // The WASM file (idfoto_wasm_bg.wasm) is loaded by the JS glue code. type WasmModule = typeof import('idfoto-wasm'); let wasm: WasmModule | null = null; async function initWasm(): Promise { if (wasm) return wasm; // wasm-pack --target web produces an ES module with an `default` init function // that loads the .wasm file. In a Chrome MV3 service worker we import the JS // glue and call init() with the wasm URL. const mod = await import( // @ts-ignore TS2307 — resolved at runtime by the service worker, not by TS/webpack /* webpackIgnore: true */ './idfoto_wasm.js' ) as WasmModule & { default: (input?: string | URL) => Promise }; await mod.default('./idfoto_wasm_bg.wasm'); vault.setWasm(mod); wasm = mod; wasmReady = true; return mod; } // --- Storage helpers --- async function loadConfig(): Promise { const result = await chrome.storage.local.get('vaultConfig'); return (result.vaultConfig as VaultConfig) ?? null; } async function loadImageBase64(): Promise { const result = await chrome.storage.local.get('imageBase64'); return (result.imageBase64 as string) ?? null; } async function loadSetupState(): Promise { const config = await loadConfig(); const imageBase64 = await loadImageBase64(); return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null, }; } function ensureGitHost(config: VaultConfig): GitHost { if (!gitHost) { gitHost = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken); } return gitHost; } // --- Message handler --- 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 } }; 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); return { ok: true }; } case 'lock': masterKey = null; manifest = null; 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 '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 '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' }; } 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}`, ); 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}`, ); 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(); const entry = await vault.fetchAndDecryptEntry(gitHost, masterKey, req.id); if (!entry.totp_secret) return { ok: false, error: 'No TOTP secret for this entry' }; const now = Math.floor(Date.now() / 1000); const code = w.generate_totp(entry.totp_secret, BigInt(now)); const remaining = 30 - (now % 30); return { ok: true, data: { code, remaining_seconds: remaining } }; } // --- 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); return { ok: true }; } // --- Setup --- case 'get_setup_state': { const state = await loadSetupState(); return { ok: true, data: state }; } case 'save_setup': { await chrome.storage.local.set({ 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 'generate_password': { const w = await initWasm(); const password = w.generate_password(req.length); 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 }; } default: return { ok: false, error: `Unknown message type: ${(req as { type: string }).type}` }; } }