diff --git a/extension/src/content/capture.ts b/extension/src/content/capture.ts index 4d26078..3a3addc 100644 --- a/extension/src/content/capture.ts +++ b/extension/src/content/capture.ts @@ -9,7 +9,7 @@ /// are applied via textContent, never innerHTML. import type { Request, Response } from '../shared/messages'; -import type { DeviceSettings, Item, LoginCore } from '../shared/types'; +import type { DeviceSettings } from '../shared/types'; import { createShadowHost, type ShadowSurface } from './shadow'; // --- State --- @@ -93,14 +93,15 @@ async function onFormSubmit(pwField: HTMLInputElement): Promise { const data = resp.data as { action: string; entryId?: string; entryName?: string }; if (data.action === 'skip') return; - // Fetch settings for prompt style - const settingsResp = await sendMessage({ type: 'get_settings' }); - const defaults: DeviceSettings = { captureEnabled: true, captureStyle: 'bar' }; - const settings: DeviceSettings = settingsResp.ok - ? ((settingsResp.data as { settings: DeviceSettings }).settings ?? defaults) - : defaults; + // Fetch settings for prompt style. Content scripts have direct + // chrome.storage.local access (manifest grants "storage"), so we don't + // need to round-trip through the SW for this — which also avoids the + // router's content→popup-only rejection for 'get_settings'. + const stored = await chrome.storage.local.get('relicarioSettings'); + const settings: DeviceSettings = (stored.relicarioSettings as DeviceSettings) + ?? { captureEnabled: true, captureStyle: 'bar' }; - showPrompt(settings.captureStyle, data.action, username, password, data.entryId); + showPrompt(settings.captureStyle, data.action, username, password); } // --- Prompt UI --- @@ -117,14 +118,12 @@ function showPrompt( action: string, username: string, password: string, - entryId?: string, ): void { removeExistingPrompt(); const hostname = (() => { try { return new URL(window.location.href).hostname; } catch { return window.location.href; } })(); - const url = window.location.href; const surface = createShadowHost(); currentPrompt = surface; @@ -236,52 +235,15 @@ function showPrompt( if (autoDismissTimer) clearTimeout(autoDismissTimer); }; - // Save button + // Save button — single content-callable message; the SW figures out + // whether this is an add or an update (and enforces origin-binding). saveBtn.addEventListener('click', async () => { clearAutoDismiss(); - - const now = Math.floor(Date.now() / 1000); - const loginCore: LoginCore & { type: 'login' } = { - type: 'login', - username, - password, - url, - }; - - if (action === 'update' && entryId) { - // For update we need a valid Item — fetch the existing one, merge the - // updated login fields, and write it back. The router's update_item - // expects a full Item. We fall back to a minimal item if fetch fails. - const getResp = await sendMessage({ type: 'get_item', id: entryId }); - if (getResp.ok) { - const existing = (getResp.data as { item: Item }).item; - const updated: Item = { - ...existing, - title: existing.title || hostname, - modified: now, - core: { ...existing.core, ...loginCore }, - }; - await sendMessage({ type: 'update_item', id: entryId, item: updated }); - } - } else { - // New item — SW will assign the id; we just pass an empty string. - const item: Item = { - id: '', - title: hostname, - type: 'login', - tags: [], - favorite: false, - created: now, - modified: now, - core: loginCore, - sections: [], - attachments: [], - field_history: {}, - }; - await sendMessage({ type: 'add_item', item }); + const resp = await sendMessage({ type: 'capture_save_login', username, password }); + if (!resp.ok) { + msgSpan.textContent = `✗ ${resp.error}`; + return; } - - // Show confirmation msgSpan.textContent = '✓ Saved'; saveBtn.style.display = 'none'; neverBtn.style.display = 'none'; diff --git a/extension/src/service-worker/router/__tests__/router.test.ts b/extension/src/service-worker/router/__tests__/router.test.ts index bca9835..388b2da 100644 --- a/extension/src/service-worker/router/__tests__/router.test.ts +++ b/extension/src/service-worker/router/__tests__/router.test.ts @@ -14,6 +14,8 @@ vi.mock('../../vault', async (importOriginal) => { fetchAndDecryptItem: vi.fn(), fetchAndDecryptSettings: vi.fn(), encryptAndWriteSettings: vi.fn(), + encryptAndWriteItem: vi.fn(), + encryptAndWriteManifest: vi.fn(), }; }); @@ -340,3 +342,151 @@ describe('isContent sender.id guard', () => { expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); }); }); + +// --- capture_save_login (content-callable, origin-bound) --- + +describe('capture_save_login', () => { + const EXISTING_ID = 'dddddddddddddddd'; + + function loginItem(url: string, username: string, password: string): Item { + return { + id: EXISTING_ID, + title: 'Example', + type: 'login', + tags: [], + favorite: false, + created: 0, + modified: 0, + core: { type: 'login', username, password, url }, + sections: [], + attachments: [], + field_history: {}, + }; + } + + function primeUnlocked(state: RouterState): void { + vi.mocked(session.getCurrent).mockReturnValue({ free: () => {} } as never); + state.gitHost = {} as never; + } + + beforeEach(() => { + vi.mocked(session.getCurrent).mockReset(); + vi.mocked(vault.fetchAndDecryptItem).mockReset(); + vi.mocked(vault.encryptAndWriteItem).mockReset(); + vi.mocked(vault.encryptAndWriteManifest).mockReset(); + vi.mocked(vault.encryptAndWriteItem).mockResolvedValue(undefined); + vi.mocked(vault.encryptAndWriteManifest).mockResolvedValue(undefined); + }); + + it('accepts capture_save_login from top-frame content', async () => { + const state = makeState(); + primeUnlocked(state); + const res = await route( + { type: 'capture_save_login', username: 'alice', password: 'hunter2' }, + state, + makeContentSender('https://example.com/login'), + ); + expect(res.ok).toBe(true); + }); + + it('rejects capture_save_login from popup', async () => { + const state = makeState(); + primeUnlocked(state); + const res = await route( + { type: 'capture_save_login', username: 'alice', password: 'hunter2' }, + state, + makePopupSender(), + ); + expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); + + it('update path: existing (host, username) match rotates the password', async () => { + const state = makeState(); + primeUnlocked(state); + // Seed manifest with a login for example.com. + state.manifest = { + schema_version: 2, + items: { + [EXISTING_ID]: { + id: EXISTING_ID, type: 'login', title: 'Example', + tags: [], favorite: false, icon_hint: 'example.com', + modified: 0, attachment_summaries: [], + }, + }, + }; + vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue( + loginItem('https://example.com/', 'alice', 'oldpass'), + ); + + const res = await route( + { type: 'capture_save_login', username: 'alice', password: 'newpass' }, + state, + makeContentSender('https://example.com/login'), + ); + expect(res).toMatchObject({ ok: true, data: { action: 'updated', id: EXISTING_ID } }); + // Verify write was invoked with a core whose password is the new one. + expect(vault.encryptAndWriteItem).toHaveBeenCalledTimes(1); + const writtenItem = vi.mocked(vault.encryptAndWriteItem).mock.calls[0][3]; + expect(writtenItem.id).toBe(EXISTING_ID); + if (writtenItem.core.type !== 'login') throw new Error('expected login core'); + expect(writtenItem.core.password).toBe('newpass'); + expect(writtenItem.core.username).toBe('alice'); + }); + + it('add path: no match creates a new item bound to senderHost', async () => { + const state = makeState(); + primeUnlocked(state); + // Empty manifest — no candidates. + state.manifest = { schema_version: 2, items: {} }; + + const res = await route( + { type: 'capture_save_login', username: 'bob', password: 's3cret' }, + state, + makeContentSender('https://example.com/signup'), + ); + expect(res.ok).toBe(true); + if (res.ok) { + const data = res.data as { action: string; id: string }; + expect(data.action).toBe('added'); + expect(data.id).toBe('fakeitemid0000ab'); // from stub new_item_id() + } + expect(vault.encryptAndWriteItem).toHaveBeenCalledTimes(1); + const newItem = vi.mocked(vault.encryptAndWriteItem).mock.calls[0][3]; + expect(newItem.title).toBe('example.com'); + if (newItem.core.type !== 'login') throw new Error('expected login core'); + expect(newItem.core.url).toBe('https://example.com'); + expect(newItem.core.username).toBe('bob'); + expect(newItem.core.password).toBe('s3cret'); + // Manifest entry should have been added too. + expect(state.manifest!.items['fakeitemid0000ab']).toBeDefined(); + }); + + it('origin_mismatch when existing item for same username has a different host', async () => { + const state = makeState(); + primeUnlocked(state); + // Manifest says there's a match for example.com (icon_hint), but the + // underlying item actually belongs to github.com — defense-in-depth + // check should reject. + state.manifest = { + schema_version: 2, + items: { + [EXISTING_ID]: { + id: EXISTING_ID, type: 'login', title: 'Example', + tags: [], favorite: false, icon_hint: 'example.com', + modified: 0, attachment_summaries: [], + }, + }, + }; + vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue( + loginItem('https://github.com/', 'alice', 'oldpass'), + ); + + const res = await route( + { type: 'capture_save_login', username: 'alice', password: 'newpass' }, + state, + makeContentSender('https://example.com/login'), + ); + expect(res).toEqual({ ok: false, error: 'origin_mismatch' }); + expect(vault.encryptAndWriteItem).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/service-worker/router/content-callable.ts b/extension/src/service-worker/router/content-callable.ts index df46b60..73143b5 100644 --- a/extension/src/service-worker/router/content-callable.ts +++ b/extension/src/service-worker/router/content-callable.ts @@ -5,7 +5,7 @@ /// sender.tab !== undefined. import type { ContentMessage, Response } from '../../shared/messages'; -import type { Manifest } from '../../shared/types'; +import type { Item, Manifest } from '../../shared/types'; import type { GitHost } from '../git-host'; import * as vault from '../vault'; import * as session from '../session'; @@ -13,6 +13,8 @@ import * as session from '../session'; export interface ContentState { manifest: Manifest | null; gitHost: GitHost | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + wasm: any; } export async function handle( @@ -93,9 +95,95 @@ export async function handle( } 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 } }; + } } } +// --- Manifest entry derivation (duplicated from popup-only for self-containment) --- + +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, + })), + }; +} + async function loadDeviceSettings(): Promise<{ captureEnabled: boolean; captureStyle: 'bar' | 'toast' }> { const r = await chrome.storage.local.get('relicarioSettings'); return (r.relicarioSettings as { captureEnabled: boolean; captureStyle: 'bar' | 'toast' }) diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index 55a320a..3001b9f 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -35,7 +35,8 @@ export type ContentMessage = | { type: 'get_autofill_candidates' } | { type: 'get_credentials'; id: ItemId } | { type: 'check_credential'; username: string; password: string } - | { type: 'blacklist_site' }; + | { type: 'blacklist_site' } + | { type: 'capture_save_login'; username: string; password: string }; // --- Union for chrome.runtime.sendMessage call sites --- @@ -99,4 +100,5 @@ export const POPUP_ONLY_TYPES: ReadonlySet = new Set([ export const CONTENT_CALLABLE_TYPES: ReadonlySet = new Set([ 'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site', + 'capture_save_login', ] as ContentMessage['type'][]);