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);833 expect(Object.keys(storage).some((key) => key.includes("GitHub"))).toBe(false);834 expect(Object.keys(storage).some((key) => key.startsWith("canary-last-fetch:"))).toBe(true);835836 const second = await dispatch({ type: "FIRE_CANARY_PING", payload });837 expect(second).toMatchObject({ ok: true, data: { rateLimited: true } });838 expect(fetchMock).toHaveBeenCalledTimes(1); 839 });840841 it("LV-3: removes legacy plaintext rate-limit keys", async () => {842 const legacyKey = "canary-last-fetch:https://example.com/hook:GitHub";843 storage[legacyKey] = Date.now();844845 await dispatch({846 type: "FIRE_CANARY_PING",847 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },848 });849850 expect(storage[legacyKey]).toBeUndefined();851 });852853 it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => {854 await dispatch({855 type: "FIRE_CANARY_PING",856 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },857 });858 await dispatch({859 type: "FIRE_CANARY_PING",860 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" },861 });862 expect(fetchMock).toHaveBeenCalledTimes(2);863 });864865 it("LV-3: test pings are exempt from the debounce", async () => {866 const payload = {867 webhookUrl: "https://example.com/hook",868 entryTitle: "GitHub",869 trigger: "test" as const,870 };871 await dispatch({ type: "FIRE_CANARY_PING", payload });872 await dispatch({ type: "FIRE_CANARY_PING", payload });873 expect(fetchMock).toHaveBeenCalledTimes(2);874 });875876 it("rejects test pings from content scripts before contacting the webhook", async () => {877 const response = await dispatch(878 {879 type: "FIRE_CANARY_PING",880 payload: {881 webhookUrl: "https://example.com/hook",882 entryTitle: "GitHub",883 trigger: "test",884 },885 },886 {887 id: "peach-extension",888 url: "https://example.com/login",889 tab: { id: 7 },890 } as chrome.runtime.MessageSender,891 );892893 expect(response).toMatchObject({ ok: false, error: "invalid_sender" });894 expect(fetchMock).not.toHaveBeenCalled();895 });896897 it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {898 await expect(899 dispatch({900 type: "FIRE_CANARY_PING",901 payload: {902 webhookUrl: "http://example.com/hook",903 entryTitle: "GitHub",904 trigger: "password_copied",905 },906 })907 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });908909 expect(fetchMock).not.toHaveBeenCalled();910 });911912 it("F3: FIRE_CANARY_PING rejects IPv4-mapped IPv6 webhooks pointing at private ranges (Codex Security 2026-08-04)", async () => {913 for (const webhookUrl of [914 "https://[::ffff:192.168.0.1]/hook",915 "https://[::ffff:c0a8:1]/hook",916 "https://[::ffff:10.0.0.1]/hook",917 "https://[::ffff:127.0.0.1]/hook",918 "https://[::ffff:169.254.1.1]/hook",919 "https://[::ffff:172.16.0.1]/hook",920 ]) {921 await expect(922 dispatch({923 type: "FIRE_CANARY_PING",924 payload: { webhookUrl, entryTitle: "GitHub", trigger: "password_copied" },925 })926 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });927 }928929 expect(fetchMock).not.toHaveBeenCalled();930 });931932 it("GET_IDENTITIES when locked returns vault locked error", async () => {933 const vault = await import("./vault");934 await vault.createVault("correct-pass-12345");935 vault.lock();936 await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({937 ok: false,938 error: "vault_locked",939 });940 });941942 it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {943 const vault = await import("./vault");944 await vault.createVault("correct-pass-12345");945 await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });946 await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });947 await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });948949 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };950 expect(resp.ok).toBe(true);951 const data = resp.data as Array<{ value: string; type: string; useCount: number }>;952 expect(data.find((d) => d.value === "[email protected]")).toMatchObject({953 type: "email",954 useCount: 2,955 });956 expect(data.find((d) => d.value === "juni_patel")).toMatchObject({957 type: "username",958 useCount: 1,959 });960 961 const emailIdx = data.findIndex((d) => d.value === "[email protected]");962 const userIdx = data.findIndex((d) => d.value === "juni_patel");963 expect(emailIdx).toBeLessThan(userIdx);964 });965966 it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {967 const vault = await import("./vault");968 await vault.createVault("correct-pass-12345");969 for (let i = 0; i < 3; i++) {970 await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });971 }972973 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };974 const data = resp.data as Array<{ value: string; useCount: number }>;975 const hits = data.filter((d) => d.value === "[email protected]");976 expect(hits).toHaveLength(1);977 expect(hits[0]!.useCount).toBe(3);978 });979980 it("GET_IDENTITIES rejects content-script and page-relay senders", async () => {981 const vault = await import("./vault");982 await vault.createVault("correct-pass-12345");983 await vault.addEntry({984 title: "Private identity",985 username: "[email protected]",986 password: "pw",987 url: "https://private.example",988 });989990 const senders = [991 {992 id: "peach-extension",993 url: "https://private.example/signup",994 tab: { id: 1 } as chrome.tabs.Tab,995 },996 { id: "peach-extension", url: "https://private.example/signup" },997 ] satisfies chrome.runtime.MessageSender[];998 for (const sender of senders) {999 await expect(dispatch({ type: "GET_IDENTITIES" }, sender)).resolves.toMatchObject({1000 ok: false,1001 error: "invalid_sender",1002 });1003 }1004 });10051006 it("IS_CARD_DECLINED does not refresh the auto-lock timer", async () => {1007 const vault = await import("./vault");1008 await vault.createVault("correct-pass-12345");1009 const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;10101011 1012 alarmsCreate.mockClear();1013 await dispatch({ type: "GET_ENTRIES" });1014 expect(alarmsCreate).toHaveBeenCalled();10151016 1017 alarmsCreate.mockClear();1018 await dispatch({ type: "IS_CARD_DECLINED", payload: { origin: "https://example.com" } });1019 expect(alarmsCreate).not.toHaveBeenCalled();1020 });10211022 it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {1023 const vault = await import("./vault");1024 await vault.createVault("correct-pass-12345");1025 await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });10261027 1028 storage.peach_license = "PEACH-PRO-TEST-KEY";1029 storage.peach_pro_celebrated = true;10301031 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });1032 expect(res).toMatchObject({ ok: true });10331034 1035 expect(storage.peach_vault_a).toBeUndefined();1036 1037 expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");1038 expect(storage.peach_pro_celebrated).toBe(true);1039 });10401041 it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {1042 const messages = await import("./messages");1043 const vault = await import("./vault");10441045 let resolveGate: () => void = () => {};1046 messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));10471048 let settled = false;1049 const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });10501051 1052 1053 await new Promise((r) => setTimeout(r, 20));1054 expect(settled).toBe(false);10551056 1057 await vault.createVault("correct-pass-12345");1058 resolveGate();10591060 await expect(pending).resolves.toMatchObject({ ok: true, data: true });1061 });10621063 it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {1064 const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });1065 expect(res.ok).toBe(false);1066 1067 const has = await dispatch({ type: "HAS_VAULT" });1068 expect((has as { data?: boolean }).data).toBe(false);1069 });10701071 it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {1072 const vault = await import("./vault");1073 await vault.createVault("correct-pass-12345");1074 const e = await vault.addEntry({1075 title: "Mail",1076 username: "june",1077 password: "pw",1078 url: "https://mail.test",1079 totp: "JBSWY3DPEHPK3PXP",1080 });10811082 const res = await dispatch(1083 { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },1084 { id: "peach-extension", url: "https://mail.test/login" },1085 );1086 expect(res.ok).toBe(true);1087 const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };1088 expect(data.totp).toBeUndefined();1089 expect(data.totpCode).toMatch(/^\d{6}$/);1090 expect(data.totpRemaining).toBeGreaterThan(0);1091 expect(data.totpRemaining).toBeLessThanOrEqual(30);1092 });10931094 it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {1095 const vault = await import("./vault");1096 await vault.createVault("correct-pass-12345");1097 const e = await vault.addEntry({1098 title: "Bank",1099 username: "june",1100 password: "old-pw",1101 url: "https://bank.test",1102 });1103 await vault.updateEntry(e.id, { password: "new-pw" });11041105 const fill = await dispatch(1106 { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },1107 { id: "peach-extension", url: "https://bank.test/login" },1108 );1109 expect(fill.ok).toBe(true);1110 1111 expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();11121113 const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });1114 expect(edit.ok).toBe(true);1115 const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];1116 expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);1117 });11181119 it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {1120 const vault = await import("./vault");1121 await vault.createVault("correct-pass-12345");1122 const e = await vault.addEntry({1123 title: "Mail",1124 username: "june",1125 password: "pw",1126 url: "https://mail.test",1127 totp: "JBSWY3DPEHPK3PXP",1128 });11291130 const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });1131 expect(res.ok).toBe(true);1132 const data = res.data as { totp?: string };1133 expect(data.totp).toBe("JBSWY3DPEHPK3PXP");1134 });11351136 it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {1137 const vault = await import("./vault");1138 await vault.createVault("correct-pass-12345");11391140 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });1141 expect(res).toMatchObject({ ok: true });11421143 expect(storage.peach_vault_a).toBeUndefined();1144 expect(storage.peach_license).toBeUndefined();1145 expect(storage.peach_pro_celebrated).toBeUndefined();1146 });11471148 11491150 function tabSender(url: string, tabId = 1, frameId = 0): chrome.runtime.MessageSender {1151 return { id: "peach-extension", tab: { id: tabId, url } as chrome.tabs.Tab, url, frameId };1152 }11531154 async function addPasskeyOnlyEntry(title: string, url: string) {1155 const vault = await import("./vault");1156 const generated = await generatePasskeyKeypair(-7);1157 return vault.addEntry({1158 type: "login",1159 title,1160 url,1161 passkey: {1162 rpId: new URL(url).hostname,1163 rpName: title,1164 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1165 publicKeyCose: base64urlEncode(generated.publicKeyCose),1166 privateKeyJwk: generated.privateKeyJwk,1167 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1168 userName: "june",1169 userDisplayName: "June",1170 algorithm: -7,1171 signCount: 0,1172 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",1173 backupEligible: true,1174 backupState: false,1175 createdAt: Date.now(),1176 },1177 });1178 }11791180 it("PB-26-201: normal inventory ignores forged domain and gesture booleans", async () => {1181 const vault = await import("./vault");1182 await vault.createVault("correct-pass-12345");1183 await vault.addEntry({ title: "GitHub", username: "victim", password: "secret", url: "https://github.com" });1184 await vault.addEntry({ title: "Evil", username: "attacker", password: "known", url: "https://evil.example.com" });11851186 const result = await dispatch(1187 {1188 type: "AUTOFILL_QUERY",1189 payload: {1190 domain: "github.com",1191 includeAll: true,1192 userGesture: true,1193 explicitSummon: true,1194 },1195 },1196 tabSender("https://evil.example.com/phish"),1197 );11981199 expect(result).toMatchObject({ ok: true });1200 expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil"]);1201 });12021203 it("PB-26-201: normal TOTP inventory is scoped to the attested frame origin", async () => {1204 const vault = await import("./vault");1205 await vault.createVault("correct-pass-12345");1206 await vault.addEntry({ title: "GitHub 2FA", password: "secret", url: "https://github.com", totp: "JBSWY3DPEHPK3PXP" });1207 await vault.addEntry({ title: "Evil 2FA", password: "known", url: "https://evil.example.com", totp: "JBSWY3DPEHPK3PXP" });12081209 const result = await dispatch(1210 { type: "AUTOFILL_QUERY", payload: { domain: "github.com", kind: "totp" } },1211 tabSender("https://evil.example.com/verify"),1212 );12131214 expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil 2FA"]);1215 });12161217 it("PB-26-201: summon capability is one-shot after inventory authorization", async () => {1218 const vault = await import("./vault");1219 const messages = await import("./messages");1220 await vault.createVault("correct-pass-12345");1221 const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com" });1222 const sender = tabSender("https://evil.example.com/phish");1223 const token = messages.createSummonCapability(1, "https://evil.example.com");12241225 const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1226 expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });12271228 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1229 expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });1230 const replay = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1231 expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1232 });12331234 it("PB-26-201: summon capability is consumed by a same-domain fill", async () => {1235 const vault = await import("./vault");1236 const messages = await import("./messages");1237 await vault.createVault("correct-pass-12345");1238 const entry = await vault.addEntry({ title: "GitHub", password: "secret123", url: "https://evil.example.com" });1239 const sender = tabSender("https://evil.example.com/login");1240 const token = messages.createSummonCapability(1, "https://evil.example.com");12411242 const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1243 expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });1244 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1245 expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });12461247 const offDomainReplay = await dispatch(1248 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1249 tabSender("https://github.com/login"),1250 );1251 expect(offDomainReplay).toMatchObject({ ok: false, error: "entry_not_found" });1252 });12531254 it("PB-26-201: summon capability cannot fill a different entry", async () => {1255 const vault = await import("./vault");1256 const messages = await import("./messages");1257 await vault.createVault("correct-pass-12345");1258 const first = await vault.addEntry({ title: "First", password: "one", url: "https://first.example" });1259 const second = await vault.addEntry({ title: "Second", password: "two", url: "https://second.example" });1260 const sender = tabSender("https://evil.example.com/phish");1261 const token = messages.createSummonCapability(1, "https://evil.example.com");1262 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1263 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: first.id, capability: token } }, sender);1264 expect(firstFill).toMatchObject({ ok: true, data: { id: first.id } });1265 const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: second.id, capability: token } }, sender);1266 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1267 expect(first.id).not.toBe(second.id);1268 });12691270 it("PB-26-201: fill before inventory is rejected without an entry oracle", async () => {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 token = messages.createSummonCapability(1, "https://evil.example.com");1276 const result = await dispatch(1277 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1278 tabSender("https://evil.example.com/phish"),1279 );1280 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1281 });12821283 it("PB-26-201: an unused summon capability does not block a normal same-origin fill", async () => {1284 const vault = await import("./vault");1285 const messages = await import("./messages");1286 await vault.createVault("correct-pass-12345");1287 const entry = await vault.addEntry({1288 title: "Evil",1289 password: "known",1290 url: "https://evil.example.com",1291 });1292 const sender = tabSender("https://evil.example.com/login");1293 const token = messages.createSummonCapability(1, "https://evil.example.com");12941295 const result = await dispatch(1296 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1297 sender,1298 );12991300 expect(result).toMatchObject({ ok: true, data: { id: entry.id } });1301 });13021303 it("PB-26-201: expired fill capability is rejected before entry-specific errors", async () => {1304 vi.useFakeTimers();1305 vi.setSystemTime(new Date("2026-09-02T12:00:00Z"));1306 const vault = await import("./vault");1307 const messages = await import("./messages");1308 await vault.createVault("correct-pass-12345");1309 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1310 const sender = tabSender("https://evil.example.com/phish");1311 const token = messages.createSummonCapability(1, "https://evil.example.com");1312 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1313 vi.advanceTimersByTime(30_001);13141315 const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1316 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1317 });13181319 it("PB-26-201: wrong-tab fill capability is rejected before entry-specific errors", async () => {1320 const vault = await import("./vault");1321 const messages = await import("./messages");1322 await vault.createVault("correct-pass-12345");1323 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1324 const token = messages.createSummonCapability(1, "https://evil.example.com");1325 await dispatch(1326 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1327 tabSender("https://evil.example.com/phish"),1328 );13291330 const result = await dispatch(1331 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1332 tabSender("https://evil.example.com/phish", 2),1333 );1334 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1335 });13361337 it("PB-26-201: wrong-frame fill capability is rejected before entry-specific errors", async () => {1338 const vault = await import("./vault");1339 const messages = await import("./messages");1340 await vault.createVault("correct-pass-12345");1341 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1342 const token = messages.createSummonCapability(1, "https://evil.example.com", 3);1343 await dispatch(1344 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1345 tabSender("https://evil.example.com/phish", 1, 3),1346 );13471348 const result = await dispatch(1349 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1350 tabSender("https://evil.example.com/phish", 1, 4),1351 );1352 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1353 });13541355 it("PB-26-201: wrong-origin fill capability is rejected before entry-specific errors", async () => {1356 const vault = await import("./vault");1357 const messages = await import("./messages");1358 await vault.createVault("correct-pass-12345");1359 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1360 const token = messages.createSummonCapability(1, "https://evil.example.com");1361 await dispatch(1362 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1363 tabSender("https://evil.example.com/phish"),1364 );13651366 const result = await dispatch(1367 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1368 tabSender("https://other.example.com/phish"),1369 );1370 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1371 });13721373 it("PB-26-201: selecting a missing id consumes the one-shot capability without an oracle", async () => {1374 const vault = await import("./vault");1375 const messages = await import("./messages");1376 await vault.createVault("correct-pass-12345");1377 const existing = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1378 const sender = tabSender("https://evil.example.com/phish");1379 const token = messages.createSummonCapability(1, "https://evil.example.com");1380 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);13811382 const missing = await dispatch(1383 { type: "GET_ENTRY_FOR_FILL", payload: { id: "missing", capability: token } },1384 sender,1385 );1386 const existingReplay = await dispatch(1387 { type: "GET_ENTRY_FOR_FILL", payload: { id: existing.id, capability: token } },1388 sender,1389 );1390 expect(missing).toMatchObject({ ok: false, error: "entry_not_found" });1391 expect(existingReplay).toEqual(missing);1392 });13931394 it("PB-26-201: inventory capability cannot be reused for a second inventory", async () => {1395 const vault = await import("./vault");1396 const messages = await import("./messages");1397 await vault.createVault("correct-pass-12345");1398 await vault.addEntry({ title: "GitHub", password: "secret", url: "https://github.com" });1399 const sender = tabSender("https://evil.example.com/phish");1400 const token = messages.createSummonCapability(1, "https://evil.example.com");1401 const first = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1402 const second = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);14031404 expect(first).toMatchObject({ ok: true, data: [{ title: "GitHub" }] });1405 expect(second).toMatchObject({ ok: false, error: "entry_not_found" });1406 });14071408 it("PB-26-201: cards require a capability and consume it on the selected fill", async () => {1409 const vault = await import("./vault");1410 const messages = await import("./messages");1411 await vault.createVault("correct-pass-12345");1412 const card = await vault.addEntry({1413 type: "card",1414 title: "Personal Visa",1415 cardholderName: "June Peach",1416 cardNumber: "4111111111111111",1417 cvc: "123",1418 expMonth: "12",1419 expYear: "2030",1420 });1421 const sender = tabSender("https://shop.example.com/checkout");1422 const passive = await dispatch(1423 { type: "AUTOFILL_QUERY", payload: { domain: "shop.example.com", kind: "card" } },1424 sender,1425 );1426 expect(passive).toMatchObject({ ok: true, data: [] });14271428 const token = messages.createSummonCapability(1, "https://shop.example.com");1429 const inventory = await dispatch(1430 { type: "AUTOFILL_QUERY", payload: { domain: "forged.example", kind: "card", capability: token } },1431 sender,1432 );1433 expect(inventory).toMatchObject({ ok: true, data: [{ id: card.id, title: "Personal Visa" }] });1434 expect((inventory.data as Array<Record<string, unknown>>)[0]).not.toHaveProperty("cardNumber");14351436 const fill = await dispatch(1437 { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1438 sender,1439 );1440 expect(fill).toMatchObject({ ok: true, data: { id: card.id, cardNumber: "4111111111111111" } });1441 const replay = await dispatch(1442 { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1443 sender,1444 );1445 expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1446 });14471448 it("PB-26-201: inline edit popup request stores only the selected entry id", async () => {1449 const result = await dispatch(1450 { type: "OPEN_POPUP", payload: { entryId: "entry-123" } },1451 tabSender("https://example.com/login"),1452 );14531454 expect(result).toMatchObject({ ok: true });1455 expect(sessionStorage.peach_open_entry).toBe("entry-123");1456 expect(sessionStorage.peach_inline_unlock).toBeUndefined();1457 });14581459 it("MV-1: tab request with a matching domain returns the entry", 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 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1471 tabSender("https://github.com/login"),1472 );1473 expect(res).toMatchObject({ ok: true, data: { id: entry.id } });1474 });14751476 it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {1477 const vault = await import("./vault");1478 await vault.createVault("correct-pass-12345");1479 const entry = await vault.addEntry({1480 title: "GitHub",1481 username: "june",1482 password: "secret123",1483 url: "https://github.com",1484 });14851486 const res = await dispatch(1487 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1488 tabSender("https://evil.example.com/phish"),1489 );1490 1491 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1492 });14931494 it("PB-26-201: explicitSummon cannot authorize off-domain fill", async () => {1495 const vault = await import("./vault");1496 await vault.createVault("correct-pass-12345");1497 const entry = await vault.addEntry({1498 title: "GitHub",1499 username: "june",1500 password: "secret123",1501 url: "https://github.com",1502 });15031504 const res = await dispatch(1505 {1506 type: "GET_ENTRY_FOR_FILL",1507 payload: { id: entry.id, explicitSummon: true },1508 },1509 tabSender("https://evil.example.com/phish"),1510 );1511 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1512 });15131514 it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {1515 const vault = await import("./vault");1516 await vault.createVault("correct-pass-12345");1517 const entry = await vault.addEntry({1518 title: "GitHub",1519 username: "june",1520 password: "secret123",1521 url: "https://github.com",1522 });15231524 const res = await dispatch(1525 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1526 tabSender("not a url"),1527 );1528 expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1529 });15301531 15321533 async function seedTwoDomainVault() {1534 const vault = await import("./vault");1535 await vault.createVault("correct-pass-12345");1536 await vault.addEntry({1537 title: "GitHub",1538 username: "june",1539 password: "gh",1540 url: "https://github.com",1541 });1542 await vault.addEntry({1543 title: "Example",1544 username: "june",1545 password: "ex",1546 url: "https://example.com",1547 });1548 }15491550 it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1551 await seedTwoDomainVault();15521553 const res = await dispatch({1554 type: "AUTOFILL_QUERY",1555 payload: { domain: "github.com", includeAll: true },1556 });1557 expect(res.ok).toBe(true);1558 const data = res.data as Array<{ title: string }>;1559 1560 expect(data).toHaveLength(1);1561 expect(data[0].title).toBe("GitHub");1562 });15631564 it("PB-26-201: userGesture cannot authorize vault-wide inventory", async () => {1565 await seedTwoDomainVault();15661567 const res = await dispatch({1568 type: "AUTOFILL_QUERY",1569 payload: { domain: "github.com", includeAll: true, userGesture: true },1570 });1571 expect(res.ok).toBe(true);1572 const data = res.data as Array<{ title: string }>;1573 expect(data.map((d) => d.title)).toEqual(["GitHub"]);1574 });15751576 it("PB-26-104: decoy pairing messages hide devices and prior sync outcome", async () => {1577 const vault = await import("./vault");1578 const { listPairedDevices, upsertPairedDevice } = await import("./paired-devices");1579 const { setSyncRunner, triggerSyncIfReachable } = await import("./sync-trigger");1580 await vault.createVault("correct-pass-12345");1581 await vault.setupDuress("decoy-pass-67890");1582 await upsertPairedDevice({1583 deviceId: "android-real",1584 deviceName: "Real Pixel",1585 deviceType: "android",1586 longTermPublicKeyHex: "a".repeat(64),1587 trustSource: "direct",1588 pairedAt: 1_700_000_000_000,1589 });1590 setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 2 }));1591 await triggerSyncIfReachable({ reason: "manual" });15921593 await vault.lock();1594 expect(await vault.unlock("decoy-pass-67890")).toBe(true);1595 await expect(dispatch({ type: "LIST_PAIRED_DEVICES" })).resolves.toEqual({1596 ok: true,1597 data: { devices: [] },1598 });1599 await expect(dispatch({ type: "GET_SYNC_STATE" })).resolves.toEqual({1600 ok: true,1601 data: { status: "idle" },1602 });1603 expect((await listPairedDevices()).map((device) => device.deviceName)).toEqual(["Real Pixel"]);1604 });16051606 it("PB-26-104: real unlocked pairing messages retain the device list", async () => {1607 const vault = await import("./vault");1608 const { upsertPairedDevice } = await import("./paired-devices");1609 await vault.createVault("correct-pass-12345");1610 await upsertPairedDevice({1611 deviceId: "android-real",1612 deviceName: "Real Pixel",1613 deviceType: "android",1614 longTermPublicKeyHex: "a".repeat(64),1615 trustSource: "direct",1616 pairedAt: 1_700_000_000_000,1617 });16181619 await expect(dispatch({ type: "LIST_PAIRED_DEVICES" })).resolves.toMatchObject({1620 ok: true,1621 data: { devices: [{ deviceId: "android-real", deviceName: "Real Pixel" }] },1622 });1623 });16241625 async function seedPendingVouched(deviceId: string) {1626 await chrome.storage.local.set({1627 peach_pending_vouched_devices_v1: [{1628 deviceId,1629 deviceName: "New Pixel",1630 deviceType: "android",1631 longTermPublicKeyHex: "e".repeat(64),1632 signingPublicKeyHex: "a".repeat(64),1633 wsPort: 51234,1634 pairedAt: 1_700_000_000_020,1635 receivedAt: 1_700_000_000_050,1636 vouchedByDeviceId: "extension-issuer",1637 }],1638 });1639 }16401641 it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1642 const vault = await import("./vault");1643 await vault.createVault("correct-pass-12345");1644 await seedPendingVouched("android-new");1645 const { findPairedDeviceById } = await import("./paired-devices");1646 const { listPendingVouchedDevices } = await import(1647 "./pending-vouched-devices"1648 );16491650 const res = await dispatch({1651 type: "CONFIRM_VOUCHED_DEVICE",1652 payload: { deviceId: "android-new" },1653 });1654 expect(res).toMatchObject({ ok: true });16551656 expect(await findPairedDeviceById("android-new")).toMatchObject({1657 deviceName: "New Pixel",1658 trustSource: "vouched",1659 longTermPublicKeyHex: "e".repeat(64),1660 });1661 expect(await listPendingVouchedDevices()).toHaveLength(0);1662 });16631664 it("S1: CONFIRM_VOUCHED_DEVICE refuses a pending device whose key is revoked", async () => {1665 const vault = await import("./vault");1666 await vault.createVault("correct-pass-12345");1667 await seedPendingVouched("android-relabelled");1668 const { findPairedDeviceById, revokeDeviceById, upsertPairedDevice } =1669 await import("./paired-devices");1670 const { listPendingVouchedDevices } = await import(1671 "./pending-vouched-devices"1672 );16731674 1675 1676 await upsertPairedDevice({1677 deviceId: "android-old",1678 deviceName: "New Pixel",1679 deviceType: "android",1680 longTermPublicKeyHex: "e".repeat(64),1681 signingPublicKeyHex: "a".repeat(64),1682 pairedAt: 1_700_000_000_000,1683 trustSource: "vouched",1684 });1685 await revokeDeviceById("android-old", "self");16861687 const res = await dispatch({1688 type: "CONFIRM_VOUCHED_DEVICE",1689 payload: { deviceId: "android-relabelled" },1690 });1691 expect(res).toMatchObject({ ok: false, error: "device_revoked" });1692 expect(await findPairedDeviceById("android-relabelled")).toBeNull();1693 expect(await listPendingVouchedDevices()).toHaveLength(0);1694 });16951696 it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1697 const vault = await import("./vault");1698 await vault.createVault("correct-pass-12345");1699 await seedPendingVouched("android-new");1700 const { findPairedDeviceById } = await import("./paired-devices");1701 const { listPendingVouchedDevices } = await import(1702 "./pending-vouched-devices"1703 );17041705 const res = await dispatch({1706 type: "REJECT_VOUCHED_DEVICE",1707 payload: { deviceId: "android-new" },1708 });1709 expect(res).toMatchObject({ ok: true });17101711 expect(await findPairedDeviceById("android-new")).toBeNull();1712 expect(await listPendingVouchedDevices()).toHaveLength(0);1713 });17141715 17161717 it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => {1718 const vault = await import("./vault");1719 await vault.createVault("correct-pass-12345");1720 const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } =1721 await import("./paired-devices");17221723 await upsertPairedDevice({1724 deviceId: "phone-1",1725 deviceName: "June's Phone",1726 deviceType: "android",1727 longTermPublicKeyHex: "a".repeat(64),1728 signingPublicKeyHex: "b".repeat(64),1729 pairedAt: 1_700_000_000_000,1730 trustSource: "direct",1731 });17321733 const res = await dispatch({1734 type: "UNPAIR_DEVICE",1735 payload: { deviceId: "phone-1" },1736 });1737 expect(res).toMatchObject({ ok: true });17381739 1740 expect(await findPairedDeviceById("phone-1")).toBeNull();1741 1742 1743 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1744 const records = await listDeviceRevocations();1745 expect(records).toHaveLength(1);1746 expect(records[0]).toMatchObject({1747 deviceId: "phone-1",1748 longTermPublicKeyHex: "a".repeat(64),1749 revokedByDeviceId: expect.any(String),1750 });1751 });17521753 it("UNPAIR_DEVICE on an unknown id is a no-op (ok) and writes no revocation", async () => {1754 const vault = await import("./vault");1755 await vault.createVault("correct-pass-12345");1756 const { listDeviceRevocations } = await import("./paired-devices");17571758 const res = await dispatch({1759 type: "UNPAIR_DEVICE",1760 payload: { deviceId: "never-paired" },1761 });1762 expect(res).toMatchObject({ ok: true });1763 1764 1765 1766 1767 const records = await listDeviceRevocations();1768 expect(records).toHaveLength(1);1769 expect(records[0]).toMatchObject({1770 deviceId: "never-paired",1771 longTermPublicKeyHex: "0".repeat(64),1772 });1773 });17741775 it("UNPAIR_DEVICE refuses an empty payload with invalid_payload", async () => {1776 const res = await dispatch({1777 type: "UNPAIR_DEVICE",1778 payload: { deviceId: "" },1779 });1780 expect(res).toMatchObject({ ok: false, error: "invalid_payload" });1781 });17821783 1784 1785 1786 1787 1788 it("F1: privileged handlers reject content-script senders but keep extension-page senders (Codex Security 2026-08-04)", async () => {1789 const vault = await import("./vault");1790 await vault.createVault("correct-pass-12345");1791 const contentScriptSender = {1792 id: "peach-extension",1793 url: "https://evil.example/page",1794 };1795 const extensionPageSender = {1796 id: "peach-extension",1797 url: "chrome-extension://peach-extension/popup.html",1798 };1799 const completion = { requestId: 1, rpId: "evil.example" };18001801 1802 await expect(1803 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, contentScriptSender)1804 ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });18051806 1807 1808 await expect(1809 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1810 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });1811 });18121813 it("F2: COMPLETE_FIREFOX_PASSKEY_CREATE must match the pending ceremony (Codex Security 2026-08-04)", async () => {1814 const vault = await import("./vault");1815 await vault.createVault("correct-pass-12345");1816 const extensionPageSender = {1817 id: "peach-extension",1818 url: "chrome-extension://peach-extension/popup.html",1819 };1820 const completion = {1821 requestId: 123,1822 rpId: "example.com",1823 rpName: "Example",1824 userName: "june",1825 userHandle: "BBBB",1826 credentialId: "cred-1",1827 publicKeyCose: "c3BraQ",1828 algorithm: -7 as const,1829 };18301831 1832 await expect(1833 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1834 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });18351836 1837 sessionStorage.passkey_pending_firefox = {1838 requestId: 123,1839 type: "create",1840 rpId: "example.com",1841 rpName: "Example",1842 userName: "june",1843 browser: "firefox",1844 challenge: "AAAA",1845 userHandle: "BBBB",1846 };18471848 1849 await expect(1850 dispatch(1851 { type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { ...completion, userHandle: "CCCC" } },1852 extensionPageSender1853 )1854 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });18551856 1857 await expect(1858 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1859 ).resolves.toMatchObject({ ok: true });1860 const entries = await dispatch({ type: "GET_ENTRIES" });1861 expect(entries).toMatchObject({ ok: true });1862 const stored = (entries as { data: Array<{ passkey?: { credentialId?: string } }> }).data;1863 expect(stored.some((entry) => entry.passkey?.credentialId === "cred-1")).toBe(true);1864 });18651866 it("F2: INITIATE_FIREFOX_PASSKEY_CREATE rejects an rpId the caller origin does not own (Codex Security 2026-08-04)", async () => {1867 await expect(1868 dispatch(1869 {1870 type: "INITIATE_FIREFOX_PASSKEY_CREATE",1871 payload: { rpId: "bank.example", rpName: "Bank", userName: "june" },1872 },1873 { id: "peach-extension", url: "https://evil.example/page" }1874 )1875 ).resolves.toMatchObject({ ok: false, error: "rpid_origin_mismatch" });1876 });18771878 it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {1879 1880 1881 1882 1883 const vault = await import("./vault");1884 await vault.createVault("correct-pass-12345");1885 const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice } = await import(1886 "./paired-devices"1887 );18881889 await upsertPairedDevice({1890 deviceId: "phone-1",1891 deviceName: "June's Phone",1892 deviceType: "android",1893 longTermPublicKeyHex: "a".repeat(64),1894 signingPublicKeyHex: "b".repeat(64),1895 pairedAt: 1_700_000_000_000,1896 trustSource: "direct",1897 });18981899 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(false);1900 await revokeDeviceById("phone-1", "self");1901 1902 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1903 expect(await isDeviceRevoked("fresh-id", "a".repeat(64))).toBe(true);1904 });19051906 19071908 it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1909 const vault = await import("./vault");1910 await vault.createVault("correct-pass-12345");19111912 const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1913 expect(res).toMatchObject({ ok: false, error: "password_required" });1914 });19151916 it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1917 const vault = await import("./vault");1918 await vault.createVault("correct-pass-12345");19191920 const res = await dispatch({1921 type: "GET_RECOVERY_WORDS",1922 payload: { password: "not-the-password" },1923 });1924 expect(res).toMatchObject({ ok: false, error: "wrong_password" });1925 expect(res.data).toBeUndefined();1926 });19271928 it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1929 const vault = await import("./vault");1930 await vault.createVault("correct-pass-12345");19311932 const res = await dispatch({1933 type: "GET_RECOVERY_WORDS",1934 payload: { password: "correct-pass-12345" },1935 });1936 expect(res.ok).toBe(true);1937 expect((res.data as string[]).length).toBe(12);1938 });19391940 it("PB-26-311: cleartext export requires a fresh master-password proof", async () => {1941 const vault = await import("./vault");1942 await vault.createVault("correct-pass-12345");19431944 await expect(1945 dispatch({ type: "EXPORT_ENTRIES" as never, payload: {} }),1946 ).resolves.toMatchObject({ ok: false, error: "password_required" });1947 await expect(1948 dispatch({1949 type: "EXPORT_ENTRIES" as never,1950 payload: { password: "not-the-password" },1951 }),1952 ).resolves.toMatchObject({ ok: false, error: "wrong_password" });1953 await expect(1954 dispatch({1955 type: "EXPORT_ENTRIES" as never,1956 payload: { password: "correct-pass-12345" },1957 }),1958 ).resolves.toMatchObject({ ok: true, data: [] });1959 });19601961 it("GET_PIN_STATUS reports only a session-available PIN wrapper", async () => {1962 const vault = await import("./vault");1963 const password = "correct-pass-12345";1964 const popupSender = {1965 id: "peach-extension",1966 url: "chrome-extension://peach-extension/src/popup/index.html",1967 } as chrome.runtime.MessageSender;19681969 await vault.createVault(password);1970 await vault.setupPinUnlock(password, "1234");1971 await vault.lock();19721973 await expect(dispatch({ type: "GET_PIN_STATUS" }, popupSender)).resolves.toMatchObject({1974 ok: true,1975 data: { configured: true, available: true, attemptsRemaining: 3 },1976 });1977 });1978});