Files
relicario/extension/src/service-worker/router/popup-only.ts
adlee-was-taken 0003c3e658 feat(ext/sw): device management — devices.ts + router handlers
Adds readDevices, addDevice, revokeDevice helpers that read/write
.relicario/devices.json. Router handlers: list_devices, add_device,
revoke_device.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-26 15:53:08 -04:00

424 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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, TotpConfig, AttachmentRef } 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';
import * as devices from '../devices';
// --- 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);
// Resolve the TotpConfig from whichever carrier the item type uses.
// Login items hold TOTP as an optional subfield on LoginCore; the standalone
// Totp item type carries it as TotpCore.config (required).
let cfg: TotpConfig | null = null;
if (item.core.type === 'login' && item.core.totp) {
cfg = item.core.totp;
} else if (item.core.type === 'totp') {
cfg = item.core.config;
}
if (!cfg) return { ok: false, error: 'no_totp' };
const now = Math.floor(Date.now() / 1000);
const code = state.wasm.totp_compute(JSON.stringify(cfg), 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 'generate_passphrase': {
const passphrase = state.wasm.generate_passphrase(JSON.stringify(msg.request));
return { ok: true, data: { passphrase } };
}
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_vault_settings': {
const handle = session.getCurrent();
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
return { ok: true, data: { settings } };
}
case 'update_vault_settings': {
const handle = session.getCurrent();
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
await vault.encryptAndWriteSettings(
state.gitHost, handle, msg.settings,
'settings: update vault-level config',
);
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 };
}
case 'upload_attachment': {
try {
const handle = session.getCurrent();
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
// Look up the per-attachment size cap from vault settings (if any).
let maxBytes = BigInt(Number.MAX_SAFE_INTEGER);
try {
const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
const cap = settings.attachment_caps?.per_attachment_max_bytes;
if (cap !== undefined) maxBytes = BigInt(cap);
} catch {
// If settings are unavailable, proceed uncapped.
}
const plaintext = new Uint8Array(msg.bytes);
// Cap enforcement layering:
// - per_attachment_max_bytes: enforced here via WASM (defense-in-depth)
// - per_item_max_count, per_vault_*_cap_bytes: enforced client-side in
// the popup (Task 7's attachments-disclosure component does this).
const encrypted = state.wasm.attachment_encrypt(handle, plaintext, maxBytes);
// encrypted: EncryptedAttachment — exposes .aid (string) and .bytes (Uint8Array)
const aid: string = encrypted.aid;
const encBytes: Uint8Array = encrypted.bytes;
// Duplicate-id check: same content hash → same id → no-op re-upload.
let existingRef: AttachmentRef | undefined;
try {
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.itemId);
existingRef = item.attachments.find((a) => a.id === aid);
} catch {
// Item not yet persisted or not found — will fail at addAttachmentToItem below.
}
if (existingRef) {
return { ok: true, data: { attachment: existingRef } };
}
// Store the encrypted blob.
const blobPath = `attachments/${aid}.bin`;
await state.gitHost.putBlob(blobPath, encBytes, `attach: ${msg.filename}`);
// Build the AttachmentRef and append it to the item + manifest.
const ref: AttachmentRef = {
id: aid,
filename: msg.filename,
mime_type: msg.mimeType,
size: plaintext.length,
created: Math.floor(Date.now() / 1000),
};
await vault.addAttachmentToItem(state.gitHost, handle, msg.itemId, ref);
return { ok: true, data: { attachment: ref } };
} catch (e) {
console.error('[relicario] upload_attachment failed', e);
return { ok: false, error: 'upload_failed' };
}
}
case 'download_attachment': {
try {
const handle = session.getCurrent();
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
// Fetch the item to recover the filename + mime type from the AttachmentRef.
const item = await vault.fetchAndDecryptItem(state.gitHost, handle, msg.itemId);
const ref = item.attachments.find((a) => a.id === msg.attachmentId);
if (!ref) return { ok: false, error: 'attachment_not_found' };
const blobPath = `attachments/${ref.id}.bin`;
const encBytes = await state.gitHost.getBlob(blobPath);
const decrypted = state.wasm.attachment_decrypt(handle, encBytes);
return {
ok: true,
data: {
bytes: decrypted.buffer.slice(
decrypted.byteOffset,
decrypted.byteOffset + decrypted.byteLength,
) as ArrayBuffer,
filename: ref.filename,
mimeType: ref.mime_type,
},
};
} catch (e) {
console.error('[relicario] download_attachment failed', e);
return { ok: false, error: 'download_failed' };
}
}
case 'list_devices': {
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
const list = await devices.readDevices(state.gitHost);
return { ok: true, data: { devices: list } };
}
case 'add_device': {
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
const device = {
name: msg.name,
public_key: msg.public_key,
added_at: Math.floor(Date.now() / 1000),
};
await devices.addDevice(state.gitHost, device);
return { ok: true };
}
case 'revoke_device': {
if (!state.gitHost) return { ok: false, error: 'vault_locked' };
await devices.revokeDevice(state.gitHost, msg.name);
return { ok: true };
}
// Handlers for these cases are added in Tasks 45.
case 'list_trashed':
case 'restore_item':
case 'purge_item':
case 'purge_all_trash':
case 'get_field_history':
return { ok: false, error: 'not_implemented' };
}
}
// --- 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' };
// Pass the hostname the SW validated. The content script re-verifies
// against location.href before filling — if the tab navigated between
// our chrome.tabs.get check above and the sendMessage delivery below,
// fill.ts rejects with 'origin_changed'.
await chrome.tabs.sendMessage(msg.capturedTabId, {
type: 'fill_credentials',
username: item.core.username ?? '',
password: item.core.password ?? '',
expectedHost: currentHost,
});
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; }
}