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("PB-26-303: page GET_SETTINGS returns only theme, accent, and its own summon record", async () => {158 storage.peach_settings = {159 theme: "dark",160 accentColor: "peach",161 autoLockMinutes: 5,162 pinEnabled: true,163 manualSummonDomains: {164 "example.com": { firstSummonedAt: 1, summonCount: 2, lastSummonedAt: 3 },165 "other.example": { firstSummonedAt: 4, summonCount: 5, lastSummonedAt: 6 },166 },167 };168 const response = await dispatch(169 { type: "GET_SETTINGS" },170 {171 id: "peach-extension",172 url: "https://login.example.com/form",173 tab: { id: 1 },174 frameId: 0,175 } as chrome.runtime.MessageSender,176 );177178 expect(response).toEqual({179 ok: true,180 data: {181 theme: "dark",182 accentColor: "peach",183 manualSummonDomains: {184 "example.com": { firstSummonedAt: 1, summonCount: 2, lastSummonedAt: 3 },185 },186 },187 });188 await expect(dispatch({ type: "GET_SETTINGS" })).resolves.toMatchObject({189 ok: true,190 data: {191 autoLockMinutes: 5,192 pinEnabled: true,193 manualSummonDomains: {194 "example.com": expect.anything(),195 "other.example": expect.anything(),196 },197 },198 });199 });200201 it("RECORD_MANUAL_SUMMON binds and bounds the sender domain", async () => {202 const sender = {203 id: "peach-extension",204 url: "https://login.example.com/form",205 origin: "https://login.example.com",206 tab: { id: 1 },207 frameId: 0,208 } as chrome.runtime.MessageSender;209210 await expect(dispatch({211 type: "RECORD_MANUAL_SUMMON",212 payload: { domain: "evil.example" },213 }, sender)).resolves.toEqual({ ok: false, error: "invalid_domain" });214 await expect(dispatch({215 type: "RECORD_MANUAL_SUMMON",216 payload: { domain: "example.com" },217 }, sender)).resolves.toEqual({ ok: true });218 await expect(dispatch({219 type: "RECORD_MANUAL_SUMMON",220 payload: { domain: "a".repeat(254) },221 }, { ...sender, origin: "https://a.com", url: "https://a.com/form" })).resolves.toEqual({222 ok: false,223 error: "invalid_domain",224 });225 });226227 it("GET_ENTRIES when unlocked returns the entry array", async () => {228 const vault = await import("./vault");229 await vault.createVault("correct-pass-12345");230 await vault.addEntry({231 title: "GitHub",232 username: "june",233 password: "secret123",234 url: "https://github.com",235 });236237 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({238 ok: true,239 data: [240 expect.objectContaining({241 title: "GitHub",242 username: "june",243 }),244 ],245 });246 });247248 it("GET_ENTRIES when locked returns a vault locked error", async () => {249 const vault = await import("./vault");250 await vault.createVault("correct-pass-12345");251 vault.lock();252253 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({254 ok: false,255 error: "vault_locked",256 });257 });258259 it("ADD_ENTRY with a valid payload returns the created entry", async () => {260 const vault = await import("./vault");261 await vault.createVault("correct-pass-12345");262263 const response = await dispatch({264 type: "ADD_ENTRY",265 payload: {266 title: "GitHub",267 username: "june",268 password: "secret123",269 url: "https://github.com",270 },271 });272273 expect(response).toMatchObject({274 ok: true,275 data: expect.objectContaining({276 id: expect.any(String),277 title: "GitHub",278 }),279 });280 expect(vault.getEntries()).toHaveLength(1);281 });282283 it("ADD_ENTRY when locked returns a vault locked error", async () => {284 const vault = await import("./vault");285 await vault.createVault("correct-pass-12345");286 vault.lock();287288 await expect(289 dispatch({290 type: "ADD_ENTRY",291 payload: {292 title: "GitHub",293 username: "june",294 password: "secret123",295 url: "https://github.com",296 },297 })298 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });299 });300301 it("ADD_ENTRY with no password succeeds for email-only accounts", async () => {302 const vault = await import("./vault");303 await vault.createVault("correct-pass-12345");304305 await expect(306 dispatch({307 type: "ADD_ENTRY",308 payload: {309 title: "GitHub",310 username: "june",311 password: "",312 url: "https://github.com",313 },314 })315 ).resolves.toMatchObject({ ok: true });316 });317318 it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {319 const vault = await import("./vault");320 await vault.createVault("correct-pass-12345");321 const entry = await vault.addEntry({322 title: "GitHub",323 username: "june",324 password: "secret123",325 url: "https://github.com",326 });327328 await expect(329 dispatch({330 type: "UPDATE_ENTRY",331 payload: { id: entry.id, patch: { password: "new-secret" } },332 })333 ).resolves.toMatchObject({ ok: true });334335 expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");336 });337338 it("UPDATE_ENTRY with an empty password clears it for email-only accounts", async () => {339 const vault = await import("./vault");340 await vault.createVault("correct-pass-12345");341 const entry = await vault.addEntry({342 title: "GitHub",343 username: "june",344 password: "secret123",345 url: "https://github.com",346 });347348 await expect(349 dispatch({350 type: "UPDATE_ENTRY",351 payload: { id: entry.id, patch: { password: "" } },352 })353 ).resolves.toMatchObject({ ok: true });354355 expect(vault.getEntryById(entry.id)?.password).toBeUndefined();356 });357358 it("DELETE_ENTRY soft-deletes the target entry", async () => {359 const vault = await import("./vault");360 await vault.createVault("correct-pass-12345");361 const entry = await vault.addEntry({362 title: "GitHub",363 username: "june",364 password: "secret123",365 url: "https://github.com",366 });367368 await expect(369 dispatch({370 type: "DELETE_ENTRY",371 payload: { id: entry.id },372 })373 ).resolves.toMatchObject({ ok: true });374375 expect(vault.getEntries()).toEqual([]);376 expect(vault.getDeletedEntries()).toHaveLength(1);377 });378379 it("DELETE_ENTRY of a missing id returns an error", async () => {380 const vault = await import("./vault");381 await vault.createVault("correct-pass-12345");382383 await expect(384 dispatch({385 type: "DELETE_ENTRY",386 payload: { id: "missing" },387 })388 ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });389 });390391 it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {392 const vault = await import("./vault");393 await vault.createVault("correct-pass-12345");394 const entry = await vault.addEntry({395 title: "GitHub",396 username: "june",397 password: "secret123",398 url: "https://github.com",399 });400401 const response = await dispatch(402 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },403 { id: "peach-extension", url: "https://github.com/login" },404 );405406 expect(response).toMatchObject({407 ok: true,408 data: expect.objectContaining({409 id: entry.id,410 username: "june",411 password: "secret123",412 }),413 });414 expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();415 });416417 it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {418 const vault = await import("./vault");419 await vault.createVault("correct-pass-12345");420 vault.lock();421422 await expect(423 dispatch({424 type: "GET_ENTRY_FOR_FILL",425 payload: { id: "missing" },426 })427 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });428 });429430 it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {431 const vault = await import("./vault");432 await vault.createVault("correct-pass-12345");433 const generated = await generatePasskeyKeypair(-7);434 const entry = await vault.addEntry({435 type: "login",436 title: "WebAuthn.io",437 passkey: {438 rpId: "webauthn.io",439 rpName: "WebAuthn.io",440 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),441 publicKeyCose: base64urlEncode(generated.publicKeyCose),442 privateKeyJwk: generated.privateKeyJwk,443 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),444 userName: "june",445 userDisplayName: "June",446 algorithm: -7,447 signCount: 0,448 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",449 backupEligible: true,450 backupState: false,451 createdAt: Date.now(),452 },453 });454455 await expect(456 dispatch({457 type: "GET_ENTRY_FOR_FILL",458 payload: { id: entry.id },459 })460 ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });461 });462463 it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {464 const vault = await import("./vault");465 await vault.createVault("correct-pass-12345");466 await vault.addEntry({467 title: "GitHub",468 username: "june",469 password: "secret123",470 url: "https://github.com",471 totp: "JBSWY3DPEHPK3PXP",472 });473474 const response = await dispatch({475 type: "AUTOFILL_QUERY",476 payload: { domain: "github.com" },477 });478479 expect(response).toMatchObject({480 ok: true,481 data: [482 {483 id: expect.any(String),484 title: "GitHub",485 username: "june",486 url: "https://github.com",487 hasTotp: true,488 },489 ],490 });491 expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();492 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();493 });494495 it("AUTOFILL_QUERY includeAll returns search-ready summaries for non-note, non-passkey entries", async () => {496 const vault = await import("./vault");497 await vault.createVault("correct-pass-12345");498 await vault.addEntry({499 title: "GitHub",500 username: "june",501 password: "secret123",502 url: "https://github.com",503 });504 await vault.addEntry({505 type: "note",506 title: "Secure note",507 notes: "hello",508 });509510 const response = await dispatch({511 type: "AUTOFILL_QUERY",512 payload: { domain: "github.com", includeAll: true },513 });514515 expect(response).toMatchObject({516 ok: true,517 data: [518 {519 id: expect.any(String),520 title: "GitHub",521 username: "june",522 url: "https://github.com",523 hasPasskey: false,524 },525 ],526 });527 const data = response.data as Array<Record<string, unknown>>;528 expect(data).toHaveLength(1);529 530 expect(data[0]).not.toHaveProperty("password");531 expect(data[0]).not.toHaveProperty("totp");532 });533534 it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {535 const vault = await import("./vault");536 await vault.createVault("correct-pass-12345");537 await vault.addEntry({538 title: "GitHub",539 username: "june",540 password: "secret123",541 url: "https://github.com",542 totp: "JBSWY3DPEHPK3PXP",543 });544 await vault.addEntry({545 title: "No 2FA",546 username: "person",547 password: "secret123",548 url: "https://github.com",549 });550551 const response = await dispatch({552 type: "AUTOFILL_QUERY",553 payload: { domain: "github.com", kind: "totp" },554 });555556 expect(response).toMatchObject({557 ok: true,558 data: [559 {560 id: expect.any(String),561 title: "GitHub",562 username: "june",563 url: "https://github.com",564 hasTotp: true,565 },566 ],567 });568 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();569 expect(response.data).toHaveLength(1);570 });571572 it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {573 const vault = await import("./vault");574 await vault.createVault("correct-pass-12345");575 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });576577 const response = await dispatch({578 type: "RESTORE_ENTRIES",579 payload: {580 mode: "replace",581 entries: [582 {583 id: "restored-1",584 type: "login",585 title: "B",586 username: "june",587 password: "two",588 url: "https://b.test",589 createdAt: 1,590 modifiedAt: 1,591 },592 ],593 },594 });595596 expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });597 expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);598 });599600 it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {601 const vault = await import("./vault");602 await vault.createVault("correct-pass-12345");603604 await expect(605 dispatch({606 type: "RESTORE_ENTRIES",607 payload: { mode: "replace", entries: [] },608 })609 ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });610 });611612 it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {613 const vault = await import("./vault");614 await vault.createVault("correct-pass-12345");615616 await expect(617 dispatch({618 type: "CHANGE_PASSWORD",619 payload: {620 currentPassword: "correct-pass-12345",621 newPassword: "new-pass-67890",622 },623 })624 ).resolves.toMatchObject({ ok: true });625626 vault.lock();627 expect(await vault.unlock("new-pass-67890")).toBe(true);628629 await expect(630 dispatch({631 type: "CHANGE_PASSWORD",632 payload: {633 currentPassword: "wrong-pass",634 newPassword: "another-pass",635 },636 })637 ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });638 });639640 it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {641 const vault = await import("./vault");642 await vault.createVault("correct-pass-12345");643644 (645 chrome.alarms.create as unknown as ReturnType<typeof vi.fn>646 ).mockClear();647 (648 chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>649 ).mockClear();650651 await expect(652 dispatch({653 type: "UPDATE_SETTINGS",654 payload: { autoLockMinutes: 30 },655 })656 ).resolves.toMatchObject({ ok: true });657658 expect(vault.getSettings().autoLockMinutes).toBe(30);659 expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);660 expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {661 delayInMinutes: 30,662 });663 });664665 it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {666 vi.useFakeTimers();667 const vault = await import("./vault");668 await vault.createVault("correct-pass-12345");669 const entry = await vault.addEntry({670 title: "GitHub",671 username: "june",672 password: "secret123",673 url: "https://github.com",674 });675 const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);676677 const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });678 await vi.runAllTimersAsync();679 const response = await responsePromise;680681 expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });682 expect(vault.getEntryById(entry.id)?.breach).toBe(7);683 breachSpy.mockRestore();684 });685686 it("rejects passkey approval from a different tab than the ceremony", async () => {687 sessionStorage.passkey_pending_301 = {688 requestId: 301,689 type: "get",690 rpId: "example.com",691 rpName: "Example",692 userName: "[email protected]",693 tabId: 7,694 origin: "https://example.com",695 };696697 await expect(698 dispatch(699 { type: "PASSKEY_AUTH_RESPONSE", payload: { requestId: 301, approved: true } },700 { id: "peach-extension", tab: { id: 8 }, url: "https://example.com/login" } as chrome.runtime.MessageSender,701 ),702 ).resolves.toMatchObject({ ok: false, error: "invalid_passkey_approval_sender" });703 });704705 it("messages from foreign or URL-less senders are rejected", async () => {706 const vault = await import("./vault");707 await vault.createVault("correct-pass-12345");708709 await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({710 ok: false,711 error: "invalid_sender",712 });713714 await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({715 ok: false,716 error: "invalid_sender",717 });718 });719720 it("LOCK and UNLOCK messages update vault state correctly", async () => {721 const vault = await import("./vault");722723 await expect(724 dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })725 ).resolves.toMatchObject({ ok: true });726 expect(vault.isUnlocked()).toBe(true);727728 await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });729 expect(vault.isUnlocked()).toBe(false);730731 await expect(732 dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })733 ).resolves.toMatchObject({ ok: false });734 expect(vault.isUnlocked()).toBe(false);735736 await expect(737 dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })738 ).resolves.toMatchObject({ ok: true });739 expect(vault.isUnlocked()).toBe(true);740 });741742 it("does not acknowledge LOCK until session cleanup finishes", async () => {743 await expect(744 dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })745 ).resolves.toMatchObject({ ok: true });746747 const removalStarted = deferred();748 const releaseRemoval = deferred();749 const remove = chrome.storage.session.remove as unknown as ReturnType<typeof vi.fn>;750 remove.mockImplementation(async (keys: string | string[]) => {751 removalStarted.resolve();752 await releaseRemoval.promise;753 for (const key of Array.isArray(keys) ? keys : [keys]) delete sessionStorage[key];754 });755756 let responded = false;757 const response = dispatch({ type: "LOCK" }).then((result) => {758 responded = true;759 return result;760 });761 await removalStarted.promise;762763 try {764 await Promise.resolve();765 expect(responded).toBe(false);766 } finally {767 releaseRemoval.resolve();768 }769770 await expect(response).resolves.toMatchObject({ ok: true });771 });772773 it("remembers the last successful unlock method", async () => {774 const vault = await import("./vault");775 const password = "correct-pass-12345";776 const popupSender = {777 id: "peach-extension",778 url: "chrome-extension://peach-extension/src/popup/index.html",779 } as chrome.runtime.MessageSender;780781 await vault.createVault(password);782 expect(await vault.setupPinUnlock(password, "1234")).toBe(true);783 vault.lock();784785 await expect(786 dispatch({ type: "UNLOCK_WITH_PIN", payload: { pin: "1234" } }, popupSender),787 ).resolves.toMatchObject({ ok: true });788 expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(true);789790 vault.lock();791 await expect(792 dispatch({ type: "UNLOCK", payload: { password } }, popupSender),793 ).resolves.toMatchObject({ ok: true });794 expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(false);795 });796797 it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {798 const response = await dispatch({799 type: "FIRE_CANARY_PING",800 payload: {801 webhookUrl: "https://example.com/hook",802 entryTitle: "GitHub",803 trigger: "password_copied",804 },805 });806807 expect(response).toMatchObject({ ok: true });808 expect(fetchMock).toHaveBeenCalledWith(809 "https://example.com/hook",810 expect.objectContaining({811 method: "POST",812 headers: { "Content-Type": "application/json" },813 body: expect.any(String),814 })815 );816 const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;817 expect(body).toMatchObject({818 entry: "GitHub",819 trigger: "password_copied",820 source: "peach-extension",821 });822 });823824 it("LV-3: debounces a repeat non-test ping for the same webhook+entry", async () => {825 const payload = {826 webhookUrl: "https://example.com/hook",827 entryTitle: "GitHub",828 trigger: "autofilled" as const,829 };830 const first = await dispatch({ type: "FIRE_CANARY_PING", payload });831 expect(first).toMatchObject({ ok: true });832 expect(fetchMock).toHaveBeenCalledTimes(1);833834 const second = await dispatch({ type: "FIRE_CANARY_PING", payload });835 expect(second).toMatchObject({ ok: true, data: { rateLimited: true } });836 expect(fetchMock).toHaveBeenCalledTimes(1); 837 });838839 it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => {840 await dispatch({841 type: "FIRE_CANARY_PING",842 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },843 });844 await dispatch({845 type: "FIRE_CANARY_PING",846 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" },847 });848 expect(fetchMock).toHaveBeenCalledTimes(2);849 });850851 it("LV-3: test pings are exempt from the debounce", async () => {852 const payload = {853 webhookUrl: "https://example.com/hook",854 entryTitle: "GitHub",855 trigger: "test" as const,856 };857 await dispatch({ type: "FIRE_CANARY_PING", payload });858 await dispatch({ type: "FIRE_CANARY_PING", payload });859 expect(fetchMock).toHaveBeenCalledTimes(2);860 });861862 it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {863 await expect(864 dispatch({865 type: "FIRE_CANARY_PING",866 payload: {867 webhookUrl: "http://example.com/hook",868 entryTitle: "GitHub",869 trigger: "password_copied",870 },871 })872 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });873874 expect(fetchMock).not.toHaveBeenCalled();875 });876877 it("F3: FIRE_CANARY_PING rejects IPv4-mapped IPv6 webhooks pointing at private ranges (Codex Security 2026-08-04)", async () => {878 for (const webhookUrl of [879 "https://[::ffff:192.168.0.1]/hook",880 "https://[::ffff:c0a8:1]/hook",881 "https://[::ffff:10.0.0.1]/hook",882 "https://[::ffff:127.0.0.1]/hook",883 "https://[::ffff:169.254.1.1]/hook",884 "https://[::ffff:172.16.0.1]/hook",885 ]) {886 await expect(887 dispatch({888 type: "FIRE_CANARY_PING",889 payload: { webhookUrl, entryTitle: "GitHub", trigger: "password_copied" },890 })891 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });892 }893894 expect(fetchMock).not.toHaveBeenCalled();895 });896897 it("GET_IDENTITIES when locked returns vault locked error", async () => {898 const vault = await import("./vault");899 await vault.createVault("correct-pass-12345");900 vault.lock();901 await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({902 ok: false,903 error: "vault_locked",904 });905 });906907 it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {908 const vault = await import("./vault");909 await vault.createVault("correct-pass-12345");910 await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });911 await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });912 await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });913914 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };915 expect(resp.ok).toBe(true);916 const data = resp.data as Array<{ value: string; type: string; useCount: number }>;917 expect(data.find((d) => d.value === "[email protected]")).toMatchObject({918 type: "email",919 useCount: 2,920 });921 expect(data.find((d) => d.value === "juni_patel")).toMatchObject({922 type: "username",923 useCount: 1,924 });925 926 const emailIdx = data.findIndex((d) => d.value === "[email protected]");927 const userIdx = data.findIndex((d) => d.value === "juni_patel");928 expect(emailIdx).toBeLessThan(userIdx);929 });930931 it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {932 const vault = await import("./vault");933 await vault.createVault("correct-pass-12345");934 for (let i = 0; i < 3; i++) {935 await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });936 }937938 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };939 const data = resp.data as Array<{ value: string; useCount: number }>;940 const hits = data.filter((d) => d.value === "[email protected]");941 expect(hits).toHaveLength(1);942 expect(hits[0]!.useCount).toBe(3);943 });944945 it("GET_IDENTITIES rejects content-script and page-relay senders", async () => {946 const vault = await import("./vault");947 await vault.createVault("correct-pass-12345");948 await vault.addEntry({949 title: "Private identity",950 username: "[email protected]",951 password: "pw",952 url: "https://private.example",953 });954955 const senders = [956 {957 id: "peach-extension",958 url: "https://private.example/signup",959 tab: { id: 1 } as chrome.tabs.Tab,960 },961 { id: "peach-extension", url: "https://private.example/signup" },962 ] satisfies chrome.runtime.MessageSender[];963 for (const sender of senders) {964 await expect(dispatch({ type: "GET_IDENTITIES" }, sender)).resolves.toMatchObject({965 ok: false,966 error: "invalid_sender",967 });968 }969 });970971 it("IS_CARD_DECLINED does not refresh the auto-lock timer", async () => {972 const vault = await import("./vault");973 await vault.createVault("correct-pass-12345");974 const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;975976 977 alarmsCreate.mockClear();978 await dispatch({ type: "GET_ENTRIES" });979 expect(alarmsCreate).toHaveBeenCalled();980981 982 alarmsCreate.mockClear();983 await dispatch({ type: "IS_CARD_DECLINED", payload: { origin: "https://example.com" } });984 expect(alarmsCreate).not.toHaveBeenCalled();985 });986987 it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {988 const vault = await import("./vault");989 await vault.createVault("correct-pass-12345");990 await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });991992 993 storage.peach_license = "PEACH-PRO-TEST-KEY";994 storage.peach_pro_celebrated = true;995996 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });997 expect(res).toMatchObject({ ok: true });998999 1000 expect(storage.peach_vault_a).toBeUndefined();1001 1002 expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");1003 expect(storage.peach_pro_celebrated).toBe(true);1004 });10051006 it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {1007 const messages = await import("./messages");1008 const vault = await import("./vault");10091010 let resolveGate: () => void = () => {};1011 messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));10121013 let settled = false;1014 const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });10151016 1017 1018 await new Promise((r) => setTimeout(r, 20));1019 expect(settled).toBe(false);10201021 1022 await vault.createVault("correct-pass-12345");1023 resolveGate();10241025 await expect(pending).resolves.toMatchObject({ ok: true, data: true });1026 });10271028 it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {1029 const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });1030 expect(res.ok).toBe(false);1031 1032 const has = await dispatch({ type: "HAS_VAULT" });1033 expect((has as { data?: boolean }).data).toBe(false);1034 });10351036 it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {1037 const vault = await import("./vault");1038 await vault.createVault("correct-pass-12345");1039 const e = await vault.addEntry({1040 title: "Mail",1041 username: "june",1042 password: "pw",1043 url: "https://mail.test",1044 totp: "JBSWY3DPEHPK3PXP",1045 });10461047 const res = await dispatch(1048 { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },1049 { id: "peach-extension", url: "https://mail.test/login" },1050 );1051 expect(res.ok).toBe(true);1052 const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };1053 expect(data.totp).toBeUndefined();1054 expect(data.totpCode).toMatch(/^\d{6}$/);1055 expect(data.totpRemaining).toBeGreaterThan(0);1056 expect(data.totpRemaining).toBeLessThanOrEqual(30);1057 });10581059 it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {1060 const vault = await import("./vault");1061 await vault.createVault("correct-pass-12345");1062 const e = await vault.addEntry({1063 title: "Bank",1064 username: "june",1065 password: "old-pw",1066 url: "https://bank.test",1067 });1068 await vault.updateEntry(e.id, { password: "new-pw" });10691070 const fill = await dispatch(1071 { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },1072 { id: "peach-extension", url: "https://bank.test/login" },1073 );1074 expect(fill.ok).toBe(true);1075 1076 expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();10771078 const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });1079 expect(edit.ok).toBe(true);1080 const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];1081 expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);1082 });10831084 it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {1085 const vault = await import("./vault");1086 await vault.createVault("correct-pass-12345");1087 const e = await vault.addEntry({1088 title: "Mail",1089 username: "june",1090 password: "pw",1091 url: "https://mail.test",1092 totp: "JBSWY3DPEHPK3PXP",1093 });10941095 const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });1096 expect(res.ok).toBe(true);1097 const data = res.data as { totp?: string };1098 expect(data.totp).toBe("JBSWY3DPEHPK3PXP");1099 });11001101 it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {1102 const vault = await import("./vault");1103 await vault.createVault("correct-pass-12345");11041105 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });1106 expect(res).toMatchObject({ ok: true });11071108 expect(storage.peach_vault_a).toBeUndefined();1109 expect(storage.peach_license).toBeUndefined();1110 expect(storage.peach_pro_celebrated).toBeUndefined();1111 });11121113 11141115 function tabSender(url: string, tabId = 1, frameId = 0): chrome.runtime.MessageSender {1116 return { id: "peach-extension", tab: { id: tabId, url } as chrome.tabs.Tab, url, frameId };1117 }11181119 async function addPasskeyOnlyEntry(title: string, url: string) {1120 const vault = await import("./vault");1121 const generated = await generatePasskeyKeypair(-7);1122 return vault.addEntry({1123 type: "login",1124 title,1125 url,1126 passkey: {1127 rpId: new URL(url).hostname,1128 rpName: title,1129 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1130 publicKeyCose: base64urlEncode(generated.publicKeyCose),1131 privateKeyJwk: generated.privateKeyJwk,1132 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1133 userName: "june",1134 userDisplayName: "June",1135 algorithm: -7,1136 signCount: 0,1137 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",1138 backupEligible: true,1139 backupState: false,1140 createdAt: Date.now(),1141 },1142 });1143 }11441145 it("PB-26-201: normal inventory ignores forged domain and gesture booleans", async () => {1146 const vault = await import("./vault");1147 await vault.createVault("correct-pass-12345");1148 await vault.addEntry({ title: "GitHub", username: "victim", password: "secret", url: "https://github.com" });1149 await vault.addEntry({ title: "Evil", username: "attacker", password: "known", url: "https://evil.example.com" });11501151 const result = await dispatch(1152 {1153 type: "AUTOFILL_QUERY",1154 payload: {1155 domain: "github.com",1156 includeAll: true,1157 userGesture: true,1158 explicitSummon: true,1159 },1160 },1161 tabSender("https://evil.example.com/phish"),1162 );11631164 expect(result).toMatchObject({ ok: true });1165 expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil"]);1166 });11671168 it("PB-26-201: normal TOTP inventory is scoped to the attested frame origin", async () => {1169 const vault = await import("./vault");1170 await vault.createVault("correct-pass-12345");1171 await vault.addEntry({ title: "GitHub 2FA", password: "secret", url: "https://github.com", totp: "JBSWY3DPEHPK3PXP" });1172 await vault.addEntry({ title: "Evil 2FA", password: "known", url: "https://evil.example.com", totp: "JBSWY3DPEHPK3PXP" });11731174 const result = await dispatch(1175 { type: "AUTOFILL_QUERY", payload: { domain: "github.com", kind: "totp" } },1176 tabSender("https://evil.example.com/verify"),1177 );11781179 expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil 2FA"]);1180 });11811182 it("PB-26-201: summon capability is one-shot after inventory authorization", async () => {1183 const vault = await import("./vault");1184 const messages = await import("./messages");1185 await vault.createVault("correct-pass-12345");1186 const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com" });1187 const sender = tabSender("https://evil.example.com/phish");1188 const token = messages.createSummonCapability(1, "https://evil.example.com");11891190 const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1191 expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });11921193 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1194 expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });1195 const replay = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1196 expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1197 });11981199 it("PB-26-201: summon capability is consumed by a same-domain fill", async () => {1200 const vault = await import("./vault");1201 const messages = await import("./messages");1202 await vault.createVault("correct-pass-12345");1203 const entry = await vault.addEntry({ title: "GitHub", password: "secret123", url: "https://evil.example.com" });1204 const sender = tabSender("https://evil.example.com/login");1205 const token = messages.createSummonCapability(1, "https://evil.example.com");12061207 const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1208 expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });1209 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1210 expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });12111212 const offDomainReplay = await dispatch(1213 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1214 tabSender("https://github.com/login"),1215 );1216 expect(offDomainReplay).toMatchObject({ ok: false, error: "entry_not_found" });1217 });12181219 it("PB-26-201: summon capability cannot fill a different entry", async () => {1220 const vault = await import("./vault");1221 const messages = await import("./messages");1222 await vault.createVault("correct-pass-12345");1223 const first = await vault.addEntry({ title: "First", password: "one", url: "https://first.example" });1224 const second = await vault.addEntry({ title: "Second", password: "two", url: "https://second.example" });1225 const sender = tabSender("https://evil.example.com/phish");1226 const token = messages.createSummonCapability(1, "https://evil.example.com");1227 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1228 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: first.id, capability: token } }, sender);1229 expect(firstFill).toMatchObject({ ok: true, data: { id: first.id } });1230 const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: second.id, capability: token } }, sender);1231 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1232 expect(first.id).not.toBe(second.id);1233 });12341235 it("PB-26-201: fill before inventory is rejected without an entry oracle", async () => {1236 const vault = await import("./vault");1237 const messages = await import("./messages");1238 await vault.createVault("correct-pass-12345");1239 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1240 const token = messages.createSummonCapability(1, "https://evil.example.com");1241 const result = await dispatch(1242 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1243 tabSender("https://evil.example.com/phish"),1244 );1245 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1246 });12471248 it("PB-26-201: an unused summon capability does not block a normal same-origin fill", async () => {1249 const vault = await import("./vault");1250 const messages = await import("./messages");1251 await vault.createVault("correct-pass-12345");1252 const entry = await vault.addEntry({1253 title: "Evil",1254 password: "known",1255 url: "https://evil.example.com",1256 });1257 const sender = tabSender("https://evil.example.com/login");1258 const token = messages.createSummonCapability(1, "https://evil.example.com");12591260 const result = await dispatch(1261 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1262 sender,1263 );12641265 expect(result).toMatchObject({ ok: true, data: { id: entry.id } });1266 });12671268 it("PB-26-201: expired fill capability is rejected before entry-specific errors", async () => {1269 vi.useFakeTimers();1270 vi.setSystemTime(new Date("2026-09-02T12:00:00Z"));1271 const vault = await import("./vault");1272 const messages = await import("./messages");1273 await vault.createVault("correct-pass-12345");1274 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1275 const sender = tabSender("https://evil.example.com/phish");1276 const token = messages.createSummonCapability(1, "https://evil.example.com");1277 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1278 vi.advanceTimersByTime(30_001);12791280 const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1281 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1282 });12831284 it("PB-26-201: wrong-tab fill capability is rejected before entry-specific errors", async () => {1285 const vault = await import("./vault");1286 const messages = await import("./messages");1287 await vault.createVault("correct-pass-12345");1288 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1289 const token = messages.createSummonCapability(1, "https://evil.example.com");1290 await dispatch(1291 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1292 tabSender("https://evil.example.com/phish"),1293 );12941295 const result = await dispatch(1296 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1297 tabSender("https://evil.example.com/phish", 2),1298 );1299 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1300 });13011302 it("PB-26-201: wrong-frame fill capability is rejected before entry-specific errors", async () => {1303 const vault = await import("./vault");1304 const messages = await import("./messages");1305 await vault.createVault("correct-pass-12345");1306 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1307 const token = messages.createSummonCapability(1, "https://evil.example.com", 3);1308 await dispatch(1309 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1310 tabSender("https://evil.example.com/phish", 1, 3),1311 );13121313 const result = await dispatch(1314 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1315 tabSender("https://evil.example.com/phish", 1, 4),1316 );1317 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1318 });13191320 it("PB-26-201: wrong-origin fill capability is rejected before entry-specific errors", async () => {1321 const vault = await import("./vault");1322 const messages = await import("./messages");1323 await vault.createVault("correct-pass-12345");1324 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1325 const token = messages.createSummonCapability(1, "https://evil.example.com");1326 await dispatch(1327 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1328 tabSender("https://evil.example.com/phish"),1329 );13301331 const result = await dispatch(1332 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1333 tabSender("https://other.example.com/phish"),1334 );1335 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1336 });13371338 it("PB-26-201: selecting a missing id consumes the one-shot capability without an oracle", async () => {1339 const vault = await import("./vault");1340 const messages = await import("./messages");1341 await vault.createVault("correct-pass-12345");1342 const existing = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1343 const sender = tabSender("https://evil.example.com/phish");1344 const token = messages.createSummonCapability(1, "https://evil.example.com");1345 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);13461347 const missing = await dispatch(1348 { type: "GET_ENTRY_FOR_FILL", payload: { id: "missing", capability: token } },1349 sender,1350 );1351 const existingReplay = await dispatch(1352 { type: "GET_ENTRY_FOR_FILL", payload: { id: existing.id, capability: token } },1353 sender,1354 );1355 expect(missing).toMatchObject({ ok: false, error: "entry_not_found" });1356 expect(existingReplay).toEqual(missing);1357 });13581359 it("PB-26-201: inventory capability cannot be reused for a second inventory", async () => {1360 const vault = await import("./vault");1361 const messages = await import("./messages");1362 await vault.createVault("correct-pass-12345");1363 await vault.addEntry({ title: "GitHub", password: "secret", url: "https://github.com" });1364 const sender = tabSender("https://evil.example.com/phish");1365 const token = messages.createSummonCapability(1, "https://evil.example.com");1366 const first = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1367 const second = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);13681369 expect(first).toMatchObject({ ok: true, data: [{ title: "GitHub" }] });1370 expect(second).toMatchObject({ ok: false, error: "entry_not_found" });1371 });13721373 it("PB-26-201: cards require a capability and consume it on the selected fill", async () => {1374 const vault = await import("./vault");1375 const messages = await import("./messages");1376 await vault.createVault("correct-pass-12345");1377 const card = await vault.addEntry({1378 type: "card",1379 title: "Personal Visa",1380 cardholderName: "June Peach",1381 cardNumber: "4111111111111111",1382 cvc: "123",1383 expMonth: "12",1384 expYear: "2030",1385 });1386 const sender = tabSender("https://shop.example.com/checkout");1387 const passive = await dispatch(1388 { type: "AUTOFILL_QUERY", payload: { domain: "shop.example.com", kind: "card" } },1389 sender,1390 );1391 expect(passive).toMatchObject({ ok: true, data: [] });13921393 const token = messages.createSummonCapability(1, "https://shop.example.com");1394 const inventory = await dispatch(1395 { type: "AUTOFILL_QUERY", payload: { domain: "forged.example", kind: "card", capability: token } },1396 sender,1397 );1398 expect(inventory).toMatchObject({ ok: true, data: [{ id: card.id, title: "Personal Visa" }] });1399 expect((inventory.data as Array<Record<string, unknown>>)[0]).not.toHaveProperty("cardNumber");14001401 const fill = await dispatch(1402 { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1403 sender,1404 );1405 expect(fill).toMatchObject({ ok: true, data: { id: card.id, cardNumber: "4111111111111111" } });1406 const replay = await dispatch(1407 { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1408 sender,1409 );1410 expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1411 });14121413 it("PB-26-201: inline edit popup request stores only the selected entry id", async () => {1414 const result = await dispatch(1415 { type: "OPEN_POPUP", payload: { entryId: "entry-123" } },1416 tabSender("https://example.com/login"),1417 );14181419 expect(result).toMatchObject({ ok: true });1420 expect(sessionStorage.peach_open_entry).toBe("entry-123");1421 expect(sessionStorage.peach_inline_unlock).toBeUndefined();1422 });14231424 it("MV-1: tab request with a matching domain returns the entry", async () => {1425 const vault = await import("./vault");1426 await vault.createVault("correct-pass-12345");1427 const entry = await vault.addEntry({1428 title: "GitHub",1429 username: "june",1430 password: "secret123",1431 url: "https://github.com",1432 });14331434 const res = await dispatch(1435 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1436 tabSender("https://github.com/login"),1437 );1438 expect(res).toMatchObject({ ok: true, data: { id: entry.id } });1439 });14401441 it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {1442 const vault = await import("./vault");1443 await vault.createVault("correct-pass-12345");1444 const entry = await vault.addEntry({1445 title: "GitHub",1446 username: "june",1447 password: "secret123",1448 url: "https://github.com",1449 });14501451 const res = await dispatch(1452 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1453 tabSender("https://evil.example.com/phish"),1454 );1455 1456 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1457 });14581459 it("PB-26-201: explicitSummon cannot authorize off-domain fill", async () => {1460 const vault = await import("./vault");1461 await vault.createVault("correct-pass-12345");1462 const entry = await vault.addEntry({1463 title: "GitHub",1464 username: "june",1465 password: "secret123",1466 url: "https://github.com",1467 });14681469 const res = await dispatch(1470 {1471 type: "GET_ENTRY_FOR_FILL",1472 payload: { id: entry.id, explicitSummon: true },1473 },1474 tabSender("https://evil.example.com/phish"),1475 );1476 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1477 });14781479 it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {1480 const vault = await import("./vault");1481 await vault.createVault("correct-pass-12345");1482 const entry = await vault.addEntry({1483 title: "GitHub",1484 username: "june",1485 password: "secret123",1486 url: "https://github.com",1487 });14881489 const res = await dispatch(1490 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1491 tabSender("not a url"),1492 );1493 expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1494 });14951496 14971498 async function seedTwoDomainVault() {1499 const vault = await import("./vault");1500 await vault.createVault("correct-pass-12345");1501 await vault.addEntry({1502 title: "GitHub",1503 username: "june",1504 password: "gh",1505 url: "https://github.com",1506 });1507 await vault.addEntry({1508 title: "Example",1509 username: "june",1510 password: "ex",1511 url: "https://example.com",1512 });1513 }15141515 it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1516 await seedTwoDomainVault();15171518 const res = await dispatch({1519 type: "AUTOFILL_QUERY",1520 payload: { domain: "github.com", includeAll: true },1521 });1522 expect(res.ok).toBe(true);1523 const data = res.data as Array<{ title: string }>;1524 1525 expect(data).toHaveLength(1);1526 expect(data[0].title).toBe("GitHub");1527 });15281529 it("PB-26-201: userGesture cannot authorize vault-wide inventory", async () => {1530 await seedTwoDomainVault();15311532 const res = await dispatch({1533 type: "AUTOFILL_QUERY",1534 payload: { domain: "github.com", includeAll: true, userGesture: true },1535 });1536 expect(res.ok).toBe(true);1537 const data = res.data as Array<{ title: string }>;1538 expect(data.map((d) => d.title)).toEqual(["GitHub"]);1539 });15401541 15421543 async function seedPendingVouched(deviceId: string) {1544 await chrome.storage.local.set({1545 peach_pending_vouched_devices_v1: [{1546 deviceId,1547 deviceName: "New Pixel",1548 deviceType: "android",1549 longTermPublicKeyHex: "e".repeat(64),1550 signingPublicKeyHex: "a".repeat(64),1551 wsPort: 51234,1552 pairedAt: 1_700_000_000_020,1553 receivedAt: 1_700_000_000_050,1554 vouchedByDeviceId: "extension-issuer",1555 }],1556 });1557 }15581559 it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1560 const vault = await import("./vault");1561 await vault.createVault("correct-pass-12345");1562 await seedPendingVouched("android-new");1563 const { findPairedDeviceById } = await import("./paired-devices");1564 const { listPendingVouchedDevices } = await import(1565 "./pending-vouched-devices"1566 );15671568 const res = await dispatch({1569 type: "CONFIRM_VOUCHED_DEVICE",1570 payload: { deviceId: "android-new" },1571 });1572 expect(res).toMatchObject({ ok: true });15731574 expect(await findPairedDeviceById("android-new")).toMatchObject({1575 deviceName: "New Pixel",1576 trustSource: "vouched",1577 longTermPublicKeyHex: "e".repeat(64),1578 });1579 expect(await listPendingVouchedDevices()).toHaveLength(0);1580 });15811582 it("S1: CONFIRM_VOUCHED_DEVICE refuses a pending device whose key is revoked", async () => {1583 const vault = await import("./vault");1584 await vault.createVault("correct-pass-12345");1585 await seedPendingVouched("android-relabelled");1586 const { findPairedDeviceById, revokeDeviceById, upsertPairedDevice } =1587 await import("./paired-devices");1588 const { listPendingVouchedDevices } = await import(1589 "./pending-vouched-devices"1590 );15911592 1593 1594 await upsertPairedDevice({1595 deviceId: "android-old",1596 deviceName: "New Pixel",1597 deviceType: "android",1598 longTermPublicKeyHex: "e".repeat(64),1599 signingPublicKeyHex: "a".repeat(64),1600 pairedAt: 1_700_000_000_000,1601 trustSource: "vouched",1602 });1603 await revokeDeviceById("android-old", "self");16041605 const res = await dispatch({1606 type: "CONFIRM_VOUCHED_DEVICE",1607 payload: { deviceId: "android-relabelled" },1608 });1609 expect(res).toMatchObject({ ok: false, error: "device_revoked" });1610 expect(await findPairedDeviceById("android-relabelled")).toBeNull();1611 expect(await listPendingVouchedDevices()).toHaveLength(0);1612 });16131614 it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1615 const vault = await import("./vault");1616 await vault.createVault("correct-pass-12345");1617 await seedPendingVouched("android-new");1618 const { findPairedDeviceById } = await import("./paired-devices");1619 const { listPendingVouchedDevices } = await import(1620 "./pending-vouched-devices"1621 );16221623 const res = await dispatch({1624 type: "REJECT_VOUCHED_DEVICE",1625 payload: { deviceId: "android-new" },1626 });1627 expect(res).toMatchObject({ ok: true });16281629 expect(await findPairedDeviceById("android-new")).toBeNull();1630 expect(await listPendingVouchedDevices()).toHaveLength(0);1631 });16321633 16341635 it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => {1636 const vault = await import("./vault");1637 await vault.createVault("correct-pass-12345");1638 const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } =1639 await import("./paired-devices");16401641 await upsertPairedDevice({1642 deviceId: "phone-1",1643 deviceName: "June's Phone",1644 deviceType: "android",1645 longTermPublicKeyHex: "a".repeat(64),1646 signingPublicKeyHex: "b".repeat(64),1647 pairedAt: 1_700_000_000_000,1648 trustSource: "direct",1649 });16501651 const res = await dispatch({1652 type: "UNPAIR_DEVICE",1653 payload: { deviceId: "phone-1" },1654 });1655 expect(res).toMatchObject({ ok: true });16561657 1658 expect(await findPairedDeviceById("phone-1")).toBeNull();1659 1660 1661 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1662 const records = await listDeviceRevocations();1663 expect(records).toHaveLength(1);1664 expect(records[0]).toMatchObject({1665 deviceId: "phone-1",1666 longTermPublicKeyHex: "a".repeat(64),1667 revokedByDeviceId: expect.any(String),1668 });1669 });16701671 it("UNPAIR_DEVICE on an unknown id is a no-op (ok) and writes no revocation", async () => {1672 const vault = await import("./vault");1673 await vault.createVault("correct-pass-12345");1674 const { listDeviceRevocations } = await import("./paired-devices");16751676 const res = await dispatch({1677 type: "UNPAIR_DEVICE",1678 payload: { deviceId: "never-paired" },1679 });1680 expect(res).toMatchObject({ ok: true });1681 1682 1683 1684 1685 const records = await listDeviceRevocations();1686 expect(records).toHaveLength(1);1687 expect(records[0]).toMatchObject({1688 deviceId: "never-paired",1689 longTermPublicKeyHex: "0".repeat(64),1690 });1691 });16921693 it("UNPAIR_DEVICE refuses an empty payload with invalid_payload", async () => {1694 const res = await dispatch({1695 type: "UNPAIR_DEVICE",1696 payload: { deviceId: "" },1697 });1698 expect(res).toMatchObject({ ok: false, error: "invalid_payload" });1699 });17001701 1702 1703 1704 1705 1706 it("F1: privileged handlers reject content-script senders but keep extension-page senders (Codex Security 2026-08-04)", async () => {1707 const vault = await import("./vault");1708 await vault.createVault("correct-pass-12345");1709 const contentScriptSender = {1710 id: "peach-extension",1711 url: "https://evil.example/page",1712 };1713 const extensionPageSender = {1714 id: "peach-extension",1715 url: "chrome-extension://peach-extension/popup.html",1716 };1717 const completion = { requestId: 1, rpId: "evil.example" };17181719 1720 await expect(1721 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, contentScriptSender)1722 ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });17231724 1725 1726 await expect(1727 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1728 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });1729 });17301731 it("F2: COMPLETE_FIREFOX_PASSKEY_CREATE must match the pending ceremony (Codex Security 2026-08-04)", async () => {1732 const vault = await import("./vault");1733 await vault.createVault("correct-pass-12345");1734 const extensionPageSender = {1735 id: "peach-extension",1736 url: "chrome-extension://peach-extension/popup.html",1737 };1738 const completion = {1739 requestId: 123,1740 rpId: "example.com",1741 rpName: "Example",1742 userName: "june",1743 userHandle: "BBBB",1744 credentialId: "cred-1",1745 publicKeyCose: "c3BraQ",1746 algorithm: -7 as const,1747 };17481749 1750 await expect(1751 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1752 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });17531754 1755 sessionStorage.passkey_pending_firefox = {1756 requestId: 123,1757 type: "create",1758 rpId: "example.com",1759 rpName: "Example",1760 userName: "june",1761 browser: "firefox",1762 challenge: "AAAA",1763 userHandle: "BBBB",1764 };17651766 1767 await expect(1768 dispatch(1769 { type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { ...completion, userHandle: "CCCC" } },1770 extensionPageSender1771 )1772 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });17731774 1775 await expect(1776 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1777 ).resolves.toMatchObject({ ok: true });1778 const entries = await dispatch({ type: "GET_ENTRIES" });1779 expect(entries).toMatchObject({ ok: true });1780 const stored = (entries as { data: Array<{ passkey?: { credentialId?: string } }> }).data;1781 expect(stored.some((entry) => entry.passkey?.credentialId === "cred-1")).toBe(true);1782 });17831784 it("F2: INITIATE_FIREFOX_PASSKEY_CREATE rejects an rpId the caller origin does not own (Codex Security 2026-08-04)", async () => {1785 await expect(1786 dispatch(1787 {1788 type: "INITIATE_FIREFOX_PASSKEY_CREATE",1789 payload: { rpId: "bank.example", rpName: "Bank", userName: "june" },1790 },1791 { id: "peach-extension", url: "https://evil.example/page" }1792 )1793 ).resolves.toMatchObject({ ok: false, error: "rpid_origin_mismatch" });1794 });17951796 it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {1797 1798 1799 1800 1801 const vault = await import("./vault");1802 await vault.createVault("correct-pass-12345");1803 const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice } = await import(1804 "./paired-devices"1805 );18061807 await upsertPairedDevice({1808 deviceId: "phone-1",1809 deviceName: "June's Phone",1810 deviceType: "android",1811 longTermPublicKeyHex: "a".repeat(64),1812 signingPublicKeyHex: "b".repeat(64),1813 pairedAt: 1_700_000_000_000,1814 trustSource: "direct",1815 });18161817 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(false);1818 await revokeDeviceById("phone-1", "self");1819 1820 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1821 expect(await isDeviceRevoked("fresh-id", "a".repeat(64))).toBe(true);1822 });18231824 18251826 it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1827 const vault = await import("./vault");1828 await vault.createVault("correct-pass-12345");18291830 const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1831 expect(res).toMatchObject({ ok: false, error: "password_required" });1832 });18331834 it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1835 const vault = await import("./vault");1836 await vault.createVault("correct-pass-12345");18371838 const res = await dispatch({1839 type: "GET_RECOVERY_WORDS",1840 payload: { password: "not-the-password" },1841 });1842 expect(res).toMatchObject({ ok: false, error: "wrong_password" });1843 expect(res.data).toBeUndefined();1844 });18451846 it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1847 const vault = await import("./vault");1848 await vault.createVault("correct-pass-12345");18491850 const res = await dispatch({1851 type: "GET_RECOVERY_WORDS",1852 payload: { password: "correct-pass-12345" },1853 });1854 expect(res.ok).toBe(true);1855 expect((res.data as string[]).length).toBe(12);1856 });18571858 it("PB-26-311: cleartext export requires a fresh master-password proof", async () => {1859 const vault = await import("./vault");1860 await vault.createVault("correct-pass-12345");18611862 await expect(1863 dispatch({ type: "EXPORT_ENTRIES" as never, payload: {} }),1864 ).resolves.toMatchObject({ ok: false, error: "password_required" });1865 await expect(1866 dispatch({1867 type: "EXPORT_ENTRIES" as never,1868 payload: { password: "not-the-password" },1869 }),1870 ).resolves.toMatchObject({ ok: false, error: "wrong_password" });1871 await expect(1872 dispatch({1873 type: "EXPORT_ENTRIES" as never,1874 payload: { password: "correct-pass-12345" },1875 }),1876 ).resolves.toMatchObject({ ok: true, data: [] });1877 });18781879 it("GET_PIN_STATUS reports only a session-available PIN wrapper", async () => {1880 const vault = await import("./vault");1881 const password = "correct-pass-12345";1882 const popupSender = {1883 id: "peach-extension",1884 url: "chrome-extension://peach-extension/src/popup/index.html",1885 } as chrome.runtime.MessageSender;18861887 await vault.createVault(password);1888 await vault.setupPinUnlock(password, "1234");1889 await vault.lock();18901891 await expect(dispatch({ type: "GET_PIN_STATUS" }, popupSender)).resolves.toMatchObject({1892 ok: true,1893 data: { configured: true, available: true, attemptsRemaining: 3 },1894 });1895 });1896});