1import { beforeEach, describe, expect, it, vi } from "vitest";23import { base64urlEncode, generatePasskeyKeypair } from "./passkey-crypto";4import type { Message, MessageResponse } from "../shared/types";56type StorageShape = Record<string, unknown>;78function deferred(): { promise: Promise<void>; resolve: () => void } {9 let resolve!: () => void;10 const promise = new Promise<void>((done) => {11 resolve = done;12 });13 return { promise, resolve };14}15type MessageListener = (16 msg: unknown,17 sender: chrome.runtime.MessageSender,18 sendResponse: (response: MessageResponse) => void19) => boolean;2021describe("background messages", () => {22 let storage: StorageShape;23 let sessionStorage: StorageShape;24 let listener: MessageListener;25 let fetchMock: ReturnType<typeof vi.fn>;2627 beforeEach(async () => {28 vi.resetModules();29 vi.useRealTimers();30 storage = {31 peach_settings: { breachMonitoring: false },32 };33 sessionStorage = {};3435 fetchMock = vi.fn(async () => ({36 ok: true,37 status: 200,38 text: async () => "",39 }));40 vi.stubGlobal("fetch", fetchMock);41 vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);4243 const tabs = [{ id: 1 }];44 const chromeMock = {45 runtime: {46 id: "peach-extension",47 getURL: (p: string) => `chrome-extension://peach-extension/${p}`,48 onMessage: {49 addListener: vi.fn((fn: MessageListener) => {50 listener = fn;51 }),52 },53 sendMessage: vi.fn(async () => ({ ok: true })),54 },55 tabs: {56 query: vi.fn((_query: unknown, callback?: (tabs: Array<{ id?: number }>) => void) => {57 if (typeof callback === "function") {58 callback(tabs);59 return;60 }61 return Promise.resolve(tabs);62 }),63 sendMessage: vi.fn(async () => ({ ok: true })),64 },65 storage: {66 local: {67 get: vi.fn(async (key?: string | string[]) => {68 if (!key) return storage;69 if (typeof key === "string") {70 return { [key]: storage[key] };71 }7273 return key.reduce<Record<string, unknown>>((result, entry) => {74 result[entry] = storage[entry];75 return result;76 }, {});77 }),78 set: vi.fn(async (next: Record<string, unknown>) => {79 Object.assign(storage, next);80 }),81 remove: vi.fn(async (keys: string | string[]) => {82 const list = Array.isArray(keys) ? keys : [keys];83 for (const key of list) {84 delete storage[key];85 }86 }),87 clear: vi.fn(async () => {88 for (const key of Object.keys(storage)) delete storage[key];89 }),90 },91 session: {92 get: vi.fn(async (key?: string | string[]) => {93 if (!key) return sessionStorage;94 if (typeof key === "string") {95 return { [key]: sessionStorage[key] };96 }97 return key.reduce<Record<string, unknown>>((result, entry) => {98 result[entry] = sessionStorage[entry];99 return result;100 }, {});101 }),102 set: vi.fn(async (next: Record<string, unknown>) => {103 Object.assign(sessionStorage, next);104 }),105 remove: vi.fn(async (keys: string | string[]) => {106 const list = Array.isArray(keys) ? keys : [keys];107 for (const key of list) {108 delete sessionStorage[key];109 }110 }),111 clear: vi.fn(async () => {112 for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];113 }),114 },115 sync: {116 get: vi.fn(async (_key?: string | string[]) => ({})),117 set: vi.fn(async (_values: Record<string, unknown>) => undefined),118 remove: vi.fn(async (_keys: string | string[]) => undefined),119 clear: vi.fn(async () => undefined),120 },121 },122 alarms: {123 clear: vi.fn(),124 create: vi.fn(),125 onAlarm: { addListener: vi.fn() },126 },127 idle: {128 setDetectionInterval: vi.fn(),129 onStateChanged: { addListener: vi.fn() },130 },131 scripting: {132 executeScript: vi.fn(async () => []),133 },134 } as unknown as typeof chrome;135136 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;137138 const messages = await import("./messages");139 messages.setupMessageHandler();140 const vault = await import("./vault");141 await vault.ensureSettingsLoaded();142 });143144 async function dispatch(145 message: Message,146 sender: chrome.runtime.MessageSender = {147 id: "peach-extension",148 url: "chrome-extension://peach-extension/popup.html",149 }150 ): Promise<MessageResponse> {151 return await new Promise<MessageResponse>((resolve) => {152 const keepChannelOpen = listener(message, sender, (response) => resolve(response));153 expect(keepChannelOpen).toBe(true);154 });155 }156157 it("GET_ENTRIES when unlocked returns the entry array", async () => {158 const vault = await import("./vault");159 await vault.createVault("correct-pass-12345");160 await vault.addEntry({161 title: "GitHub",162 username: "june",163 password: "secret123",164 url: "https://github.com",165 });166167 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({168 ok: true,169 data: [170 expect.objectContaining({171 title: "GitHub",172 username: "june",173 }),174 ],175 });176 });177178 it("GET_ENTRIES when locked returns a vault locked error", async () => {179 const vault = await import("./vault");180 await vault.createVault("correct-pass-12345");181 vault.lock();182183 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({184 ok: false,185 error: "vault_locked",186 });187 });188189 it("ADD_ENTRY with a valid payload returns the created entry", async () => {190 const vault = await import("./vault");191 await vault.createVault("correct-pass-12345");192193 const response = await dispatch({194 type: "ADD_ENTRY",195 payload: {196 title: "GitHub",197 username: "june",198 password: "secret123",199 url: "https://github.com",200 },201 });202203 expect(response).toMatchObject({204 ok: true,205 data: expect.objectContaining({206 id: expect.any(String),207 title: "GitHub",208 }),209 });210 expect(vault.getEntries()).toHaveLength(1);211 });212213 it("ADD_ENTRY when locked returns a vault locked error", async () => {214 const vault = await import("./vault");215 await vault.createVault("correct-pass-12345");216 vault.lock();217218 await expect(219 dispatch({220 type: "ADD_ENTRY",221 payload: {222 title: "GitHub",223 username: "june",224 password: "secret123",225 url: "https://github.com",226 },227 })228 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });229 });230231 it("ADD_ENTRY with no password succeeds for email-only accounts", async () => {232 const vault = await import("./vault");233 await vault.createVault("correct-pass-12345");234235 await expect(236 dispatch({237 type: "ADD_ENTRY",238 payload: {239 title: "GitHub",240 username: "june",241 password: "",242 url: "https://github.com",243 },244 })245 ).resolves.toMatchObject({ ok: true });246 });247248 it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {249 const vault = await import("./vault");250 await vault.createVault("correct-pass-12345");251 const entry = await vault.addEntry({252 title: "GitHub",253 username: "june",254 password: "secret123",255 url: "https://github.com",256 });257258 await expect(259 dispatch({260 type: "UPDATE_ENTRY",261 payload: { id: entry.id, patch: { password: "new-secret" } },262 })263 ).resolves.toMatchObject({ ok: true });264265 expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");266 });267268 it("UPDATE_ENTRY with an empty password clears it for email-only accounts", async () => {269 const vault = await import("./vault");270 await vault.createVault("correct-pass-12345");271 const entry = await vault.addEntry({272 title: "GitHub",273 username: "june",274 password: "secret123",275 url: "https://github.com",276 });277278 await expect(279 dispatch({280 type: "UPDATE_ENTRY",281 payload: { id: entry.id, patch: { password: "" } },282 })283 ).resolves.toMatchObject({ ok: true });284285 expect(vault.getEntryById(entry.id)?.password).toBeUndefined();286 });287288 it("DELETE_ENTRY soft-deletes the target entry", async () => {289 const vault = await import("./vault");290 await vault.createVault("correct-pass-12345");291 const entry = await vault.addEntry({292 title: "GitHub",293 username: "june",294 password: "secret123",295 url: "https://github.com",296 });297298 await expect(299 dispatch({300 type: "DELETE_ENTRY",301 payload: { id: entry.id },302 })303 ).resolves.toMatchObject({ ok: true });304305 expect(vault.getEntries()).toEqual([]);306 expect(vault.getDeletedEntries()).toHaveLength(1);307 });308309 it("DELETE_ENTRY of a missing id returns an error", async () => {310 const vault = await import("./vault");311 await vault.createVault("correct-pass-12345");312313 await expect(314 dispatch({315 type: "DELETE_ENTRY",316 payload: { id: "missing" },317 })318 ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });319 });320321 it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {322 const vault = await import("./vault");323 await vault.createVault("correct-pass-12345");324 const entry = await vault.addEntry({325 title: "GitHub",326 username: "june",327 password: "secret123",328 url: "https://github.com",329 });330331 const response = await dispatch(332 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },333 { id: "peach-extension", url: "https://github.com/login" },334 );335336 expect(response).toMatchObject({337 ok: true,338 data: expect.objectContaining({339 id: entry.id,340 username: "june",341 password: "secret123",342 }),343 });344 expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();345 });346347 it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {348 const vault = await import("./vault");349 await vault.createVault("correct-pass-12345");350 vault.lock();351352 await expect(353 dispatch({354 type: "GET_ENTRY_FOR_FILL",355 payload: { id: "missing" },356 })357 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });358 });359360 it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {361 const vault = await import("./vault");362 await vault.createVault("correct-pass-12345");363 const generated = await generatePasskeyKeypair(-7);364 const entry = await vault.addEntry({365 type: "login",366 title: "WebAuthn.io",367 passkey: {368 rpId: "webauthn.io",369 rpName: "WebAuthn.io",370 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),371 publicKeyCose: base64urlEncode(generated.publicKeyCose),372 privateKeyJwk: generated.privateKeyJwk,373 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),374 userName: "june",375 userDisplayName: "June",376 algorithm: -7,377 signCount: 0,378 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",379 backupEligible: true,380 backupState: false,381 createdAt: Date.now(),382 },383 });384385 await expect(386 dispatch({387 type: "GET_ENTRY_FOR_FILL",388 payload: { id: entry.id },389 })390 ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });391 });392393 it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {394 const vault = await import("./vault");395 await vault.createVault("correct-pass-12345");396 await vault.addEntry({397 title: "GitHub",398 username: "june",399 password: "secret123",400 url: "https://github.com",401 totp: "JBSWY3DPEHPK3PXP",402 });403404 const response = await dispatch({405 type: "AUTOFILL_QUERY",406 payload: { domain: "github.com" },407 });408409 expect(response).toMatchObject({410 ok: true,411 data: [412 {413 id: expect.any(String),414 title: "GitHub",415 username: "june",416 url: "https://github.com",417 hasTotp: true,418 },419 ],420 });421 expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();422 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();423 });424425 it("AUTOFILL_QUERY includeAll returns search-ready summaries for non-note, non-passkey entries", async () => {426 const vault = await import("./vault");427 await vault.createVault("correct-pass-12345");428 await vault.addEntry({429 title: "GitHub",430 username: "june",431 password: "secret123",432 url: "https://github.com",433 });434 await vault.addEntry({435 type: "note",436 title: "Secure note",437 notes: "hello",438 });439440 const response = await dispatch({441 type: "AUTOFILL_QUERY",442 payload: { domain: "github.com", includeAll: true },443 });444445 expect(response).toMatchObject({446 ok: true,447 data: [448 {449 id: expect.any(String),450 title: "GitHub",451 username: "june",452 url: "https://github.com",453 hasPasskey: false,454 },455 ],456 });457 const data = response.data as Array<Record<string, unknown>>;458 expect(data).toHaveLength(1);459 460 expect(data[0]).not.toHaveProperty("password");461 expect(data[0]).not.toHaveProperty("totp");462 });463464 it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {465 const vault = await import("./vault");466 await vault.createVault("correct-pass-12345");467 await vault.addEntry({468 title: "GitHub",469 username: "june",470 password: "secret123",471 url: "https://github.com",472 totp: "JBSWY3DPEHPK3PXP",473 });474 await vault.addEntry({475 title: "No 2FA",476 username: "person",477 password: "secret123",478 url: "https://github.com",479 });480481 const response = await dispatch({482 type: "AUTOFILL_QUERY",483 payload: { domain: "github.com", kind: "totp" },484 });485486 expect(response).toMatchObject({487 ok: true,488 data: [489 {490 id: expect.any(String),491 title: "GitHub",492 username: "june",493 url: "https://github.com",494 hasTotp: true,495 },496 ],497 });498 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();499 expect(response.data).toHaveLength(1);500 });501502 it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {503 const vault = await import("./vault");504 await vault.createVault("correct-pass-12345");505 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });506507 const response = await dispatch({508 type: "RESTORE_ENTRIES",509 payload: {510 mode: "replace",511 entries: [512 {513 id: "restored-1",514 type: "login",515 title: "B",516 username: "june",517 password: "two",518 url: "https://b.test",519 createdAt: 1,520 modifiedAt: 1,521 },522 ],523 },524 });525526 expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });527 expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);528 });529530 it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {531 const vault = await import("./vault");532 await vault.createVault("correct-pass-12345");533534 await expect(535 dispatch({536 type: "RESTORE_ENTRIES",537 payload: { mode: "replace", entries: [] },538 })539 ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });540 });541542 it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {543 const vault = await import("./vault");544 await vault.createVault("correct-pass-12345");545546 await expect(547 dispatch({548 type: "CHANGE_PASSWORD",549 payload: {550 currentPassword: "correct-pass-12345",551 newPassword: "new-pass-67890",552 },553 })554 ).resolves.toMatchObject({ ok: true });555556 vault.lock();557 expect(await vault.unlock("new-pass-67890")).toBe(true);558559 await expect(560 dispatch({561 type: "CHANGE_PASSWORD",562 payload: {563 currentPassword: "wrong-pass",564 newPassword: "another-pass",565 },566 })567 ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });568 });569570 it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {571 const vault = await import("./vault");572 await vault.createVault("correct-pass-12345");573574 (575 chrome.alarms.create as unknown as ReturnType<typeof vi.fn>576 ).mockClear();577 (578 chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>579 ).mockClear();580581 await expect(582 dispatch({583 type: "UPDATE_SETTINGS",584 payload: { autoLockMinutes: 30 },585 })586 ).resolves.toMatchObject({ ok: true });587588 expect(vault.getSettings().autoLockMinutes).toBe(30);589 expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);590 expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {591 delayInMinutes: 30,592 });593 });594595 it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {596 vi.useFakeTimers();597 const vault = await import("./vault");598 await vault.createVault("correct-pass-12345");599 const entry = await vault.addEntry({600 title: "GitHub",601 username: "june",602 password: "secret123",603 url: "https://github.com",604 });605 const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);606607 const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });608 await vi.runAllTimersAsync();609 const response = await responsePromise;610611 expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });612 expect(vault.getEntryById(entry.id)?.breach).toBe(7);613 breachSpy.mockRestore();614 });615616 it("rejects passkey approval from a different tab than the ceremony", async () => {617 sessionStorage.passkey_pending_301 = {618 requestId: 301,619 type: "get",620 rpId: "example.com",621 rpName: "Example",622 userName: "[email protected]",623 tabId: 7,624 origin: "https://example.com",625 };626627 await expect(628 dispatch(629 { type: "PASSKEY_AUTH_RESPONSE", payload: { requestId: 301, approved: true } },630 { id: "peach-extension", tab: { id: 8 }, url: "https://example.com/login" } as chrome.runtime.MessageSender,631 ),632 ).resolves.toMatchObject({ ok: false, error: "invalid_passkey_approval_sender" });633 });634635 it("messages from foreign or URL-less senders are rejected", async () => {636 const vault = await import("./vault");637 await vault.createVault("correct-pass-12345");638639 await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({640 ok: false,641 error: "invalid_sender",642 });643644 await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({645 ok: false,646 error: "invalid_sender",647 });648 });649650 it("LOCK and UNLOCK messages update vault state correctly", async () => {651 const vault = await import("./vault");652653 await expect(654 dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })655 ).resolves.toMatchObject({ ok: true });656 expect(vault.isUnlocked()).toBe(true);657658 await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });659 expect(vault.isUnlocked()).toBe(false);660661 await expect(662 dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })663 ).resolves.toMatchObject({ ok: false });664 expect(vault.isUnlocked()).toBe(false);665666 await expect(667 dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })668 ).resolves.toMatchObject({ ok: true });669 expect(vault.isUnlocked()).toBe(true);670 });671672 it("does not acknowledge LOCK until session cleanup finishes", async () => {673 await expect(674 dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })675 ).resolves.toMatchObject({ ok: true });676677 const removalStarted = deferred();678 const releaseRemoval = deferred();679 const remove = chrome.storage.session.remove as unknown as ReturnType<typeof vi.fn>;680 remove.mockImplementation(async (keys: string | string[]) => {681 removalStarted.resolve();682 await releaseRemoval.promise;683 for (const key of Array.isArray(keys) ? keys : [keys]) delete sessionStorage[key];684 });685686 let responded = false;687 const response = dispatch({ type: "LOCK" }).then((result) => {688 responded = true;689 return result;690 });691 await removalStarted.promise;692693 try {694 await Promise.resolve();695 expect(responded).toBe(false);696 } finally {697 releaseRemoval.resolve();698 }699700 await expect(response).resolves.toMatchObject({ ok: true });701 });702703 it("remembers the last successful unlock method", async () => {704 const vault = await import("./vault");705 const password = "correct-pass-12345";706 const popupSender = {707 id: "peach-extension",708 url: "chrome-extension://peach-extension/src/popup/index.html",709 } as chrome.runtime.MessageSender;710711 await vault.createVault(password);712 expect(await vault.setupPinUnlock(password, "1234")).toBe(true);713 vault.lock();714715 await expect(716 dispatch({ type: "UNLOCK_WITH_PIN", payload: { pin: "1234" } }, popupSender),717 ).resolves.toMatchObject({ ok: true });718 expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(true);719720 vault.lock();721 await expect(722 dispatch({ type: "UNLOCK", payload: { password } }, popupSender),723 ).resolves.toMatchObject({ ok: true });724 expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(false);725 });726727 it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {728 const response = await dispatch({729 type: "FIRE_CANARY_PING",730 payload: {731 webhookUrl: "https://example.com/hook",732 entryTitle: "GitHub",733 trigger: "password_copied",734 },735 });736737 expect(response).toMatchObject({ ok: true });738 expect(fetchMock).toHaveBeenCalledWith(739 "https://example.com/hook",740 expect.objectContaining({741 method: "POST",742 headers: { "Content-Type": "application/json" },743 body: expect.any(String),744 })745 );746 const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;747 expect(body).toMatchObject({748 entry: "GitHub",749 trigger: "password_copied",750 source: "peach-extension",751 });752 });753754 it("LV-3: debounces a repeat non-test ping for the same webhook+entry", async () => {755 const payload = {756 webhookUrl: "https://example.com/hook",757 entryTitle: "GitHub",758 trigger: "autofilled" as const,759 };760 const first = await dispatch({ type: "FIRE_CANARY_PING", payload });761 expect(first).toMatchObject({ ok: true });762 expect(fetchMock).toHaveBeenCalledTimes(1);763764 const second = await dispatch({ type: "FIRE_CANARY_PING", payload });765 expect(second).toMatchObject({ ok: true, data: { rateLimited: true } });766 expect(fetchMock).toHaveBeenCalledTimes(1); 767 });768769 it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => {770 await dispatch({771 type: "FIRE_CANARY_PING",772 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },773 });774 await dispatch({775 type: "FIRE_CANARY_PING",776 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" },777 });778 expect(fetchMock).toHaveBeenCalledTimes(2);779 });780781 it("LV-3: test pings are exempt from the debounce", async () => {782 const payload = {783 webhookUrl: "https://example.com/hook",784 entryTitle: "GitHub",785 trigger: "test" as const,786 };787 await dispatch({ type: "FIRE_CANARY_PING", payload });788 await dispatch({ type: "FIRE_CANARY_PING", payload });789 expect(fetchMock).toHaveBeenCalledTimes(2);790 });791792 it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {793 await expect(794 dispatch({795 type: "FIRE_CANARY_PING",796 payload: {797 webhookUrl: "http://example.com/hook",798 entryTitle: "GitHub",799 trigger: "password_copied",800 },801 })802 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });803804 expect(fetchMock).not.toHaveBeenCalled();805 });806807 it("F3: FIRE_CANARY_PING rejects IPv4-mapped IPv6 webhooks pointing at private ranges (Codex Security 2026-08-04)", async () => {808 for (const webhookUrl of [809 "https://[::ffff:192.168.0.1]/hook",810 "https://[::ffff:c0a8:1]/hook",811 "https://[::ffff:10.0.0.1]/hook",812 "https://[::ffff:127.0.0.1]/hook",813 "https://[::ffff:169.254.1.1]/hook",814 "https://[::ffff:172.16.0.1]/hook",815 ]) {816 await expect(817 dispatch({818 type: "FIRE_CANARY_PING",819 payload: { webhookUrl, entryTitle: "GitHub", trigger: "password_copied" },820 })821 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });822 }823824 expect(fetchMock).not.toHaveBeenCalled();825 });826827 it("GET_IDENTITIES when locked returns vault locked error", async () => {828 const vault = await import("./vault");829 await vault.createVault("correct-pass-12345");830 vault.lock();831 await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({832 ok: false,833 error: "vault_locked",834 });835 });836837 it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {838 const vault = await import("./vault");839 await vault.createVault("correct-pass-12345");840 await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });841 await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });842 await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });843844 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };845 expect(resp.ok).toBe(true);846 const data = resp.data as Array<{ value: string; type: string; useCount: number }>;847 expect(data.find((d) => d.value === "[email protected]")).toMatchObject({848 type: "email",849 useCount: 2,850 });851 expect(data.find((d) => d.value === "juni_patel")).toMatchObject({852 type: "username",853 useCount: 1,854 });855 856 const emailIdx = data.findIndex((d) => d.value === "[email protected]");857 const userIdx = data.findIndex((d) => d.value === "juni_patel");858 expect(emailIdx).toBeLessThan(userIdx);859 });860861 it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {862 const vault = await import("./vault");863 await vault.createVault("correct-pass-12345");864 for (let i = 0; i < 3; i++) {865 await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });866 }867868 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };869 const data = resp.data as Array<{ value: string; useCount: number }>;870 const hits = data.filter((d) => d.value === "[email protected]");871 expect(hits).toHaveLength(1);872 expect(hits[0]!.useCount).toBe(3);873 });874875 it("GET_IDENTITIES rejects content-script and page-relay senders", async () => {876 const vault = await import("./vault");877 await vault.createVault("correct-pass-12345");878 await vault.addEntry({879 title: "Private identity",880 username: "[email protected]",881 password: "pw",882 url: "https://private.example",883 });884885 const senders = [886 {887 id: "peach-extension",888 url: "https://private.example/signup",889 tab: { id: 1 } as chrome.tabs.Tab,890 },891 { id: "peach-extension", url: "https://private.example/signup" },892 ] satisfies chrome.runtime.MessageSender[];893 for (const sender of senders) {894 await expect(dispatch({ type: "GET_IDENTITIES" }, sender)).resolves.toMatchObject({895 ok: false,896 error: "invalid_sender",897 });898 }899 });900901 it("IS_CARD_DECLINED does not refresh the auto-lock timer", async () => {902 const vault = await import("./vault");903 await vault.createVault("correct-pass-12345");904 const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;905906 907 alarmsCreate.mockClear();908 await dispatch({ type: "GET_ENTRIES" });909 expect(alarmsCreate).toHaveBeenCalled();910911 912 alarmsCreate.mockClear();913 await dispatch({ type: "IS_CARD_DECLINED", payload: { origin: "https://example.com" } });914 expect(alarmsCreate).not.toHaveBeenCalled();915 });916917 it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {918 const vault = await import("./vault");919 await vault.createVault("correct-pass-12345");920 await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });921922 923 storage.peach_license = "PEACH-PRO-TEST-KEY";924 storage.peach_pro_celebrated = true;925926 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });927 expect(res).toMatchObject({ ok: true });928929 930 expect(storage.peach_vault_a).toBeUndefined();931 932 expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");933 expect(storage.peach_pro_celebrated).toBe(true);934 });935936 it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {937 const messages = await import("./messages");938 const vault = await import("./vault");939940 let resolveGate: () => void = () => {};941 messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));942943 let settled = false;944 const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });945946 947 948 await new Promise((r) => setTimeout(r, 20));949 expect(settled).toBe(false);950951 952 await vault.createVault("correct-pass-12345");953 resolveGate();954955 await expect(pending).resolves.toMatchObject({ ok: true, data: true });956 });957958 it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {959 const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });960 expect(res.ok).toBe(false);961 962 const has = await dispatch({ type: "HAS_VAULT" });963 expect((has as { data?: boolean }).data).toBe(false);964 });965966 it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {967 const vault = await import("./vault");968 await vault.createVault("correct-pass-12345");969 const e = await vault.addEntry({970 title: "Mail",971 username: "june",972 password: "pw",973 url: "https://mail.test",974 totp: "JBSWY3DPEHPK3PXP",975 });976977 const res = await dispatch(978 { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },979 { id: "peach-extension", url: "https://mail.test/login" },980 );981 expect(res.ok).toBe(true);982 const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };983 expect(data.totp).toBeUndefined();984 expect(data.totpCode).toMatch(/^\d{6}$/);985 expect(data.totpRemaining).toBeGreaterThan(0);986 expect(data.totpRemaining).toBeLessThanOrEqual(30);987 });988989 it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {990 const vault = await import("./vault");991 await vault.createVault("correct-pass-12345");992 const e = await vault.addEntry({993 title: "Bank",994 username: "june",995 password: "old-pw",996 url: "https://bank.test",997 });998 await vault.updateEntry(e.id, { password: "new-pw" });9991000 const fill = await dispatch(1001 { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },1002 { id: "peach-extension", url: "https://bank.test/login" },1003 );1004 expect(fill.ok).toBe(true);1005 1006 expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();10071008 const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });1009 expect(edit.ok).toBe(true);1010 const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];1011 expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);1012 });10131014 it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {1015 const vault = await import("./vault");1016 await vault.createVault("correct-pass-12345");1017 const e = await vault.addEntry({1018 title: "Mail",1019 username: "june",1020 password: "pw",1021 url: "https://mail.test",1022 totp: "JBSWY3DPEHPK3PXP",1023 });10241025 const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });1026 expect(res.ok).toBe(true);1027 const data = res.data as { totp?: string };1028 expect(data.totp).toBe("JBSWY3DPEHPK3PXP");1029 });10301031 it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {1032 const vault = await import("./vault");1033 await vault.createVault("correct-pass-12345");10341035 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });1036 expect(res).toMatchObject({ ok: true });10371038 expect(storage.peach_vault_a).toBeUndefined();1039 expect(storage.peach_license).toBeUndefined();1040 expect(storage.peach_pro_celebrated).toBeUndefined();1041 });10421043 10441045 function tabSender(url: string, tabId = 1, frameId = 0): chrome.runtime.MessageSender {1046 return { id: "peach-extension", tab: { id: tabId, url } as chrome.tabs.Tab, url, frameId };1047 }10481049 async function addPasskeyOnlyEntry(title: string, url: string) {1050 const vault = await import("./vault");1051 const generated = await generatePasskeyKeypair(-7);1052 return vault.addEntry({1053 type: "login",1054 title,1055 url,1056 passkey: {1057 rpId: new URL(url).hostname,1058 rpName: title,1059 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1060 publicKeyCose: base64urlEncode(generated.publicKeyCose),1061 privateKeyJwk: generated.privateKeyJwk,1062 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1063 userName: "june",1064 userDisplayName: "June",1065 algorithm: -7,1066 signCount: 0,1067 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",1068 backupEligible: true,1069 backupState: false,1070 createdAt: Date.now(),1071 },1072 });1073 }10741075 it("PB-26-201: normal inventory ignores forged domain and gesture booleans", async () => {1076 const vault = await import("./vault");1077 await vault.createVault("correct-pass-12345");1078 await vault.addEntry({ title: "GitHub", username: "victim", password: "secret", url: "https://github.com" });1079 await vault.addEntry({ title: "Evil", username: "attacker", password: "known", url: "https://evil.example.com" });10801081 const result = await dispatch(1082 {1083 type: "AUTOFILL_QUERY",1084 payload: {1085 domain: "github.com",1086 includeAll: true,1087 userGesture: true,1088 explicitSummon: true,1089 },1090 },1091 tabSender("https://evil.example.com/phish"),1092 );10931094 expect(result).toMatchObject({ ok: true });1095 expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil"]);1096 });10971098 it("PB-26-201: normal TOTP inventory is scoped to the attested frame origin", async () => {1099 const vault = await import("./vault");1100 await vault.createVault("correct-pass-12345");1101 await vault.addEntry({ title: "GitHub 2FA", password: "secret", url: "https://github.com", totp: "JBSWY3DPEHPK3PXP" });1102 await vault.addEntry({ title: "Evil 2FA", password: "known", url: "https://evil.example.com", totp: "JBSWY3DPEHPK3PXP" });11031104 const result = await dispatch(1105 { type: "AUTOFILL_QUERY", payload: { domain: "github.com", kind: "totp" } },1106 tabSender("https://evil.example.com/verify"),1107 );11081109 expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil 2FA"]);1110 });11111112 it("PB-26-201: summon capability is one-shot after inventory authorization", async () => {1113 const vault = await import("./vault");1114 const messages = await import("./messages");1115 await vault.createVault("correct-pass-12345");1116 const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com" });1117 const sender = tabSender("https://evil.example.com/phish");1118 const token = messages.createSummonCapability(1, "https://evil.example.com");11191120 const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1121 expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });11221123 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1124 expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });1125 const replay = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1126 expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1127 });11281129 it("PB-26-201: summon capability is consumed by a same-domain fill", async () => {1130 const vault = await import("./vault");1131 const messages = await import("./messages");1132 await vault.createVault("correct-pass-12345");1133 const entry = await vault.addEntry({ title: "GitHub", password: "secret123", url: "https://evil.example.com" });1134 const sender = tabSender("https://evil.example.com/login");1135 const token = messages.createSummonCapability(1, "https://evil.example.com");11361137 const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1138 expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });1139 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1140 expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });11411142 const offDomainReplay = await dispatch(1143 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1144 tabSender("https://github.com/login"),1145 );1146 expect(offDomainReplay).toMatchObject({ ok: false, error: "entry_not_found" });1147 });11481149 it("PB-26-201: summon capability cannot fill a different entry", async () => {1150 const vault = await import("./vault");1151 const messages = await import("./messages");1152 await vault.createVault("correct-pass-12345");1153 const first = await vault.addEntry({ title: "First", password: "one", url: "https://first.example" });1154 const second = await vault.addEntry({ title: "Second", password: "two", url: "https://second.example" });1155 const sender = tabSender("https://evil.example.com/phish");1156 const token = messages.createSummonCapability(1, "https://evil.example.com");1157 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1158 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: first.id, capability: token } }, sender);1159 expect(firstFill).toMatchObject({ ok: true, data: { id: first.id } });1160 const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: second.id, capability: token } }, sender);1161 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1162 expect(first.id).not.toBe(second.id);1163 });11641165 it("PB-26-201: fill before inventory is rejected without an entry oracle", async () => {1166 const vault = await import("./vault");1167 const messages = await import("./messages");1168 await vault.createVault("correct-pass-12345");1169 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1170 const token = messages.createSummonCapability(1, "https://evil.example.com");1171 const result = await dispatch(1172 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1173 tabSender("https://evil.example.com/phish"),1174 );1175 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1176 });11771178 it("PB-26-201: an unused summon capability does not block a normal same-origin fill", async () => {1179 const vault = await import("./vault");1180 const messages = await import("./messages");1181 await vault.createVault("correct-pass-12345");1182 const entry = await vault.addEntry({1183 title: "Evil",1184 password: "known",1185 url: "https://evil.example.com",1186 });1187 const sender = tabSender("https://evil.example.com/login");1188 const token = messages.createSummonCapability(1, "https://evil.example.com");11891190 const result = await dispatch(1191 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1192 sender,1193 );11941195 expect(result).toMatchObject({ ok: true, data: { id: entry.id } });1196 });11971198 it("PB-26-201: expired fill capability is rejected before entry-specific errors", async () => {1199 vi.useFakeTimers();1200 vi.setSystemTime(new Date("2026-09-02T12:00:00Z"));1201 const vault = await import("./vault");1202 const messages = await import("./messages");1203 await vault.createVault("correct-pass-12345");1204 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1205 const sender = tabSender("https://evil.example.com/phish");1206 const token = messages.createSummonCapability(1, "https://evil.example.com");1207 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1208 vi.advanceTimersByTime(30_001);12091210 const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1211 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1212 });12131214 it("PB-26-201: wrong-tab fill capability is rejected before entry-specific errors", async () => {1215 const vault = await import("./vault");1216 const messages = await import("./messages");1217 await vault.createVault("correct-pass-12345");1218 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1219 const token = messages.createSummonCapability(1, "https://evil.example.com");1220 await dispatch(1221 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1222 tabSender("https://evil.example.com/phish"),1223 );12241225 const result = await dispatch(1226 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1227 tabSender("https://evil.example.com/phish", 2),1228 );1229 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1230 });12311232 it("PB-26-201: wrong-frame fill capability is rejected before entry-specific errors", async () => {1233 const vault = await import("./vault");1234 const messages = await import("./messages");1235 await vault.createVault("correct-pass-12345");1236 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1237 const token = messages.createSummonCapability(1, "https://evil.example.com", 3);1238 await dispatch(1239 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1240 tabSender("https://evil.example.com/phish", 1, 3),1241 );12421243 const result = await dispatch(1244 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1245 tabSender("https://evil.example.com/phish", 1, 4),1246 );1247 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1248 });12491250 it("PB-26-201: wrong-origin fill capability is rejected before entry-specific errors", async () => {1251 const vault = await import("./vault");1252 const messages = await import("./messages");1253 await vault.createVault("correct-pass-12345");1254 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1255 const token = messages.createSummonCapability(1, "https://evil.example.com");1256 await dispatch(1257 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1258 tabSender("https://evil.example.com/phish"),1259 );12601261 const result = await dispatch(1262 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1263 tabSender("https://other.example.com/phish"),1264 );1265 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1266 });12671268 it("PB-26-201: selecting a missing id consumes the one-shot capability without an oracle", async () => {1269 const vault = await import("./vault");1270 const messages = await import("./messages");1271 await vault.createVault("correct-pass-12345");1272 const existing = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1273 const sender = tabSender("https://evil.example.com/phish");1274 const token = messages.createSummonCapability(1, "https://evil.example.com");1275 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);12761277 const missing = await dispatch(1278 { type: "GET_ENTRY_FOR_FILL", payload: { id: "missing", capability: token } },1279 sender,1280 );1281 const existingReplay = await dispatch(1282 { type: "GET_ENTRY_FOR_FILL", payload: { id: existing.id, capability: token } },1283 sender,1284 );1285 expect(missing).toMatchObject({ ok: false, error: "entry_not_found" });1286 expect(existingReplay).toEqual(missing);1287 });12881289 it("PB-26-201: inventory capability cannot be reused for a second inventory", async () => {1290 const vault = await import("./vault");1291 const messages = await import("./messages");1292 await vault.createVault("correct-pass-12345");1293 await vault.addEntry({ title: "GitHub", password: "secret", url: "https://github.com" });1294 const sender = tabSender("https://evil.example.com/phish");1295 const token = messages.createSummonCapability(1, "https://evil.example.com");1296 const first = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1297 const second = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);12981299 expect(first).toMatchObject({ ok: true, data: [{ title: "GitHub" }] });1300 expect(second).toMatchObject({ ok: false, error: "entry_not_found" });1301 });13021303 it("PB-26-201: cards require a capability and consume it on the selected fill", async () => {1304 const vault = await import("./vault");1305 const messages = await import("./messages");1306 await vault.createVault("correct-pass-12345");1307 const card = await vault.addEntry({1308 type: "card",1309 title: "Personal Visa",1310 cardholderName: "June Peach",1311 cardNumber: "4111111111111111",1312 cvc: "123",1313 expMonth: "12",1314 expYear: "2030",1315 });1316 const sender = tabSender("https://shop.example.com/checkout");1317 const passive = await dispatch(1318 { type: "AUTOFILL_QUERY", payload: { domain: "shop.example.com", kind: "card" } },1319 sender,1320 );1321 expect(passive).toMatchObject({ ok: true, data: [] });13221323 const token = messages.createSummonCapability(1, "https://shop.example.com");1324 const inventory = await dispatch(1325 { type: "AUTOFILL_QUERY", payload: { domain: "forged.example", kind: "card", capability: token } },1326 sender,1327 );1328 expect(inventory).toMatchObject({ ok: true, data: [{ id: card.id, title: "Personal Visa" }] });1329 expect((inventory.data as Array<Record<string, unknown>>)[0]).not.toHaveProperty("cardNumber");13301331 const fill = await dispatch(1332 { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1333 sender,1334 );1335 expect(fill).toMatchObject({ ok: true, data: { id: card.id, cardNumber: "4111111111111111" } });1336 const replay = await dispatch(1337 { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1338 sender,1339 );1340 expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1341 });13421343 it("PB-26-201: inline edit popup request stores only the selected entry id", async () => {1344 const result = await dispatch(1345 { type: "OPEN_POPUP", payload: { entryId: "entry-123" } },1346 tabSender("https://example.com/login"),1347 );13481349 expect(result).toMatchObject({ ok: true });1350 expect(sessionStorage.peach_open_entry).toBe("entry-123");1351 expect(sessionStorage.peach_inline_unlock).toBeUndefined();1352 });13531354 it("MV-1: tab request with a matching domain returns the entry", async () => {1355 const vault = await import("./vault");1356 await vault.createVault("correct-pass-12345");1357 const entry = await vault.addEntry({1358 title: "GitHub",1359 username: "june",1360 password: "secret123",1361 url: "https://github.com",1362 });13631364 const res = await dispatch(1365 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1366 tabSender("https://github.com/login"),1367 );1368 expect(res).toMatchObject({ ok: true, data: { id: entry.id } });1369 });13701371 it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {1372 const vault = await import("./vault");1373 await vault.createVault("correct-pass-12345");1374 const entry = await vault.addEntry({1375 title: "GitHub",1376 username: "june",1377 password: "secret123",1378 url: "https://github.com",1379 });13801381 const res = await dispatch(1382 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1383 tabSender("https://evil.example.com/phish"),1384 );1385 1386 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1387 });13881389 it("PB-26-201: explicitSummon cannot authorize off-domain fill", async () => {1390 const vault = await import("./vault");1391 await vault.createVault("correct-pass-12345");1392 const entry = await vault.addEntry({1393 title: "GitHub",1394 username: "june",1395 password: "secret123",1396 url: "https://github.com",1397 });13981399 const res = await dispatch(1400 {1401 type: "GET_ENTRY_FOR_FILL",1402 payload: { id: entry.id, explicitSummon: true },1403 },1404 tabSender("https://evil.example.com/phish"),1405 );1406 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1407 });14081409 it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {1410 const vault = await import("./vault");1411 await vault.createVault("correct-pass-12345");1412 const entry = await vault.addEntry({1413 title: "GitHub",1414 username: "june",1415 password: "secret123",1416 url: "https://github.com",1417 });14181419 const res = await dispatch(1420 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1421 tabSender("not a url"),1422 );1423 expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1424 });14251426 14271428 async function seedTwoDomainVault() {1429 const vault = await import("./vault");1430 await vault.createVault("correct-pass-12345");1431 await vault.addEntry({1432 title: "GitHub",1433 username: "june",1434 password: "gh",1435 url: "https://github.com",1436 });1437 await vault.addEntry({1438 title: "Example",1439 username: "june",1440 password: "ex",1441 url: "https://example.com",1442 });1443 }14441445 it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1446 await seedTwoDomainVault();14471448 const res = await dispatch({1449 type: "AUTOFILL_QUERY",1450 payload: { domain: "github.com", includeAll: true },1451 });1452 expect(res.ok).toBe(true);1453 const data = res.data as Array<{ title: string }>;1454 1455 expect(data).toHaveLength(1);1456 expect(data[0].title).toBe("GitHub");1457 });14581459 it("PB-26-201: userGesture cannot authorize vault-wide inventory", async () => {1460 await seedTwoDomainVault();14611462 const res = await dispatch({1463 type: "AUTOFILL_QUERY",1464 payload: { domain: "github.com", includeAll: true, userGesture: true },1465 });1466 expect(res.ok).toBe(true);1467 const data = res.data as Array<{ title: string }>;1468 expect(data.map((d) => d.title)).toEqual(["GitHub"]);1469 });14701471 14721473 async function seedPendingVouched(deviceId: string) {1474 await chrome.storage.local.set({1475 peach_pending_vouched_devices_v1: [{1476 deviceId,1477 deviceName: "New Pixel",1478 deviceType: "android",1479 longTermPublicKeyHex: "e".repeat(64),1480 signingPublicKeyHex: "a".repeat(64),1481 wsPort: 51234,1482 pairedAt: 1_700_000_000_020,1483 receivedAt: 1_700_000_000_050,1484 vouchedByDeviceId: "extension-issuer",1485 }],1486 });1487 }14881489 it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1490 const vault = await import("./vault");1491 await vault.createVault("correct-pass-12345");1492 await seedPendingVouched("android-new");1493 const { findPairedDeviceById } = await import("./paired-devices");1494 const { listPendingVouchedDevices } = await import(1495 "./pending-vouched-devices"1496 );14971498 const res = await dispatch({1499 type: "CONFIRM_VOUCHED_DEVICE",1500 payload: { deviceId: "android-new" },1501 });1502 expect(res).toMatchObject({ ok: true });15031504 expect(await findPairedDeviceById("android-new")).toMatchObject({1505 deviceName: "New Pixel",1506 trustSource: "vouched",1507 longTermPublicKeyHex: "e".repeat(64),1508 });1509 expect(await listPendingVouchedDevices()).toHaveLength(0);1510 });15111512 it("S1: CONFIRM_VOUCHED_DEVICE refuses a pending device whose key is revoked", async () => {1513 const vault = await import("./vault");1514 await vault.createVault("correct-pass-12345");1515 await seedPendingVouched("android-relabelled");1516 const { findPairedDeviceById, revokeDeviceById, upsertPairedDevice } =1517 await import("./paired-devices");1518 const { listPendingVouchedDevices } = await import(1519 "./pending-vouched-devices"1520 );15211522 1523 1524 await upsertPairedDevice({1525 deviceId: "android-old",1526 deviceName: "New Pixel",1527 deviceType: "android",1528 longTermPublicKeyHex: "e".repeat(64),1529 signingPublicKeyHex: "a".repeat(64),1530 pairedAt: 1_700_000_000_000,1531 trustSource: "vouched",1532 });1533 await revokeDeviceById("android-old", "self");15341535 const res = await dispatch({1536 type: "CONFIRM_VOUCHED_DEVICE",1537 payload: { deviceId: "android-relabelled" },1538 });1539 expect(res).toMatchObject({ ok: false, error: "device_revoked" });1540 expect(await findPairedDeviceById("android-relabelled")).toBeNull();1541 expect(await listPendingVouchedDevices()).toHaveLength(0);1542 });15431544 it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1545 const vault = await import("./vault");1546 await vault.createVault("correct-pass-12345");1547 await seedPendingVouched("android-new");1548 const { findPairedDeviceById } = await import("./paired-devices");1549 const { listPendingVouchedDevices } = await import(1550 "./pending-vouched-devices"1551 );15521553 const res = await dispatch({1554 type: "REJECT_VOUCHED_DEVICE",1555 payload: { deviceId: "android-new" },1556 });1557 expect(res).toMatchObject({ ok: true });15581559 expect(await findPairedDeviceById("android-new")).toBeNull();1560 expect(await listPendingVouchedDevices()).toHaveLength(0);1561 });15621563 15641565 it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => {1566 const vault = await import("./vault");1567 await vault.createVault("correct-pass-12345");1568 const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } =1569 await import("./paired-devices");15701571 await upsertPairedDevice({1572 deviceId: "phone-1",1573 deviceName: "June's Phone",1574 deviceType: "android",1575 longTermPublicKeyHex: "a".repeat(64),1576 signingPublicKeyHex: "b".repeat(64),1577 pairedAt: 1_700_000_000_000,1578 trustSource: "direct",1579 });15801581 const res = await dispatch({1582 type: "UNPAIR_DEVICE",1583 payload: { deviceId: "phone-1" },1584 });1585 expect(res).toMatchObject({ ok: true });15861587 1588 expect(await findPairedDeviceById("phone-1")).toBeNull();1589 1590 1591 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1592 const records = await listDeviceRevocations();1593 expect(records).toHaveLength(1);1594 expect(records[0]).toMatchObject({1595 deviceId: "phone-1",1596 longTermPublicKeyHex: "a".repeat(64),1597 revokedByDeviceId: expect.any(String),1598 });1599 });16001601 it("UNPAIR_DEVICE on an unknown id is a no-op (ok) and writes no revocation", async () => {1602 const vault = await import("./vault");1603 await vault.createVault("correct-pass-12345");1604 const { listDeviceRevocations } = await import("./paired-devices");16051606 const res = await dispatch({1607 type: "UNPAIR_DEVICE",1608 payload: { deviceId: "never-paired" },1609 });1610 expect(res).toMatchObject({ ok: true });1611 1612 1613 1614 1615 const records = await listDeviceRevocations();1616 expect(records).toHaveLength(1);1617 expect(records[0]).toMatchObject({1618 deviceId: "never-paired",1619 longTermPublicKeyHex: "0".repeat(64),1620 });1621 });16221623 it("UNPAIR_DEVICE refuses an empty payload with invalid_payload", async () => {1624 const res = await dispatch({1625 type: "UNPAIR_DEVICE",1626 payload: { deviceId: "" },1627 });1628 expect(res).toMatchObject({ ok: false, error: "invalid_payload" });1629 });16301631 1632 1633 1634 1635 1636 it("F1: privileged handlers reject content-script senders but keep extension-page senders (Codex Security 2026-08-04)", async () => {1637 const vault = await import("./vault");1638 await vault.createVault("correct-pass-12345");1639 const contentScriptSender = {1640 id: "peach-extension",1641 url: "https://evil.example/page",1642 };1643 const extensionPageSender = {1644 id: "peach-extension",1645 url: "chrome-extension://peach-extension/popup.html",1646 };1647 const completion = { requestId: 1, rpId: "evil.example" };16481649 1650 await expect(1651 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, contentScriptSender)1652 ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });16531654 1655 1656 await expect(1657 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1658 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });1659 });16601661 it("F2: COMPLETE_FIREFOX_PASSKEY_CREATE must match the pending ceremony (Codex Security 2026-08-04)", async () => {1662 const vault = await import("./vault");1663 await vault.createVault("correct-pass-12345");1664 const extensionPageSender = {1665 id: "peach-extension",1666 url: "chrome-extension://peach-extension/popup.html",1667 };1668 const completion = {1669 requestId: 123,1670 rpId: "example.com",1671 rpName: "Example",1672 userName: "june",1673 userHandle: "BBBB",1674 credentialId: "cred-1",1675 publicKeyCose: "c3BraQ",1676 algorithm: -7 as const,1677 };16781679 1680 await expect(1681 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1682 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });16831684 1685 sessionStorage.passkey_pending_firefox = {1686 requestId: 123,1687 type: "create",1688 rpId: "example.com",1689 rpName: "Example",1690 userName: "june",1691 browser: "firefox",1692 challenge: "AAAA",1693 userHandle: "BBBB",1694 };16951696 1697 await expect(1698 dispatch(1699 { type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { ...completion, userHandle: "CCCC" } },1700 extensionPageSender1701 )1702 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });17031704 1705 await expect(1706 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1707 ).resolves.toMatchObject({ ok: true });1708 const entries = await dispatch({ type: "GET_ENTRIES" });1709 expect(entries).toMatchObject({ ok: true });1710 const stored = (entries as { data: Array<{ passkey?: { credentialId?: string } }> }).data;1711 expect(stored.some((entry) => entry.passkey?.credentialId === "cred-1")).toBe(true);1712 });17131714 it("F2: INITIATE_FIREFOX_PASSKEY_CREATE rejects an rpId the caller origin does not own (Codex Security 2026-08-04)", async () => {1715 await expect(1716 dispatch(1717 {1718 type: "INITIATE_FIREFOX_PASSKEY_CREATE",1719 payload: { rpId: "bank.example", rpName: "Bank", userName: "june" },1720 },1721 { id: "peach-extension", url: "https://evil.example/page" }1722 )1723 ).resolves.toMatchObject({ ok: false, error: "rpid_origin_mismatch" });1724 });17251726 it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {1727 1728 1729 1730 1731 const vault = await import("./vault");1732 await vault.createVault("correct-pass-12345");1733 const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice } = await import(1734 "./paired-devices"1735 );17361737 await upsertPairedDevice({1738 deviceId: "phone-1",1739 deviceName: "June's Phone",1740 deviceType: "android",1741 longTermPublicKeyHex: "a".repeat(64),1742 signingPublicKeyHex: "b".repeat(64),1743 pairedAt: 1_700_000_000_000,1744 trustSource: "direct",1745 });17461747 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(false);1748 await revokeDeviceById("phone-1", "self");1749 1750 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1751 expect(await isDeviceRevoked("fresh-id", "a".repeat(64))).toBe(true);1752 });17531754 17551756 it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1757 const vault = await import("./vault");1758 await vault.createVault("correct-pass-12345");17591760 const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1761 expect(res).toMatchObject({ ok: false, error: "password_required" });1762 });17631764 it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1765 const vault = await import("./vault");1766 await vault.createVault("correct-pass-12345");17671768 const res = await dispatch({1769 type: "GET_RECOVERY_WORDS",1770 payload: { password: "not-the-password" },1771 });1772 expect(res).toMatchObject({ ok: false, error: "wrong_password" });1773 expect(res.data).toBeUndefined();1774 });17751776 it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1777 const vault = await import("./vault");1778 await vault.createVault("correct-pass-12345");17791780 const res = await dispatch({1781 type: "GET_RECOVERY_WORDS",1782 payload: { password: "correct-pass-12345" },1783 });1784 expect(res.ok).toBe(true);1785 expect((res.data as string[]).length).toBe(12);1786 });17871788 it("GET_PIN_STATUS reports only a session-available PIN wrapper", async () => {1789 const vault = await import("./vault");1790 const password = "correct-pass-12345";1791 const popupSender = {1792 id: "peach-extension",1793 url: "chrome-extension://peach-extension/src/popup/index.html",1794 } as chrome.runtime.MessageSender;17951796 await vault.createVault(password);1797 await vault.setupPinUnlock(password, "1234");1798 await vault.lock();17991800 await expect(dispatch({ type: "GET_PIN_STATUS" }, popupSender)).resolves.toMatchObject({1801 ok: true,1802 data: { configured: true, available: true, attemptsRemaining: 3 },1803 });1804 });1805});