import { beforeEach, describe, expect, it, vi } from "vitest"; import { base64urlEncode, generatePasskeyKeypair } from "./passkey-crypto"; import type { Message, MessageResponse } from "../shared/types"; type StorageShape = Record; function deferred(): { promise: Promise; resolve: () => void } { let resolve!: () => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } type MessageListener = ( msg: unknown, sender: chrome.runtime.MessageSender, sendResponse: (response: MessageResponse) => void ) => boolean; describe("background messages", () => { let storage: StorageShape; let sessionStorage: StorageShape; let listener: MessageListener; let fetchMock: ReturnType; beforeEach(async () => { vi.resetModules(); vi.useRealTimers(); storage = { peach_settings: { breachMonitoring: false }, }; sessionStorage = {}; fetchMock = vi.fn(async () => ({ ok: true, status: 200, text: async () => "", })); vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false); const tabs = [{ id: 1 }]; const chromeMock = { runtime: { id: "peach-extension", getURL: (p: string) => `chrome-extension://peach-extension/${p}`, onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }), }, sendMessage: vi.fn(async () => ({ ok: true })), }, tabs: { query: vi.fn((_query: unknown, callback?: (tabs: Array<{ id?: number }>) => void) => { if (typeof callback === "function") { callback(tabs); return; } return Promise.resolve(tabs); }), sendMessage: vi.fn(async () => ({ ok: true })), }, storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return storage; if (typeof key === "string") { return { [key]: storage[key] }; } return key.reduce>((result, entry) => { result[entry] = storage[entry]; return result; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(storage, next); }), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const key of list) { delete storage[key]; } }), clear: vi.fn(async () => { for (const key of Object.keys(storage)) delete storage[key]; }), }, session: { get: vi.fn(async (key?: string | string[]) => { if (!key) return sessionStorage; if (typeof key === "string") { return { [key]: sessionStorage[key] }; } return key.reduce>((result, entry) => { result[entry] = sessionStorage[entry]; return result; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(sessionStorage, next); }), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const key of list) { delete sessionStorage[key]; } }), clear: vi.fn(async () => { for (const key of Object.keys(sessionStorage)) delete sessionStorage[key]; }), }, sync: { get: vi.fn(async (_key?: string | string[]) => ({})), set: vi.fn(async (_values: Record) => undefined), remove: vi.fn(async (_keys: string | string[]) => undefined), clear: vi.fn(async () => undefined), }, }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() }, }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() }, }, scripting: { executeScript: vi.fn(async () => []), }, } as unknown as typeof chrome; (globalThis as { chrome?: typeof chrome }).chrome = chromeMock; const messages = await import("./messages"); messages.setupMessageHandler(); const vault = await import("./vault"); await vault.ensureSettingsLoaded(); }); async function dispatch( message: Message, sender: chrome.runtime.MessageSender = { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html", } ): Promise { return await new Promise((resolve) => { const keepChannelOpen = listener(message, sender, (response) => resolve(response)); expect(keepChannelOpen).toBe(true); }); } it("PB-26-303: page GET_SETTINGS returns only theme, accent, and its own summon record", async () => { storage.peach_settings = { theme: "dark", accentColor: "peach", autoLockMinutes: 5, pinEnabled: true, manualSummonDomains: { "example.com": { firstSummonedAt: 1, summonCount: 2, lastSummonedAt: 3 }, "other.example": { firstSummonedAt: 4, summonCount: 5, lastSummonedAt: 6 }, }, }; const response = await dispatch( { type: "GET_SETTINGS" }, { id: "peach-extension", url: "https://login.example.com/form", tab: { id: 1 }, frameId: 0, } as chrome.runtime.MessageSender, ); expect(response).toEqual({ ok: true, data: { theme: "dark", accentColor: "peach", manualSummonDomains: { "example.com": { firstSummonedAt: 1, summonCount: 2, lastSummonedAt: 3 }, }, }, }); await expect(dispatch({ type: "GET_SETTINGS" })).resolves.toMatchObject({ ok: true, data: { autoLockMinutes: 5, pinEnabled: true, manualSummonDomains: { "example.com": expect.anything(), "other.example": expect.anything(), }, }, }); }); it("RECORD_MANUAL_SUMMON binds and bounds the sender domain", async () => { const sender = { id: "peach-extension", url: "https://login.example.com/form", origin: "https://login.example.com", tab: { id: 1 }, frameId: 0, } as chrome.runtime.MessageSender; await expect(dispatch({ type: "RECORD_MANUAL_SUMMON", payload: { domain: "evil.example" }, }, sender)).resolves.toEqual({ ok: false, error: "invalid_domain" }); await expect(dispatch({ type: "RECORD_MANUAL_SUMMON", payload: { domain: "example.com" }, }, sender)).resolves.toEqual({ ok: true }); await expect(dispatch({ type: "RECORD_MANUAL_SUMMON", payload: { domain: "a".repeat(254) }, }, { ...sender, origin: "https://a.com", url: "https://a.com/form" })).resolves.toEqual({ ok: false, error: "invalid_domain", }); }); it("GET_ENTRIES when unlocked returns the entry array", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com", }); await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({ ok: true, data: [ expect.objectContaining({ title: "GitHub", username: "june", }), ], }); }); it("GET_ENTRIES when locked returns a vault locked error", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); vault.lock(); await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({ ok: false, error: "vault_locked", }); }); it("ADD_ENTRY with a valid payload returns the created entry", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const response = await dispatch({ type: "ADD_ENTRY", payload: { title: "GitHub", username: "june", password: "secret123", url: "https://github.com", }, }); expect(response).toMatchObject({ ok: true, data: expect.objectContaining({ id: expect.any(String), title: "GitHub", }), }); expect(vault.getEntries()).toHaveLength(1); }); it("ADD_ENTRY when locked returns a vault locked error", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); vault.lock(); await expect( dispatch({ type: "ADD_ENTRY", payload: { title: "GitHub", username: "june", password: "secret123", url: "https://github.com", }, }) ).resolves.toMatchObject({ ok: false, error: "vault_locked" }); }); it("ADD_ENTRY with no password succeeds for email-only accounts", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await expect( dispatch({ type: "ADD_ENTRY", payload: { title: "GitHub", username: "june", password: "", url: "https://github.com", }, }) ).resolves.toMatchObject({ ok: true }); }); it("UPDATE_ENTRY updates an existing entry and returns ok", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com", }); await expect( dispatch({ type: "UPDATE_ENTRY", payload: { id: entry.id, patch: { password: "new-secret" } }, }) ).resolves.toMatchObject({ ok: true }); expect(vault.getEntryById(entry.id)?.password).toBe("new-secret"); }); it("UPDATE_ENTRY with an empty password clears it for email-only accounts", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com", }); await expect( dispatch({ type: "UPDATE_ENTRY", payload: { id: entry.id, patch: { password: "" } }, }) ).resolves.toMatchObject({ ok: true }); expect(vault.getEntryById(entry.id)?.password).toBeUndefined(); }); it("DELETE_ENTRY soft-deletes the target entry", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com", }); await expect( dispatch({ type: "DELETE_ENTRY", payload: { id: entry.id }, }) ).resolves.toMatchObject({ ok: true }); expect(vault.getEntries()).toEqual([]); expect(vault.getDeletedEntries()).toHaveLength(1); }); it("DELETE_ENTRY of a missing id returns an error", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await expect( dispatch({ type: "DELETE_ENTRY", payload: { id: "missing" }, }) ).resolves.toMatchObject({ ok: false, error: "entry_not_found" }); }); it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com", }); const response = await dispatch( { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } }, { id: "peach-extension", url: "https://github.com/login" }, ); expect(response).toMatchObject({ ok: true, data: expect.objectContaining({ id: entry.id, username: "june", password: "secret123", }), }); expect((response.data as Record).privateKeyJwk).toBeUndefined(); }); it("GET_ENTRY_FOR_FILL when locked returns an error", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); vault.lock(); await expect( dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: "missing" }, }) ).resolves.toMatchObject({ ok: false, error: "vault_locked" }); }); it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const generated = await generatePasskeyKeypair(-7); const entry = await vault.addEntry({ type: "login", title: "WebAuthn.io", passkey: { rpId: "webauthn.io", rpName: "WebAuthn.io", credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))), publicKeyCose: base64urlEncode(generated.publicKeyCose), privateKeyJwk: generated.privateKeyJwk, userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))), userName: "june", userDisplayName: "June", algorithm: -7, signCount: 0, aaguid: "AAAAAAAAAAAAAAAAAAAAAA", backupEligible: true, backupState: false, createdAt: Date.now(), }, }); await expect( dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id }, }) ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" }); }); it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com", totp: "JBSWY3DPEHPK3PXP", }); const response = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "github.com" }, }); expect(response).toMatchObject({ ok: true, data: [ { id: expect.any(String), title: "GitHub", username: "june", url: "https://github.com", hasTotp: true, }, ], }); expect((response.data as Array>)[0].password).toBeUndefined(); expect((response.data as Array>)[0].totp).toBeUndefined(); }); it("AUTOFILL_QUERY includeAll returns search-ready summaries for non-note, non-passkey entries", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com", }); await vault.addEntry({ type: "note", title: "Secure note", notes: "hello", }); const response = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "github.com", includeAll: true }, }); expect(response).toMatchObject({ ok: true, data: [ { id: expect.any(String), title: "GitHub", username: "june", url: "https://github.com", hasPasskey: false, }, ], }); const data = response.data as Array>; expect(data).toHaveLength(1); // No secrets cross the boundary. expect(data[0]).not.toHaveProperty("password"); expect(data[0]).not.toHaveProperty("totp"); }); it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com", totp: "JBSWY3DPEHPK3PXP", }); await vault.addEntry({ title: "No 2FA", username: "person", password: "secret123", url: "https://github.com", }); const response = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "github.com", kind: "totp" }, }); expect(response).toMatchObject({ ok: true, data: [ { id: expect.any(String), title: "GitHub", username: "june", url: "https://github.com", hasTotp: true, }, ], }); expect((response.data as Array>)[0].totp).toBeUndefined(); expect(response.data).toHaveLength(1); }); it("RESTORE_ENTRIES replace mode restores the provided entries", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await vault.addEntry({ title: "A", username: "", password: "one", url: "" }); const response = await dispatch({ type: "RESTORE_ENTRIES", payload: { mode: "replace", entries: [ { id: "restored-1", type: "login", title: "B", username: "june", password: "two", url: "https://b.test", createdAt: 1, modifiedAt: 1, }, ], }, }); expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } }); expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]); }); it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await expect( dispatch({ type: "RESTORE_ENTRIES", payload: { mode: "replace", entries: [] }, }) ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" }); }); it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await expect( dispatch({ type: "CHANGE_PASSWORD", payload: { currentPassword: "correct-pass-12345", newPassword: "new-pass-67890", }, }) ).resolves.toMatchObject({ ok: true }); vault.lock(); expect(await vault.unlock("new-pass-67890")).toBe(true); await expect( dispatch({ type: "CHANGE_PASSWORD", payload: { currentPassword: "wrong-pass", newPassword: "another-pass", }, }) ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" }); }); it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); ( chrome.alarms.create as unknown as ReturnType ).mockClear(); ( chrome.idle.setDetectionInterval as unknown as ReturnType ).mockClear(); await expect( dispatch({ type: "UPDATE_SETTINGS", payload: { autoLockMinutes: 30 }, }) ).resolves.toMatchObject({ ok: true }); expect(vault.getSettings().autoLockMinutes).toBe(30); expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800); expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", { delayInMinutes: 30, }); }); it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => { vi.useFakeTimers(); const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com", }); const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7); const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" }); await vi.runAllTimersAsync(); const response = await responsePromise; expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } }); expect(vault.getEntryById(entry.id)?.breach).toBe(7); breachSpy.mockRestore(); }); it("rejects passkey approval from a different tab than the ceremony", async () => { sessionStorage.passkey_pending_301 = { requestId: 301, type: "get", rpId: "example.com", rpName: "Example", userName: "june@example.com", tabId: 7, origin: "https://example.com", }; await expect( dispatch( { type: "PASSKEY_AUTH_RESPONSE", payload: { requestId: 301, approved: true } }, { id: "peach-extension", tab: { id: 8 }, url: "https://example.com/login" } as chrome.runtime.MessageSender, ), ).resolves.toMatchObject({ ok: false, error: "invalid_passkey_approval_sender" }); }); it("messages from foreign or URL-less senders are rejected", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({ ok: false, error: "invalid_sender", }); await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({ ok: false, error: "invalid_sender", }); }); it("LOCK and UNLOCK messages update vault state correctly", async () => { const vault = await import("./vault"); await expect( dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } }) ).resolves.toMatchObject({ ok: true }); expect(vault.isUnlocked()).toBe(true); await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true }); expect(vault.isUnlocked()).toBe(false); await expect( dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } }) ).resolves.toMatchObject({ ok: false }); expect(vault.isUnlocked()).toBe(false); await expect( dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } }) ).resolves.toMatchObject({ ok: true }); expect(vault.isUnlocked()).toBe(true); }); it("does not acknowledge LOCK until session cleanup finishes", async () => { await expect( dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } }) ).resolves.toMatchObject({ ok: true }); const removalStarted = deferred(); const releaseRemoval = deferred(); const remove = chrome.storage.session.remove as unknown as ReturnType; remove.mockImplementation(async (keys: string | string[]) => { removalStarted.resolve(); await releaseRemoval.promise; for (const key of Array.isArray(keys) ? keys : [keys]) delete sessionStorage[key]; }); let responded = false; const response = dispatch({ type: "LOCK" }).then((result) => { responded = true; return result; }); await removalStarted.promise; try { await Promise.resolve(); expect(responded).toBe(false); } finally { releaseRemoval.resolve(); } await expect(response).resolves.toMatchObject({ ok: true }); }); it("remembers the last successful unlock method", async () => { const vault = await import("./vault"); const password = "correct-pass-12345"; const popupSender = { id: "peach-extension", url: "chrome-extension://peach-extension/src/popup/index.html", } as chrome.runtime.MessageSender; await vault.createVault(password); expect(await vault.setupPinUnlock(password, "1234")).toBe(true); vault.lock(); await expect( dispatch({ type: "UNLOCK_WITH_PIN", payload: { pin: "1234" } }, popupSender), ).resolves.toMatchObject({ ok: true }); expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(true); vault.lock(); await expect( dispatch({ type: "UNLOCK", payload: { password } }, popupSender), ).resolves.toMatchObject({ ok: true }); expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(false); }); it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => { const response = await dispatch({ type: "FIRE_CANARY_PING", payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "password_copied", }, }); expect(response).toMatchObject({ ok: true }); expect(fetchMock).toHaveBeenCalledWith( "https://example.com/hook", expect.objectContaining({ method: "POST", headers: { "Content-Type": "application/json" }, body: expect.any(String), }) ); const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record; expect(body).toMatchObject({ entry: "GitHub", trigger: "password_copied", source: "peach-extension", }); }); it("LV-3: debounces a repeat non-test ping for the same webhook+entry", async () => { const payload = { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" as const, }; const first = await dispatch({ type: "FIRE_CANARY_PING", payload }); expect(first).toMatchObject({ ok: true }); expect(fetchMock).toHaveBeenCalledTimes(1); expect(Object.keys(storage).some((key) => key.includes("GitHub"))).toBe(false); expect(Object.keys(storage).some((key) => key.startsWith("canary-last-fetch:"))).toBe(true); const second = await dispatch({ type: "FIRE_CANARY_PING", payload }); expect(second).toMatchObject({ ok: true, data: { rateLimited: true } }); expect(fetchMock).toHaveBeenCalledTimes(1); // not fired again }); it("LV-3: removes legacy plaintext rate-limit keys", async () => { const legacyKey = "canary-last-fetch:https://example.com/hook:GitHub"; storage[legacyKey] = Date.now(); await dispatch({ type: "FIRE_CANARY_PING", payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" }, }); expect(storage[legacyKey]).toBeUndefined(); }); it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => { await dispatch({ type: "FIRE_CANARY_PING", payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" }, }); await dispatch({ type: "FIRE_CANARY_PING", payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" }, }); expect(fetchMock).toHaveBeenCalledTimes(2); }); it("LV-3: test pings are exempt from the debounce", async () => { const payload = { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "test" as const, }; await dispatch({ type: "FIRE_CANARY_PING", payload }); await dispatch({ type: "FIRE_CANARY_PING", payload }); expect(fetchMock).toHaveBeenCalledTimes(2); }); it("rejects test pings from content scripts before contacting the webhook", async () => { const response = await dispatch( { type: "FIRE_CANARY_PING", payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "test", }, }, { id: "peach-extension", url: "https://example.com/login", tab: { id: 7 }, } as chrome.runtime.MessageSender, ); expect(response).toMatchObject({ ok: false, error: "invalid_sender" }); expect(fetchMock).not.toHaveBeenCalled(); }); it("FIRE_CANARY_PING rejects insecure webhook urls", async () => { await expect( dispatch({ type: "FIRE_CANARY_PING", payload: { webhookUrl: "http://example.com/hook", entryTitle: "GitHub", trigger: "password_copied", }, }) ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" }); expect(fetchMock).not.toHaveBeenCalled(); }); it("F3: FIRE_CANARY_PING rejects IPv4-mapped IPv6 webhooks pointing at private ranges (Codex Security 2026-08-04)", async () => { for (const webhookUrl of [ "https://[::ffff:192.168.0.1]/hook", "https://[::ffff:c0a8:1]/hook", "https://[::ffff:10.0.0.1]/hook", "https://[::ffff:127.0.0.1]/hook", "https://[::ffff:169.254.1.1]/hook", "https://[::ffff:172.16.0.1]/hook", ]) { await expect( dispatch({ type: "FIRE_CANARY_PING", payload: { webhookUrl, entryTitle: "GitHub", trigger: "password_copied" }, }) ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" }); } expect(fetchMock).not.toHaveBeenCalled(); }); it("GET_IDENTITIES when locked returns vault locked error", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); vault.lock(); await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({ ok: false, error: "vault_locked", }); }); it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await vault.addEntry({ title: "Gmail", username: "june@gmail.com", password: "pw1", url: "https://gmail.com" }); await vault.addEntry({ title: "GitHub", username: "june@gmail.com", password: "pw2", url: "https://github.com" }); await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" }); const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] }; expect(resp.ok).toBe(true); const data = resp.data as Array<{ value: string; type: string; useCount: number }>; expect(data.find((d) => d.value === "june@gmail.com")).toMatchObject({ type: "email", useCount: 2, }); expect(data.find((d) => d.value === "juni_patel")).toMatchObject({ type: "username", useCount: 1, }); // emails appear before usernames const emailIdx = data.findIndex((d) => d.value === "june@gmail.com"); const userIdx = data.findIndex((d) => d.value === "juni_patel"); expect(emailIdx).toBeLessThan(userIdx); }); it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); for (let i = 0; i < 3; i++) { await vault.addEntry({ title: `Site${i}`, username: "me@example.com", password: "pw", url: `https://site${i}.com` }); } const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] }; const data = resp.data as Array<{ value: string; useCount: number }>; const hits = data.filter((d) => d.value === "me@example.com"); expect(hits).toHaveLength(1); expect(hits[0]!.useCount).toBe(3); }); it("GET_IDENTITIES rejects content-script and page-relay senders", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await vault.addEntry({ title: "Private identity", username: "private@example.com", password: "pw", url: "https://private.example", }); const senders = [ { id: "peach-extension", url: "https://private.example/signup", tab: { id: 1 } as chrome.tabs.Tab, }, { id: "peach-extension", url: "https://private.example/signup" }, ] satisfies chrome.runtime.MessageSender[]; for (const sender of senders) { await expect(dispatch({ type: "GET_IDENTITIES" }, sender)).resolves.toMatchObject({ ok: false, error: "invalid_sender", }); } }); it("IS_CARD_DECLINED does not refresh the auto-lock timer", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const alarmsCreate = chrome.alarms.create as unknown as ReturnType; // Sanity: a non-excluded message (GET_ENTRIES) DOES refresh the timer alarmsCreate.mockClear(); await dispatch({ type: "GET_ENTRIES" }); expect(alarmsCreate).toHaveBeenCalled(); // Passive queries are in the exclusion list and must not re-arm alarmsCreate.mockClear(); await dispatch({ type: "IS_CARD_DECLINED", payload: { origin: "https://example.com" } }); expect(alarmsCreate).not.toHaveBeenCalled(); }); it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" }); // Simulate an activated Pro license + already-celebrated marker. storage.peach_license = "PEACH-PRO-TEST-KEY"; storage.peach_pro_celebrated = true; const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" }); expect(res).toMatchObject({ ok: true }); // Vault data is gone… expect(storage.peach_vault_a).toBeUndefined(); // …but the paid license and celebration flag survive the wipe. expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY"); expect(storage.peach_pro_celebrated).toBe(true); }); it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => { const messages = await import("./messages"); const vault = await import("./vault"); let resolveGate: () => void = () => {}; messages.setRestoreReady(new Promise((r) => { resolveGate = r; })); let settled = false; const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; }); // While restore is in flight the handler must NOT have answered yet — // this is the window where the old code returned "no vault" → onboarding. await new Promise((r) => setTimeout(r, 20)); expect(settled).toBe(false); // Restore completes (the vault becomes available), gate opens. await vault.createVault("correct-pass-12345"); resolveGate(); await expect(pending).resolves.toMatchObject({ ok: true, data: true }); }); it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => { const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } }); expect(res.ok).toBe(false); // Nothing was written — no vault exists. const has = await dispatch({ type: "HAS_VAULT" }); expect((has as { data?: boolean }).data).toBe(false); }); it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const e = await vault.addEntry({ title: "Mail", username: "june", password: "pw", url: "https://mail.test", totp: "JBSWY3DPEHPK3PXP", }); const res = await dispatch( { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } }, { id: "peach-extension", url: "https://mail.test/login" }, ); expect(res.ok).toBe(true); const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number }; expect(data.totp).toBeUndefined(); expect(data.totpCode).toMatch(/^\d{6}$/); expect(data.totpRemaining).toBeGreaterThan(0); expect(data.totpRemaining).toBeLessThanOrEqual(30); }); it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const e = await vault.addEntry({ title: "Bank", username: "june", password: "old-pw", url: "https://bank.test", }); await vault.updateEntry(e.id, { password: "new-pw" }); const fill = await dispatch( { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } }, { id: "peach-extension", url: "https://bank.test/login" }, ); expect(fill.ok).toBe(true); // Passive autofill never receives prior passwords (blast-radius containment). expect((fill.data as Record).passwordHistory).toBeUndefined(); const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } }); expect(edit.ok).toBe(true); const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? []; expect(editHist.map((h) => h.password)).toEqual(["old-pw"]); }); it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const e = await vault.addEntry({ title: "Mail", username: "june", password: "pw", url: "https://mail.test", totp: "JBSWY3DPEHPK3PXP", }); const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } }); expect(res.ok).toBe(true); const data = res.data as { totp?: string }; expect(data.totp).toBe("JBSWY3DPEHPK3PXP"); }); it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" }); expect(res).toMatchObject({ ok: true }); expect(storage.peach_vault_a).toBeUndefined(); expect(storage.peach_license).toBeUndefined(); expect(storage.peach_pro_celebrated).toBeUndefined(); }); // --- MV-1: GET_ENTRY_FOR_FILL re-validates the requesting tab's origin --- function tabSender(url: string, tabId = 1, frameId = 0): chrome.runtime.MessageSender { return { id: "peach-extension", tab: { id: tabId, url } as chrome.tabs.Tab, url, frameId }; } async function addPasskeyOnlyEntry(title: string, url: string) { const vault = await import("./vault"); const generated = await generatePasskeyKeypair(-7); return vault.addEntry({ type: "login", title, url, passkey: { rpId: new URL(url).hostname, rpName: title, credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))), publicKeyCose: base64urlEncode(generated.publicKeyCose), privateKeyJwk: generated.privateKeyJwk, userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))), userName: "june", userDisplayName: "June", algorithm: -7, signCount: 0, aaguid: "AAAAAAAAAAAAAAAAAAAAAA", backupEligible: true, backupState: false, createdAt: Date.now(), }, }); } it("PB-26-201: normal inventory ignores forged domain and gesture booleans", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await vault.addEntry({ title: "GitHub", username: "victim", password: "secret", url: "https://github.com" }); await vault.addEntry({ title: "Evil", username: "attacker", password: "known", url: "https://evil.example.com" }); const result = await dispatch( { type: "AUTOFILL_QUERY", payload: { domain: "github.com", includeAll: true, userGesture: true, explicitSummon: true, }, }, tabSender("https://evil.example.com/phish"), ); expect(result).toMatchObject({ ok: true }); expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil"]); }); it("PB-26-201: normal TOTP inventory is scoped to the attested frame origin", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await vault.addEntry({ title: "GitHub 2FA", password: "secret", url: "https://github.com", totp: "JBSWY3DPEHPK3PXP" }); await vault.addEntry({ title: "Evil 2FA", password: "known", url: "https://evil.example.com", totp: "JBSWY3DPEHPK3PXP" }); const result = await dispatch( { type: "AUTOFILL_QUERY", payload: { domain: "github.com", kind: "totp" } }, tabSender("https://evil.example.com/verify"), ); expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil 2FA"]); }); it("PB-26-201: summon capability is one-shot after inventory authorization", async () => { const vault = await import("./vault"); const messages = await import("./messages"); await vault.createVault("correct-pass-12345"); const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com" }); const sender = tabSender("https://evil.example.com/phish"); const token = messages.createSummonCapability(1, "https://evil.example.com"); const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender); expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] }); const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender); expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } }); const replay = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender); expect(replay).toMatchObject({ ok: false, error: "entry_not_found" }); }); it("PB-26-201: summon capability is consumed by a same-domain fill", async () => { const vault = await import("./vault"); const messages = await import("./messages"); await vault.createVault("correct-pass-12345"); const entry = await vault.addEntry({ title: "GitHub", password: "secret123", url: "https://evil.example.com" }); const sender = tabSender("https://evil.example.com/login"); const token = messages.createSummonCapability(1, "https://evil.example.com"); const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender); expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] }); const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender); expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } }); const offDomainReplay = await dispatch( { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, tabSender("https://github.com/login"), ); expect(offDomainReplay).toMatchObject({ ok: false, error: "entry_not_found" }); }); it("PB-26-201: summon capability cannot fill a different entry", async () => { const vault = await import("./vault"); const messages = await import("./messages"); await vault.createVault("correct-pass-12345"); const first = await vault.addEntry({ title: "First", password: "one", url: "https://first.example" }); const second = await vault.addEntry({ title: "Second", password: "two", url: "https://second.example" }); const sender = tabSender("https://evil.example.com/phish"); const token = messages.createSummonCapability(1, "https://evil.example.com"); await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender); const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: first.id, capability: token } }, sender); expect(firstFill).toMatchObject({ ok: true, data: { id: first.id } }); const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: second.id, capability: token } }, sender); expect(result).toMatchObject({ ok: false, error: "entry_not_found" }); expect(first.id).not.toBe(second.id); }); it("PB-26-201: fill before inventory is rejected without an entry oracle", async () => { const vault = await import("./vault"); const messages = await import("./messages"); await vault.createVault("correct-pass-12345"); const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com"); const token = messages.createSummonCapability(1, "https://evil.example.com"); const result = await dispatch( { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, tabSender("https://evil.example.com/phish"), ); expect(result).toMatchObject({ ok: false, error: "entry_not_found" }); }); it("PB-26-201: an unused summon capability does not block a normal same-origin fill", async () => { const vault = await import("./vault"); const messages = await import("./messages"); await vault.createVault("correct-pass-12345"); const entry = await vault.addEntry({ title: "Evil", password: "known", url: "https://evil.example.com", }); const sender = tabSender("https://evil.example.com/login"); const token = messages.createSummonCapability(1, "https://evil.example.com"); const result = await dispatch( { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender, ); expect(result).toMatchObject({ ok: true, data: { id: entry.id } }); }); it("PB-26-201: expired fill capability is rejected before entry-specific errors", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-09-02T12:00:00Z")); const vault = await import("./vault"); const messages = await import("./messages"); await vault.createVault("correct-pass-12345"); const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com"); const sender = tabSender("https://evil.example.com/phish"); const token = messages.createSummonCapability(1, "https://evil.example.com"); await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender); vi.advanceTimersByTime(30_001); const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender); expect(result).toMatchObject({ ok: false, error: "entry_not_found" }); }); it("PB-26-201: wrong-tab fill capability is rejected before entry-specific errors", async () => { const vault = await import("./vault"); const messages = await import("./messages"); await vault.createVault("correct-pass-12345"); const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com"); const token = messages.createSummonCapability(1, "https://evil.example.com"); await dispatch( { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, tabSender("https://evil.example.com/phish"), ); const result = await dispatch( { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, tabSender("https://evil.example.com/phish", 2), ); expect(result).toMatchObject({ ok: false, error: "entry_not_found" }); }); it("PB-26-201: wrong-frame fill capability is rejected before entry-specific errors", async () => { const vault = await import("./vault"); const messages = await import("./messages"); await vault.createVault("correct-pass-12345"); const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com"); const token = messages.createSummonCapability(1, "https://evil.example.com", 3); await dispatch( { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, tabSender("https://evil.example.com/phish", 1, 3), ); const result = await dispatch( { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, tabSender("https://evil.example.com/phish", 1, 4), ); expect(result).toMatchObject({ ok: false, error: "entry_not_found" }); }); it("PB-26-201: wrong-origin fill capability is rejected before entry-specific errors", async () => { const vault = await import("./vault"); const messages = await import("./messages"); await vault.createVault("correct-pass-12345"); const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com"); const token = messages.createSummonCapability(1, "https://evil.example.com"); await dispatch( { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, tabSender("https://evil.example.com/phish"), ); const result = await dispatch( { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, tabSender("https://other.example.com/phish"), ); expect(result).toMatchObject({ ok: false, error: "entry_not_found" }); }); it("PB-26-201: selecting a missing id consumes the one-shot capability without an oracle", async () => { const vault = await import("./vault"); const messages = await import("./messages"); await vault.createVault("correct-pass-12345"); const existing = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com"); const sender = tabSender("https://evil.example.com/phish"); const token = messages.createSummonCapability(1, "https://evil.example.com"); await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender); const missing = await dispatch( { type: "GET_ENTRY_FOR_FILL", payload: { id: "missing", capability: token } }, sender, ); const existingReplay = await dispatch( { type: "GET_ENTRY_FOR_FILL", payload: { id: existing.id, capability: token } }, sender, ); expect(missing).toMatchObject({ ok: false, error: "entry_not_found" }); expect(existingReplay).toEqual(missing); }); it("PB-26-201: inventory capability cannot be reused for a second inventory", async () => { const vault = await import("./vault"); const messages = await import("./messages"); await vault.createVault("correct-pass-12345"); await vault.addEntry({ title: "GitHub", password: "secret", url: "https://github.com" }); const sender = tabSender("https://evil.example.com/phish"); const token = messages.createSummonCapability(1, "https://evil.example.com"); const first = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender); const second = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender); expect(first).toMatchObject({ ok: true, data: [{ title: "GitHub" }] }); expect(second).toMatchObject({ ok: false, error: "entry_not_found" }); }); it("PB-26-201: cards require a capability and consume it on the selected fill", async () => { const vault = await import("./vault"); const messages = await import("./messages"); await vault.createVault("correct-pass-12345"); const card = await vault.addEntry({ type: "card", title: "Personal Visa", cardholderName: "June Peach", cardNumber: "4111111111111111", cvc: "123", expMonth: "12", expYear: "2030", }); const sender = tabSender("https://shop.example.com/checkout"); const passive = await dispatch( { type: "AUTOFILL_QUERY", payload: { domain: "shop.example.com", kind: "card" } }, sender, ); expect(passive).toMatchObject({ ok: true, data: [] }); const token = messages.createSummonCapability(1, "https://shop.example.com"); const inventory = await dispatch( { type: "AUTOFILL_QUERY", payload: { domain: "forged.example", kind: "card", capability: token } }, sender, ); expect(inventory).toMatchObject({ ok: true, data: [{ id: card.id, title: "Personal Visa" }] }); expect((inventory.data as Array>)[0]).not.toHaveProperty("cardNumber"); const fill = await dispatch( { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } }, sender, ); expect(fill).toMatchObject({ ok: true, data: { id: card.id, cardNumber: "4111111111111111" } }); const replay = await dispatch( { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } }, sender, ); expect(replay).toMatchObject({ ok: false, error: "entry_not_found" }); }); it("PB-26-201: inline edit popup request stores only the selected entry id", async () => { const result = await dispatch( { type: "OPEN_POPUP", payload: { entryId: "entry-123" } }, tabSender("https://example.com/login"), ); expect(result).toMatchObject({ ok: true }); expect(sessionStorage.peach_open_entry).toBe("entry-123"); expect(sessionStorage.peach_inline_unlock).toBeUndefined(); }); it("MV-1: tab request with a matching domain returns the entry", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com", }); const res = await dispatch( { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } }, tabSender("https://github.com/login"), ); expect(res).toMatchObject({ ok: true, data: { id: entry.id } }); }); it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com", }); const res = await dispatch( { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } }, tabSender("https://evil.example.com/phish"), ); // Refused with the same error as a missing id — no cross-domain oracle. expect(res).toMatchObject({ ok: false, error: "entry_not_found" }); }); it("PB-26-201: explicitSummon cannot authorize off-domain fill", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com", }); const res = await dispatch( { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, explicitSummon: true }, }, tabSender("https://evil.example.com/phish"), ); expect(res).toMatchObject({ ok: false, error: "entry_not_found" }); }); it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com", }); const res = await dispatch( { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } }, tabSender("not a url"), ); expect(res).toMatchObject({ ok: false, error: "origin_unverified" }); }); // --- MV-2: vault-wide inventory only ships on an explicit user gesture --- async function seedTwoDomainVault() { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await vault.addEntry({ title: "GitHub", username: "june", password: "gh", url: "https://github.com", }); await vault.addEntry({ title: "Example", username: "june", password: "ex", url: "https://example.com", }); } it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => { await seedTwoDomainVault(); const res = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "github.com", includeAll: true }, }); expect(res.ok).toBe(true); const data = res.data as Array<{ title: string }>; // includeAll was stripped — only the github.com match comes back. expect(data).toHaveLength(1); expect(data[0].title).toBe("GitHub"); }); it("PB-26-201: userGesture cannot authorize vault-wide inventory", async () => { await seedTwoDomainVault(); const res = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "github.com", includeAll: true, userGesture: true }, }); expect(res.ok).toBe(true); const data = res.data as Array<{ title: string }>; expect(data.map((d) => d.title)).toEqual(["GitHub"]); }); // --- MV-7: vouched device confirmation handlers --- async function seedPendingVouched(deviceId: string) { await chrome.storage.local.set({ peach_pending_vouched_devices_v1: [{ deviceId, deviceName: "New Pixel", deviceType: "android", longTermPublicKeyHex: "e".repeat(64), signingPublicKeyHex: "a".repeat(64), wsPort: 51234, pairedAt: 1_700_000_000_020, receivedAt: 1_700_000_000_050, vouchedByDeviceId: "extension-issuer", }], }); } it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await seedPendingVouched("android-new"); const { findPairedDeviceById } = await import("./paired-devices"); const { listPendingVouchedDevices } = await import( "./pending-vouched-devices" ); const res = await dispatch({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId: "android-new" }, }); expect(res).toMatchObject({ ok: true }); expect(await findPairedDeviceById("android-new")).toMatchObject({ deviceName: "New Pixel", trustSource: "vouched", longTermPublicKeyHex: "e".repeat(64), }); expect(await listPendingVouchedDevices()).toHaveLength(0); }); it("S1: CONFIRM_VOUCHED_DEVICE refuses a pending device whose key is revoked", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await seedPendingVouched("android-relabelled"); const { findPairedDeviceById, revokeDeviceById, upsertPairedDevice } = await import("./paired-devices"); const { listPendingVouchedDevices } = await import( "./pending-vouched-devices" ); // The queue entry predates the revocation, and it carries a fresh id, so // sweeping the queue by id at revocation time would not have caught it. await upsertPairedDevice({ deviceId: "android-old", deviceName: "New Pixel", deviceType: "android", longTermPublicKeyHex: "e".repeat(64), signingPublicKeyHex: "a".repeat(64), pairedAt: 1_700_000_000_000, trustSource: "vouched", }); await revokeDeviceById("android-old", "self"); const res = await dispatch({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId: "android-relabelled" }, }); expect(res).toMatchObject({ ok: false, error: "device_revoked" }); expect(await findPairedDeviceById("android-relabelled")).toBeNull(); expect(await listPendingVouchedDevices()).toHaveLength(0); }); it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await seedPendingVouched("android-new"); const { findPairedDeviceById } = await import("./paired-devices"); const { listPendingVouchedDevices } = await import( "./pending-vouched-devices" ); const res = await dispatch({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId: "android-new" }, }); expect(res).toMatchObject({ ok: true }); expect(await findPairedDeviceById("android-new")).toBeNull(); expect(await listPendingVouchedDevices()).toHaveLength(0); }); // --- unpair: UNPAIR_DEVICE revokes the device and drops it from the registry --- it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } = await import("./paired-devices"); await upsertPairedDevice({ deviceId: "phone-1", deviceName: "June's Phone", deviceType: "android", longTermPublicKeyHex: "a".repeat(64), signingPublicKeyHex: "b".repeat(64), pairedAt: 1_700_000_000_000, trustSource: "direct", }); const res = await dispatch({ type: "UNPAIR_DEVICE", payload: { deviceId: "phone-1" }, }); expect(res).toMatchObject({ ok: true }); // The row is gone locally — re-listing must not surface it. expect(await findPairedDeviceById("phone-1")).toBeNull(); // The revocation is recorded so a future manifest from a peer cannot // resurrect this device under either the same id or the same key (MV-7 + S1). expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true); const records = await listDeviceRevocations(); expect(records).toHaveLength(1); expect(records[0]).toMatchObject({ deviceId: "phone-1", longTermPublicKeyHex: "a".repeat(64), revokedByDeviceId: expect.any(String), }); }); it("UNPAIR_DEVICE on an unknown id is a no-op (ok) and writes no revocation", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const { listDeviceRevocations } = await import("./paired-devices"); const res = await dispatch({ type: "UNPAIR_DEVICE", payload: { deviceId: "never-paired" }, }); expect(res).toMatchObject({ ok: true }); // A revocation with the all-zero key sentinel is still written — the // contract is that the user-intent is recorded even when the registry had // no row. This is what blocks a peer from later vouched-adopting the same // fresh id back in. const records = await listDeviceRevocations(); expect(records).toHaveLength(1); expect(records[0]).toMatchObject({ deviceId: "never-paired", longTermPublicKeyHex: "0".repeat(64), }); }); it("UNPAIR_DEVICE refuses an empty payload with invalid_payload", async () => { const res = await dispatch({ type: "UNPAIR_DEVICE", payload: { deviceId: "" }, }); expect(res).toMatchObject({ ok: false, error: "invalid_payload" }); }); // The old "fresh direct re-pair clears the old revocation" test dispatched // COMPLETE_PAIRING, a handler deleted on 2026-08-04 (no legitimate senders; // adoption runs in-background via relay-adoption-bg.ts). The live // revocation-overlap repair is pinned in paired-devices.test.ts // (repairPairedRevocationOverlaps, runs at service-worker boot). it("F1: privileged handlers reject content-script senders but keep extension-page senders (Codex Security 2026-08-04)", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const contentScriptSender = { id: "peach-extension", url: "https://evil.example/page", }; const extensionPageSender = { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html", }; const completion = { requestId: 1, rpId: "evil.example" }; // Content-script senders must never reach the gated passkey-store handler. await expect( dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, contentScriptSender) ).resolves.toMatchObject({ ok: false, error: "invalid_sender" }); // Extension-page senders still reach it — refused by the pending-ceremony // binding, not by the sender gate. await expect( dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender) ).resolves.toMatchObject({ ok: false, error: "no_pending_request" }); }); it("F2: COMPLETE_FIREFOX_PASSKEY_CREATE must match the pending ceremony (Codex Security 2026-08-04)", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const extensionPageSender = { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html", }; const completion = { requestId: 123, rpId: "example.com", rpName: "Example", userName: "june", userHandle: "BBBB", credentialId: "cred-1", publicKeyCose: "c3BraQ", algorithm: -7 as const, }; // No pending ceremony at all -> refuse. await expect( dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender) ).resolves.toMatchObject({ ok: false, error: "no_pending_request" }); // Seed a background-minted pending ceremony. sessionStorage.passkey_pending_firefox = { requestId: 123, type: "create", rpId: "example.com", rpName: "Example", userName: "june", browser: "firefox", challenge: "AAAA", userHandle: "BBBB", }; // Mismatched userHandle (attacker-forged completion) -> refuse. await expect( dispatch( { type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { ...completion, userHandle: "CCCC" } }, extensionPageSender ) ).resolves.toMatchObject({ ok: false, error: "no_pending_request" }); // Matching completion -> stored. await expect( dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender) ).resolves.toMatchObject({ ok: true }); const entries = await dispatch({ type: "GET_ENTRIES" }); expect(entries).toMatchObject({ ok: true }); const stored = (entries as { data: Array<{ passkey?: { credentialId?: string } }> }).data; expect(stored.some((entry) => entry.passkey?.credentialId === "cred-1")).toBe(true); }); it("F2: INITIATE_FIREFOX_PASSKEY_CREATE rejects an rpId the caller origin does not own (Codex Security 2026-08-04)", async () => { await expect( dispatch( { type: "INITIATE_FIREFOX_PASSKEY_CREATE", payload: { rpId: "bank.example", rpName: "Bank", userName: "june" }, }, { id: "peach-extension", url: "https://evil.example/page" } ) ).resolves.toMatchObject({ ok: false, error: "rpid_origin_mismatch" }); }); it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => { // The cross-manifest refusal is exercised end-to-end in sync-vault-adapter.test.ts; // here we just pin the storage-level invariant the UNPAIR_DEVICE call // establishes, since the rest of the testbed's revoke coverage rides on // this state. const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice } = await import( "./paired-devices" ); await upsertPairedDevice({ deviceId: "phone-1", deviceName: "June's Phone", deviceType: "android", longTermPublicKeyHex: "a".repeat(64), signingPublicKeyHex: "b".repeat(64), pairedAt: 1_700_000_000_000, trustSource: "direct", }); expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(false); await revokeDeviceById("phone-1", "self"); // The key check is what blocks a re-vouch under a fresh id. expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true); expect(await isDeviceRevoked("fresh-id", "a".repeat(64))).toBe(true); }); // --- LV-4: GET_RECOVERY_WORDS requires a fresh password proof --- it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} }); expect(res).toMatchObject({ ok: false, error: "password_required" }); }); it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: { password: "not-the-password" }, }); expect(res).toMatchObject({ ok: false, error: "wrong_password" }); expect(res.data).toBeUndefined(); }); it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: { password: "correct-pass-12345" }, }); expect(res.ok).toBe(true); expect((res.data as string[]).length).toBe(12); }); it("PB-26-311: cleartext export requires a fresh master-password proof", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await expect( dispatch({ type: "EXPORT_ENTRIES" as never, payload: {} }), ).resolves.toMatchObject({ ok: false, error: "password_required" }); await expect( dispatch({ type: "EXPORT_ENTRIES" as never, payload: { password: "not-the-password" }, }), ).resolves.toMatchObject({ ok: false, error: "wrong_password" }); await expect( dispatch({ type: "EXPORT_ENTRIES" as never, payload: { password: "correct-pass-12345" }, }), ).resolves.toMatchObject({ ok: true, data: [] }); }); it("GET_PIN_STATUS reports only a session-available PIN wrapper", async () => { const vault = await import("./vault"); const password = "correct-pass-12345"; const popupSender = { id: "peach-extension", url: "chrome-extension://peach-extension/src/popup/index.html", } as chrome.runtime.MessageSender; await vault.createVault(password); await vault.setupPinUnlock(password, "1234"); await vault.lock(); await expect(dispatch({ type: "GET_PIN_STATUS" }, popupSender)).resolves.toMatchObject({ ok: true, data: { configured: true, available: true, attemptsRemaining: 3 }, }); }); });