diff --git a/extension/src/service-worker/index.ts b/extension/src/service-worker/index.ts new file mode 100644 index 0000000..0d7e43d --- /dev/null +++ b/extension/src/service-worker/index.ts @@ -0,0 +1,303 @@ +/// 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}` }; + } +}