- Fix .idfoto/ prefix for salt and params.json in vault.ts - Cache TOTP secrets by entry ID to avoid re-fetching every second - Fix keyboard navigation to use filtered entries, not unfiltered - Add window.close() on Escape from entry list Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
314 lines
9.5 KiB
TypeScript
314 lines
9.5 KiB
TypeScript
/// 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;
|
|
// Cache TOTP secrets by entry ID to avoid re-fetching the entry every second
|
|
const totpSecretCache: Map<string, string> = new Map();
|
|
|
|
// --- 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;
|
|
totpSecretCache.clear();
|
|
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();
|
|
|
|
// Use cached TOTP secret to avoid re-fetching the entry every second
|
|
let totpSecret = totpSecretCache.get(req.id);
|
|
if (!totpSecret) {
|
|
const entry = await vault.fetchAndDecryptEntry(gitHost, masterKey, req.id);
|
|
if (!entry.totp_secret) return { ok: false, error: 'No TOTP secret for this entry' };
|
|
totpSecret = entry.totp_secret;
|
|
totpSecretCache.set(req.id, totpSecret);
|
|
}
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const code = w.generate_totp(totpSecret, 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}` };
|
|
}
|
|
}
|