feat(ext/sw): router/popup-only handlers

This commit is contained in:
adlee-was-taken
2026-04-20 20:10:34 -04:00
parent 2fd6daad8e
commit 533bfd5bea

View File

@@ -0,0 +1,268 @@
/// 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 } 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';
// --- 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<Response> {
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);
if (item.core.type !== 'login' || !item.core.totp) {
return { ok: false, error: 'no_totp' };
}
const now = Math.floor(Date.now() / 1000);
const code = state.wasm.totp_compute(JSON.stringify(item.core.totp), 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 '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_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 };
}
}
}
// --- fill_credentials with captured-tab verification (audit M5) ---
async function handleFillCredentials(
msg: Extract<PopupMessage, { type: 'fill_credentials' }>,
state: PopupState,
): Promise<Response> {
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' };
await chrome.tabs.sendMessage(msg.capturedTabId, {
type: 'fill_credentials',
username: item.core.username ?? '',
password: item.core.password ?? '',
});
return { ok: true };
}
// --- chrome.storage.local helpers (module-scoped so all handlers share) ---
async function loadConfig(): Promise<VaultConfig | null> {
const r = await chrome.storage.local.get('vaultConfig');
return (r.vaultConfig as VaultConfig) ?? null;
}
async function loadImageBase64(): Promise<string | null> {
const r = await chrome.storage.local.get('imageBase64');
return (r.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 };
}
async function loadDeviceSettings(): Promise<DeviceSettings> {
const r = await chrome.storage.local.get('relicarioSettings');
return (r.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS };
}
async function saveDeviceSettings(s: DeviceSettings): Promise<void> {
await chrome.storage.local.set({ relicarioSettings: s });
}
async function loadBlacklist(): Promise<string[]> {
const r = await chrome.storage.local.get('captureBlacklist');
return (r.captureBlacklist as string[]) ?? [];
}
async function saveBlacklist(list: string[]): Promise<void> {
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; }
}