P1.9: loadDeviceSettings / loadBlacklist / saveBlacklist / saveDeviceSettings + itemToManifestEntry were duplicated across popup-only.ts and content-callable.ts. Lifts the four storage helpers into service-worker/ storage.ts and itemToManifestEntry into service-worker/vault.ts. Both router files now import from one home each. Adds storage.test.ts covering round-trips and defaults. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
172 lines
6.8 KiB
TypeScript
172 lines
6.8 KiB
TypeScript
/// Content-script-callable message handlers.
|
|
///
|
|
/// Origin is always derived from sender.tab.url — never trust fields on msg.
|
|
/// Router has already verified sender.frameId === 0 (top-frame only) and
|
|
/// sender.tab !== undefined.
|
|
|
|
import type { ContentMessage, Response } from '../../shared/messages';
|
|
import type { Item, Manifest } from '../../shared/types';
|
|
import type { GitHost } from '../git-host';
|
|
import * as vault from '../vault';
|
|
import { itemToManifestEntry } from '../vault';
|
|
import * as session from '../session';
|
|
import { loadDeviceSettings, loadBlacklist, saveBlacklist } from '../storage';
|
|
|
|
export interface ContentState {
|
|
manifest: Manifest | null;
|
|
gitHost: GitHost | null;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
wasm: any;
|
|
}
|
|
|
|
export async function handle(
|
|
msg: ContentMessage,
|
|
state: ContentState,
|
|
sender: chrome.runtime.MessageSender,
|
|
): Promise<Response> {
|
|
const senderHost = safeHostname(sender.tab?.url ?? '');
|
|
if (!senderHost) return { ok: false, error: 'invalid_sender_url' };
|
|
|
|
switch (msg.type) {
|
|
case 'get_autofill_candidates': {
|
|
if (!state.manifest) return { ok: false, error: 'vault_locked' };
|
|
return {
|
|
ok: true,
|
|
data: { candidates: vault.findByHostname(state.manifest, senderHost) },
|
|
};
|
|
}
|
|
|
|
case 'get_credentials': {
|
|
const handle = session.getCurrent();
|
|
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
|
|
|
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 !== senderHost) return { ok: false, error: 'origin_mismatch' };
|
|
|
|
// TOFU origin-ack check (VaultSettings.autofill_origin_acks):
|
|
const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
|
|
const acks = settings.autofill_origin_acks ?? {};
|
|
if (!(senderHost in acks)) {
|
|
return { ok: true, data: { requires_ack: true, hostname: senderHost } };
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
username: item.core.username ?? '',
|
|
password: item.core.password ?? '',
|
|
},
|
|
};
|
|
}
|
|
|
|
case 'check_credential': {
|
|
const handle = session.getCurrent();
|
|
if (!handle || !state.gitHost || !state.manifest) {
|
|
return { ok: true, data: { action: 'skip' } };
|
|
}
|
|
|
|
// Settings-gating: capture off or site blacklisted → skip.
|
|
const captureSettings = await loadDeviceSettings();
|
|
if (!captureSettings.captureEnabled) return { ok: true, data: { action: 'skip' } };
|
|
|
|
const blacklist = await loadBlacklist();
|
|
if (blacklist.includes(senderHost)) return { ok: true, data: { action: 'skip' } };
|
|
|
|
const candidates = vault.findByHostname(state.manifest, senderHost);
|
|
if (candidates.length === 0) return { ok: true, data: { action: 'save' } };
|
|
|
|
for (const [itemId, entry] of candidates) {
|
|
if (entry.type !== 'login') continue;
|
|
const full = await vault.fetchAndDecryptItem(state.gitHost, handle, itemId);
|
|
if (full.core.type !== 'login') continue;
|
|
if (full.core.username === msg.username) {
|
|
if (full.core.password === msg.password) return { ok: true, data: { action: 'skip' } };
|
|
return { ok: true, data: { action: 'update', entryId: itemId, entryName: entry.title } };
|
|
}
|
|
}
|
|
return { ok: true, data: { action: 'save' } };
|
|
}
|
|
|
|
case 'blacklist_site': {
|
|
const bl = await loadBlacklist();
|
|
if (!bl.includes(senderHost)) {
|
|
bl.push(senderHost);
|
|
await saveBlacklist(bl);
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
case 'capture_save_login': {
|
|
const handle = session.getCurrent();
|
|
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };
|
|
|
|
// Look for an existing login for this origin + username. Origin is
|
|
// always senderHost (derived from sender.tab.url by the router) — the
|
|
// content script cannot influence which host we bind to.
|
|
const candidates = vault.findByHostname(state.manifest, senderHost);
|
|
for (const [id, entry] of candidates) {
|
|
if (entry.type !== 'login') continue;
|
|
const full = await vault.fetchAndDecryptItem(state.gitHost, handle, id);
|
|
if (full.core.type !== 'login') continue;
|
|
if (full.core.username === msg.username) {
|
|
// Defense in depth: verify the existing item's own URL hostname
|
|
// matches senderHost. If it doesn't (e.g. manifest icon_hint
|
|
// drifted from core.url), refuse to mutate — updating here would
|
|
// silently bind a password to the wrong origin.
|
|
const existingHost = safeHostname(full.core.url ?? '');
|
|
if (existingHost !== senderHost) return { ok: false, error: 'origin_mismatch' };
|
|
|
|
// Update only the password field + modified timestamp.
|
|
const updated: Item = {
|
|
...full,
|
|
modified: Math.floor(Date.now() / 1000),
|
|
core: { ...full.core, password: msg.password },
|
|
};
|
|
await vault.encryptAndWriteItem(state.gitHost, handle, id, updated, `capture: update ${existingHost}`);
|
|
state.manifest.items[id] = itemToManifestEntry(updated);
|
|
await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: update ${existingHost}`);
|
|
return { ok: true, data: { action: 'updated', id } };
|
|
}
|
|
}
|
|
|
|
// No match → create a new Login item bound to senderHost. Title
|
|
// defaults to the hostname; url is the sender's full origin when we
|
|
// have it, otherwise derived from senderHost.
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const newId = state.wasm.new_item_id();
|
|
const senderOrigin = (() => {
|
|
try { return sender.tab?.url ? new URL(sender.tab.url).origin : `https://${senderHost}`; }
|
|
catch { return `https://${senderHost}`; }
|
|
})();
|
|
const item: Item = {
|
|
id: newId,
|
|
title: senderHost,
|
|
type: 'login',
|
|
tags: [],
|
|
favorite: false,
|
|
created: now,
|
|
modified: now,
|
|
core: {
|
|
type: 'login',
|
|
username: msg.username,
|
|
password: msg.password,
|
|
url: senderOrigin,
|
|
},
|
|
sections: [],
|
|
attachments: [],
|
|
field_history: {},
|
|
};
|
|
await vault.encryptAndWriteItem(state.gitHost, handle, newId, item, `capture: add ${senderHost}`);
|
|
state.manifest.items[newId] = itemToManifestEntry(item);
|
|
await vault.encryptAndWriteManifest(state.gitHost, handle, state.manifest, `manifest: add ${senderHost}`);
|
|
return { ok: true, data: { action: 'added', id: newId } };
|
|
}
|
|
}
|
|
}
|
|
|
|
function safeHostname(url: string): string | undefined {
|
|
try { return new URL(url).hostname; } catch { return undefined; }
|
|
}
|