feat: add service worker with WASM init and message router

Main entry point that loads WASM via dynamic import, manages vault state
(master key, manifest, git host), and handles all message types from
popup and content scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-12 09:42:12 -04:00
parent ea9dee00e1
commit ff62970917

View File

@@ -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<WasmModule> {
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<void> };
await mod.default('./idfoto_wasm_bg.wasm');
vault.setWasm(mod);
wasm = mod;
wasmReady = true;
return mod;
}
// --- Storage helpers ---
async function loadConfig(): Promise<VaultConfig | null> {
const result = await chrome.storage.local.get('vaultConfig');
return (result.vaultConfig as VaultConfig) ?? null;
}
async function loadImageBase64(): Promise<string | null> {
const result = await chrome.storage.local.get('imageBase64');
return (result.imageBase64 as string) ?? null;
}
async function loadSetupState(): Promise<SetupState> {
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<Response> {
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}` };
}
}