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;202122232425262728function asChromeStores(value: unknown): unknown {29 if (Array.isArray(value)) return value.map(asChromeStores);30 if (value && typeof value === "object") {31 const source = value as StorageShape;32 const clone: StorageShape = {};33 for (const key of Object.keys(source).sort()) clone[key] = asChromeStores(source[key]);34 return clone;35 }36 return value;37}3839function storeWrite(target: StorageShape, next: StorageShape): void {40 for (const [key, value] of Object.entries(next)) {41 if (value === undefined) continue;42 target[key] = asChromeStores(value);43 }44}4546describe("background messages", () => {47 let storage: StorageShape;48 let sessionStorage: StorageShape;49 let listener: MessageListener;50 let fetchMock: ReturnType<typeof vi.fn>;5152 beforeEach(async () => {53 vi.resetModules();54 vi.useRealTimers();55 storage = {56 peach_settings: { breachMonitoring: false },57 };58 sessionStorage = {};59 fetchMock = vi.fn(async () => ({60 ok: true,61 status: 200,62 text: async () => "",63 }));64 vi.stubGlobal("fetch", fetchMock);65 vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);6667 const tabs = [{ id: 1 }];68 const chromeMock = {69 runtime: {70 id: "peach-extension",71 getURL: (p: string) => `chrome-extension://peach-extension/${p}`,72 onMessage: {73 addListener: vi.fn((fn: MessageListener) => {74 listener = fn;75 }),76 },77 sendMessage: vi.fn(async () => ({ ok: true })),78 },79 tabs: {80 query: vi.fn((_query: unknown, callback?: (tabs: Array<{ id?: number }>) => void) => {81 if (typeof callback === "function") {82 callback(tabs);83 return;84 }85 return Promise.resolve(tabs);86 }),87 sendMessage: vi.fn(async () => ({ ok: true })),88 },89 storage: {90 local: {91 get: vi.fn(async (key?: string | string[]) => {92 if (!key) return storage;93 if (typeof key === "string") {94 return { [key]: storage[key] };95 }9697 return key.reduce<Record<string, unknown>>((result, entry) => {98 result[entry] = storage[entry];99 return result;100 }, {});101 }),102 set: vi.fn(async (next: Record<string, unknown>) => {103 storeWrite(storage, 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 storage[key];109 }110 }),111 clear: vi.fn(async () => {112 for (const key of Object.keys(storage)) delete storage[key];113 }),114 },115 session: {116 get: vi.fn(async (key?: string | string[]) => {117 if (!key) return sessionStorage;118 if (typeof key === "string") {119 return { [key]: sessionStorage[key] };120 }121 return key.reduce<Record<string, unknown>>((result, entry) => {122 result[entry] = sessionStorage[entry];123 return result;124 }, {});125 }),126 set: vi.fn(async (next: Record<string, unknown>) => {127 storeWrite(sessionStorage, next);128 }),129 remove: vi.fn(async (keys: string | string[]) => {130 const list = Array.isArray(keys) ? keys : [keys];131 for (const key of list) {132 delete sessionStorage[key];133 }134 }),135 clear: vi.fn(async () => {136 for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];137 }),138 },139 sync: {140 get: vi.fn(async (_key?: string | string[]) => ({})),141 set: vi.fn(async (_values: Record<string, unknown>) => undefined),142 remove: vi.fn(async (_keys: string | string[]) => undefined),143 clear: vi.fn(async () => undefined),144 },145 },146 alarms: {147 clear: vi.fn(),148 create: vi.fn(),149 onAlarm: { addListener: vi.fn() },150 },151 idle: {152 setDetectionInterval: vi.fn(),153 onStateChanged: { addListener: vi.fn() },154 },155 scripting: {156 executeScript: vi.fn(async () => []),157 },158 } as unknown as typeof chrome;159160 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;161162 const messages = await import("./messages");163 messages.setupMessageHandler();164 const vault = await import("./vault");165 await vault.ensureSettingsLoaded();166 });167168 async function dispatch(169 message: Message,170 sender: chrome.runtime.MessageSender = {171 id: "peach-extension",172 url: "chrome-extension://peach-extension/popup.html",173 }174 ): Promise<MessageResponse> {175 return await new Promise<MessageResponse>((resolve) => {176 const keepChannelOpen = listener(message, sender, (response) => resolve(response));177 expect(keepChannelOpen).toBe(true);178 });179 }180181 it("PB-26-303: page GET_SETTINGS returns only theme, accent, and its own summon record", async () => {182 storage.peach_settings = {183 theme: "dark",184 accentColor: "peach",185 autoLockMinutes: 5,186 pinEnabled: true,187 manualSummonDomains: {188 "example.com": { firstSummonedAt: 1, summonCount: 2, lastSummonedAt: 3 },189 "other.example": { firstSummonedAt: 4, summonCount: 5, lastSummonedAt: 6 },190 },191 };192 const response = await dispatch(193 { type: "GET_SETTINGS" },194 {195 id: "peach-extension",196 url: "https://login.example.com/form",197 tab: { id: 1 },198 frameId: 0,199 } as chrome.runtime.MessageSender,200 );201202 expect(response).toEqual({203 ok: true,204 data: {205 theme: "dark",206 accentColor: "peach",207 manualSummonDomains: {208 "example.com": { firstSummonedAt: 1, summonCount: 2, lastSummonedAt: 3 },209 },210 },211 });212 await expect(dispatch({ type: "GET_SETTINGS" })).resolves.toMatchObject({213 ok: true,214 data: {215 autoLockMinutes: 5,216 pinEnabled: true,217 manualSummonDomains: {218 "example.com": expect.anything(),219 "other.example": expect.anything(),220 },221 },222 });223 });224225 it("RECORD_MANUAL_SUMMON binds and bounds the sender domain", async () => {226 const sender = {227 id: "peach-extension",228 url: "https://login.example.com/form",229 origin: "https://login.example.com",230 tab: { id: 1 },231 frameId: 0,232 } as chrome.runtime.MessageSender;233234 await expect(dispatch({235 type: "RECORD_MANUAL_SUMMON",236 payload: { domain: "evil.example" },237 }, sender)).resolves.toEqual({ ok: false, error: "invalid_domain" });238 await expect(dispatch({239 type: "RECORD_MANUAL_SUMMON",240 payload: { domain: "example.com" },241 }, sender)).resolves.toEqual({ ok: true });242 await expect(dispatch({243 type: "RECORD_MANUAL_SUMMON",244 payload: { domain: "a".repeat(254) },245 }, { ...sender, origin: "https://a.com", url: "https://a.com/form" })).resolves.toEqual({246 ok: false,247 error: "invalid_domain",248 });249 });250251 it("GET_ENTRIES when unlocked returns the entry array", async () => {252 const vault = await import("./vault");253 await vault.createVault("correct-pass-12345");254 await vault.addEntry({255 title: "GitHub",256 username: "june",257 password: "secret123",258 url: "https://github.com",259 });260261 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({262 ok: true,263 data: [264 expect.objectContaining({265 title: "GitHub",266 username: "june",267 }),268 ],269 });270 });271272 it("GET_ENTRIES when locked returns a vault locked error", async () => {273 const vault = await import("./vault");274 await vault.createVault("correct-pass-12345");275 vault.lock();276277 await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({278 ok: false,279 error: "vault_locked",280 });281 });282283 it("ADD_ENTRY with a valid payload returns the created entry", async () => {284 const vault = await import("./vault");285 await vault.createVault("correct-pass-12345");286287 const response = await dispatch({288 type: "ADD_ENTRY",289 payload: {290 title: "GitHub",291 username: "june",292 password: "secret123",293 url: "https://github.com",294 },295 });296297 expect(response).toMatchObject({298 ok: true,299 data: expect.objectContaining({300 id: expect.any(String),301 title: "GitHub",302 }),303 });304 expect(vault.getEntries()).toHaveLength(1);305 });306307 it("ADD_ENTRY when locked returns a vault locked error", async () => {308 const vault = await import("./vault");309 await vault.createVault("correct-pass-12345");310 vault.lock();311312 await expect(313 dispatch({314 type: "ADD_ENTRY",315 payload: {316 title: "GitHub",317 username: "june",318 password: "secret123",319 url: "https://github.com",320 },321 })322 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });323 });324325 it("ADD_ENTRY with no password succeeds for email-only accounts", async () => {326 const vault = await import("./vault");327 await vault.createVault("correct-pass-12345");328329 await expect(330 dispatch({331 type: "ADD_ENTRY",332 payload: {333 title: "GitHub",334 username: "june",335 password: "",336 url: "https://github.com",337 },338 })339 ).resolves.toMatchObject({ ok: true });340 });341342 it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {343 const vault = await import("./vault");344 await vault.createVault("correct-pass-12345");345 const entry = await vault.addEntry({346 title: "GitHub",347 username: "june",348 password: "secret123",349 url: "https://github.com",350 });351352 await expect(353 dispatch({354 type: "UPDATE_ENTRY",355 payload: { id: entry.id, patch: { password: "new-secret" } },356 })357 ).resolves.toMatchObject({ ok: true });358359 expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");360 });361362 it("UPDATE_ENTRY with an empty password clears it for email-only accounts", async () => {363 const vault = await import("./vault");364 await vault.createVault("correct-pass-12345");365 const entry = await vault.addEntry({366 title: "GitHub",367 username: "june",368 password: "secret123",369 url: "https://github.com",370 });371372 await expect(373 dispatch({374 type: "UPDATE_ENTRY",375 payload: { id: entry.id, patch: { password: "" } },376 })377 ).resolves.toMatchObject({ ok: true });378379 expect(vault.getEntryById(entry.id)?.password).toBeUndefined();380 });381382 it("DELETE_ENTRY soft-deletes the target entry", async () => {383 const vault = await import("./vault");384 await vault.createVault("correct-pass-12345");385 const entry = await vault.addEntry({386 title: "GitHub",387 username: "june",388 password: "secret123",389 url: "https://github.com",390 });391392 await expect(393 dispatch({394 type: "DELETE_ENTRY",395 payload: { id: entry.id },396 })397 ).resolves.toMatchObject({ ok: true });398399 expect(vault.getEntries()).toEqual([]);400 expect(vault.getDeletedEntries()).toHaveLength(1);401 });402403 it("DELETE_ENTRY of a missing id returns an error", async () => {404 const vault = await import("./vault");405 await vault.createVault("correct-pass-12345");406407 await expect(408 dispatch({409 type: "DELETE_ENTRY",410 payload: { id: "missing" },411 })412 ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });413 });414415 it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {416 const vault = await import("./vault");417 await vault.createVault("correct-pass-12345");418 const entry = await vault.addEntry({419 title: "GitHub",420 username: "june",421 password: "secret123",422 url: "https://github.com",423 });424425 const response = await dispatch(426 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },427 { id: "peach-extension", url: "https://github.com/login" },428 );429430 expect(response).toMatchObject({431 ok: true,432 data: expect.objectContaining({433 id: entry.id,434 username: "june",435 password: "secret123",436 }),437 });438 expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();439 });440441 it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {442 const vault = await import("./vault");443 await vault.createVault("correct-pass-12345");444 vault.lock();445446 await expect(447 dispatch({448 type: "GET_ENTRY_FOR_FILL",449 payload: { id: "missing" },450 })451 ).resolves.toMatchObject({ ok: false, error: "vault_locked" });452 });453454 it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {455 const vault = await import("./vault");456 await vault.createVault("correct-pass-12345");457 const generated = await generatePasskeyKeypair(-7);458 const entry = await vault.addEntry({459 type: "login",460 title: "WebAuthn.io",461 passkey: {462 rpId: "webauthn.io",463 rpName: "WebAuthn.io",464 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),465 publicKeyCose: base64urlEncode(generated.publicKeyCose),466 privateKeyJwk: generated.privateKeyJwk,467 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),468 userName: "june",469 userDisplayName: "June",470 algorithm: -7,471 signCount: 0,472 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",473 backupEligible: true,474 backupState: false,475 createdAt: Date.now(),476 },477 });478479 await expect(480 dispatch({481 type: "GET_ENTRY_FOR_FILL",482 payload: { id: entry.id },483 })484 ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });485 });486487 it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {488 const vault = await import("./vault");489 await vault.createVault("correct-pass-12345");490 await vault.addEntry({491 title: "GitHub",492 username: "june",493 password: "secret123",494 url: "https://github.com",495 totp: "JBSWY3DPEHPK3PXP",496 });497498 const response = await dispatch({499 type: "AUTOFILL_QUERY",500 payload: { domain: "github.com" },501 });502503 expect(response).toMatchObject({504 ok: true,505 data: [506 {507 id: expect.any(String),508 title: "GitHub",509 username: "june",510 url: "https://github.com",511 hasTotp: true,512 },513 ],514 });515 expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();516 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();517 });518519 it("AUTOFILL_QUERY includeAll returns search-ready summaries for non-note, non-passkey entries", async () => {520 const vault = await import("./vault");521 await vault.createVault("correct-pass-12345");522 await vault.addEntry({523 title: "GitHub",524 username: "june",525 password: "secret123",526 url: "https://github.com",527 });528 await vault.addEntry({529 type: "note",530 title: "Secure note",531 notes: "hello",532 });533534 const response = await dispatch({535 type: "AUTOFILL_QUERY",536 payload: { domain: "github.com", includeAll: true },537 });538539 expect(response).toMatchObject({540 ok: true,541 data: [542 {543 id: expect.any(String),544 title: "GitHub",545 username: "june",546 url: "https://github.com",547 hasPasskey: false,548 },549 ],550 });551 const data = response.data as Array<Record<string, unknown>>;552 expect(data).toHaveLength(1);553 554 expect(data[0]).not.toHaveProperty("password");555 expect(data[0]).not.toHaveProperty("totp");556 });557558 it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {559 const vault = await import("./vault");560 await vault.createVault("correct-pass-12345");561 await vault.addEntry({562 title: "GitHub",563 username: "june",564 password: "secret123",565 url: "https://github.com",566 totp: "JBSWY3DPEHPK3PXP",567 });568 await vault.addEntry({569 title: "No 2FA",570 username: "person",571 password: "secret123",572 url: "https://github.com",573 });574575 const response = await dispatch({576 type: "AUTOFILL_QUERY",577 payload: { domain: "github.com", kind: "totp" },578 });579580 expect(response).toMatchObject({581 ok: true,582 data: [583 {584 id: expect.any(String),585 title: "GitHub",586 username: "june",587 url: "https://github.com",588 hasTotp: true,589 },590 ],591 });592 expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();593 expect(response.data).toHaveLength(1);594 });595596 it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {597 const vault = await import("./vault");598 await vault.createVault("correct-pass-12345");599 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });600601 const response = await dispatch({602 type: "RESTORE_ENTRIES",603 payload: {604 mode: "replace",605 entries: [606 {607 id: "restored-1",608 type: "login",609 title: "B",610 username: "june",611 password: "two",612 url: "https://b.test",613 createdAt: 1,614 modifiedAt: 1,615 },616 ],617 },618 });619620 expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });621 expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);622 });623624 it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {625 const vault = await import("./vault");626 await vault.createVault("correct-pass-12345");627628 await expect(629 dispatch({630 type: "RESTORE_ENTRIES",631 payload: { mode: "replace", entries: [] },632 })633 ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });634 });635636 it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {637 const vault = await import("./vault");638 await vault.createVault("correct-pass-12345");639640 await expect(641 dispatch({642 type: "CHANGE_PASSWORD",643 payload: {644 currentPassword: "correct-pass-12345",645 newPassword: "new-pass-67890",646 },647 })648 ).resolves.toMatchObject({ ok: true });649650 vault.lock();651 expect(await vault.unlock("new-pass-67890")).toBe(true);652653 await expect(654 dispatch({655 type: "CHANGE_PASSWORD",656 payload: {657 currentPassword: "wrong-pass",658 newPassword: "another-pass",659 },660 })661 ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });662 });663664 it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {665 const vault = await import("./vault");666 await vault.createVault("correct-pass-12345");667668 (669 chrome.alarms.create as unknown as ReturnType<typeof vi.fn>670 ).mockClear();671 (672 chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>673 ).mockClear();674675 await expect(676 dispatch({677 type: "UPDATE_SETTINGS",678 payload: { autoLockMinutes: 30 },679 })680 ).resolves.toMatchObject({ ok: true });681682 expect(vault.getSettings().autoLockMinutes).toBe(30);683 expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);684 expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {685 delayInMinutes: 30,686 });687 });688689 it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {690 vi.useFakeTimers();691 const vault = await import("./vault");692 await vault.createVault("correct-pass-12345");693 const entry = await vault.addEntry({694 title: "GitHub",695 username: "june",696 password: "secret123",697 url: "https://github.com",698 });699 const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);700701 const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });702 await vi.runAllTimersAsync();703 const response = await responsePromise;704705 expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });706 expect(vault.getEntryById(entry.id)?.breach).toBe(7);707 breachSpy.mockRestore();708 });709710 it("rejects passkey approval from a different tab than the ceremony", async () => {711 sessionStorage.passkey_pending_301 = {712 requestId: 301,713 type: "get",714 rpId: "example.com",715 rpName: "Example",716 userName: "[email protected]",717 tabId: 7,718 origin: "https://example.com",719 };720721 await expect(722 dispatch(723 { type: "PASSKEY_AUTH_RESPONSE", payload: { requestId: 301, approved: true } },724 { id: "peach-extension", tab: { id: 8 }, url: "https://example.com/login" } as chrome.runtime.MessageSender,725 ),726 ).resolves.toMatchObject({ ok: false, error: "invalid_passkey_approval_sender" });727 });728729 it("messages from foreign or URL-less senders are rejected", async () => {730 const vault = await import("./vault");731 await vault.createVault("correct-pass-12345");732733 await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({734 ok: false,735 error: "invalid_sender",736 });737738 await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({739 ok: false,740 error: "invalid_sender",741 });742 });743744 it("LOCK and UNLOCK messages update vault state correctly", async () => {745 const vault = await import("./vault");746747 await expect(748 dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })749 ).resolves.toMatchObject({ ok: true });750 expect(vault.isUnlocked()).toBe(true);751752 await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });753 expect(vault.isUnlocked()).toBe(false);754755 await expect(756 dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })757 ).resolves.toMatchObject({ ok: false });758 expect(vault.isUnlocked()).toBe(false);759760 await expect(761 dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })762 ).resolves.toMatchObject({ ok: true });763 expect(vault.isUnlocked()).toBe(true);764 });765766 it("does not acknowledge LOCK until session cleanup finishes", async () => {767 await expect(768 dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })769 ).resolves.toMatchObject({ ok: true });770771 const removalStarted = deferred();772 const releaseRemoval = deferred();773 const remove = chrome.storage.session.remove as unknown as ReturnType<typeof vi.fn>;774 remove.mockImplementation(async (keys: string | string[]) => {775 removalStarted.resolve();776 await releaseRemoval.promise;777 for (const key of Array.isArray(keys) ? keys : [keys]) delete sessionStorage[key];778 });779780 let responded = false;781 const response = dispatch({ type: "LOCK" }).then((result) => {782 responded = true;783 return result;784 });785 await removalStarted.promise;786787 try {788 await Promise.resolve();789 expect(responded).toBe(false);790 } finally {791 releaseRemoval.resolve();792 }793794 await expect(response).resolves.toMatchObject({ ok: true });795 });796797 it("remembers the last successful unlock method", async () => {798 const vault = await import("./vault");799 const password = "correct-pass-12345";800 const popupSender = {801 id: "peach-extension",802 url: "chrome-extension://peach-extension/src/popup/index.html",803 } as chrome.runtime.MessageSender;804805 await vault.createVault(password);806 expect(await vault.setupPinUnlock(password, "1234")).toBe(true);807 vault.lock();808809 await expect(810 dispatch({ type: "UNLOCK_WITH_PIN", payload: { pin: "1234" } }, popupSender),811 ).resolves.toMatchObject({ ok: true });812 expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(true);813814 vault.lock();815 await expect(816 dispatch({ type: "UNLOCK", payload: { password } }, popupSender),817 ).resolves.toMatchObject({ ok: true });818 expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(false);819 });820821 it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {822 const response = await dispatch({823 type: "FIRE_CANARY_PING",824 payload: {825 webhookUrl: "https://example.com/hook",826 entryTitle: "GitHub",827 trigger: "password_copied",828 },829 });830831 expect(response).toMatchObject({ ok: true });832 expect(fetchMock).toHaveBeenCalledWith(833 "https://example.com/hook",834 expect.objectContaining({835 method: "POST",836 headers: { "Content-Type": "application/json" },837 body: expect.any(String),838 })839 );840 const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;841 expect(body).toMatchObject({842 entry: "GitHub",843 trigger: "password_copied",844 source: "peach-extension",845 });846 });847848 it("LV-3: debounces a repeat non-test ping for the same webhook+entry", async () => {849 const payload = {850 webhookUrl: "https://example.com/hook",851 entryTitle: "GitHub",852 trigger: "autofilled" as const,853 };854 const first = await dispatch({ type: "FIRE_CANARY_PING", payload });855 expect(first).toMatchObject({ ok: true });856 expect(fetchMock).toHaveBeenCalledTimes(1);857 expect(Object.keys(storage).some((key) => key.includes("GitHub"))).toBe(false);858 expect(Object.keys(storage).some((key) => key.startsWith("canary-last-fetch:"))).toBe(true);859860 const second = await dispatch({ type: "FIRE_CANARY_PING", payload });861 expect(second).toMatchObject({ ok: true, data: { rateLimited: true } });862 expect(fetchMock).toHaveBeenCalledTimes(1); 863 });864865 it("LV-3: removes legacy plaintext rate-limit keys", async () => {866 const legacyKey = "canary-last-fetch:https://example.com/hook:GitHub";867 storage[legacyKey] = Date.now();868869 await dispatch({870 type: "FIRE_CANARY_PING",871 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },872 });873874 expect(storage[legacyKey]).toBeUndefined();875 });876877 it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => {878 await dispatch({879 type: "FIRE_CANARY_PING",880 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },881 });882 await dispatch({883 type: "FIRE_CANARY_PING",884 payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" },885 });886 expect(fetchMock).toHaveBeenCalledTimes(2);887 });888889 it("LV-3: test pings are exempt from the debounce", async () => {890 const payload = {891 webhookUrl: "https://example.com/hook",892 entryTitle: "GitHub",893 trigger: "test" as const,894 };895 await dispatch({ type: "FIRE_CANARY_PING", payload });896 await dispatch({ type: "FIRE_CANARY_PING", payload });897 expect(fetchMock).toHaveBeenCalledTimes(2);898 });899900 it("rejects test pings from content scripts before contacting the webhook", async () => {901 const response = await dispatch(902 {903 type: "FIRE_CANARY_PING",904 payload: {905 webhookUrl: "https://example.com/hook",906 entryTitle: "GitHub",907 trigger: "test",908 },909 },910 {911 id: "peach-extension",912 url: "https://example.com/login",913 tab: { id: 7 },914 } as chrome.runtime.MessageSender,915 );916917 expect(response).toMatchObject({ ok: false, error: "invalid_sender" });918 expect(fetchMock).not.toHaveBeenCalled();919 });920921 it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {922 await expect(923 dispatch({924 type: "FIRE_CANARY_PING",925 payload: {926 webhookUrl: "http://example.com/hook",927 entryTitle: "GitHub",928 trigger: "password_copied",929 },930 })931 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });932933 expect(fetchMock).not.toHaveBeenCalled();934 });935936 it("F3: FIRE_CANARY_PING rejects IPv4-mapped IPv6 webhooks pointing at private ranges (Codex Security 2026-08-04)", async () => {937 for (const webhookUrl of [938 "https://[::ffff:192.168.0.1]/hook",939 "https://[::ffff:c0a8:1]/hook",940 "https://[::ffff:10.0.0.1]/hook",941 "https://[::ffff:127.0.0.1]/hook",942 "https://[::ffff:169.254.1.1]/hook",943 "https://[::ffff:172.16.0.1]/hook",944 ]) {945 await expect(946 dispatch({947 type: "FIRE_CANARY_PING",948 payload: { webhookUrl, entryTitle: "GitHub", trigger: "password_copied" },949 })950 ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });951 }952953 expect(fetchMock).not.toHaveBeenCalled();954 });955956 it("GET_IDENTITIES when locked returns vault locked error", async () => {957 const vault = await import("./vault");958 await vault.createVault("correct-pass-12345");959 vault.lock();960 await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({961 ok: false,962 error: "vault_locked",963 });964 });965966 it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {967 const vault = await import("./vault");968 await vault.createVault("correct-pass-12345");969 await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });970 await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });971 await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });972973 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };974 expect(resp.ok).toBe(true);975 const data = resp.data as Array<{ value: string; type: string; useCount: number }>;976 expect(data.find((d) => d.value === "[email protected]")).toMatchObject({977 type: "email",978 useCount: 2,979 });980 expect(data.find((d) => d.value === "juni_patel")).toMatchObject({981 type: "username",982 useCount: 1,983 });984 985 const emailIdx = data.findIndex((d) => d.value === "[email protected]");986 const userIdx = data.findIndex((d) => d.value === "juni_patel");987 expect(emailIdx).toBeLessThan(userIdx);988 });989990 it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {991 const vault = await import("./vault");992 await vault.createVault("correct-pass-12345");993 for (let i = 0; i < 3; i++) {994 await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });995 }996997 const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };998 const data = resp.data as Array<{ value: string; useCount: number }>;999 const hits = data.filter((d) => d.value === "[email protected]");1000 expect(hits).toHaveLength(1);1001 expect(hits[0]!.useCount).toBe(3);1002 });10031004 it("GET_IDENTITIES rejects content-script and page-relay senders", async () => {1005 const vault = await import("./vault");1006 await vault.createVault("correct-pass-12345");1007 await vault.addEntry({1008 title: "Private identity",1009 username: "[email protected]",1010 password: "pw",1011 url: "https://private.example",1012 });10131014 const senders = [1015 {1016 id: "peach-extension",1017 url: "https://private.example/signup",1018 tab: { id: 1 } as chrome.tabs.Tab,1019 },1020 { id: "peach-extension", url: "https://private.example/signup" },1021 ] satisfies chrome.runtime.MessageSender[];1022 for (const sender of senders) {1023 await expect(dispatch({ type: "GET_IDENTITIES" }, sender)).resolves.toMatchObject({1024 ok: false,1025 error: "invalid_sender",1026 });1027 }1028 });10291030 it("IS_CARD_DECLINED does not refresh the auto-lock timer", async () => {1031 const vault = await import("./vault");1032 await vault.createVault("correct-pass-12345");1033 const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;10341035 1036 alarmsCreate.mockClear();1037 await dispatch({ type: "GET_ENTRIES" });1038 expect(alarmsCreate).toHaveBeenCalled();10391040 1041 alarmsCreate.mockClear();1042 await dispatch({ type: "IS_CARD_DECLINED", payload: { origin: "https://example.com" } });1043 expect(alarmsCreate).not.toHaveBeenCalled();1044 });10451046 it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {1047 const vault = await import("./vault");1048 await vault.createVault("correct-pass-12345");1049 await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });10501051 1052 storage.peach_license = "PEACH-PRO-TEST-KEY";1053 storage.peach_pro_celebrated = true;10541055 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });1056 expect(res).toMatchObject({ ok: true });10571058 1059 expect(storage.peach_vault_a).toBeUndefined();1060 1061 expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");1062 expect(storage.peach_pro_celebrated).toBe(true);1063 });10641065 it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {1066 const messages = await import("./messages");1067 const vault = await import("./vault");10681069 let resolveGate: () => void = () => {};1070 messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));10711072 let settled = false;1073 const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });10741075 1076 1077 await new Promise((r) => setTimeout(r, 20));1078 expect(settled).toBe(false);10791080 1081 await vault.createVault("correct-pass-12345");1082 resolveGate();10831084 await expect(pending).resolves.toMatchObject({ ok: true, data: true });1085 });10861087 it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {1088 const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });1089 expect(res.ok).toBe(false);1090 1091 const has = await dispatch({ type: "HAS_VAULT" });1092 expect((has as { data?: boolean }).data).toBe(false);1093 });10941095 it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {1096 const vault = await import("./vault");1097 await vault.createVault("correct-pass-12345");1098 const e = await vault.addEntry({1099 title: "Mail",1100 username: "june",1101 password: "pw",1102 url: "https://mail.test",1103 totp: "JBSWY3DPEHPK3PXP",1104 });11051106 const res = await dispatch(1107 { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },1108 { id: "peach-extension", url: "https://mail.test/login" },1109 );1110 expect(res.ok).toBe(true);1111 const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };1112 expect(data.totp).toBeUndefined();1113 expect(data.totpCode).toMatch(/^\d{6}$/);1114 expect(data.totpRemaining).toBeGreaterThan(0);1115 expect(data.totpRemaining).toBeLessThanOrEqual(30);1116 });11171118 it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {1119 const vault = await import("./vault");1120 await vault.createVault("correct-pass-12345");1121 const e = await vault.addEntry({1122 title: "Bank",1123 username: "june",1124 password: "old-pw",1125 url: "https://bank.test",1126 });1127 await vault.updateEntry(e.id, { password: "new-pw" });11281129 const fill = await dispatch(1130 { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },1131 { id: "peach-extension", url: "https://bank.test/login" },1132 );1133 expect(fill.ok).toBe(true);1134 1135 expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();11361137 const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });1138 expect(edit.ok).toBe(true);1139 const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];1140 expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);1141 });11421143 it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {1144 const vault = await import("./vault");1145 await vault.createVault("correct-pass-12345");1146 const e = await vault.addEntry({1147 title: "Mail",1148 username: "june",1149 password: "pw",1150 url: "https://mail.test",1151 totp: "JBSWY3DPEHPK3PXP",1152 });11531154 const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });1155 expect(res.ok).toBe(true);1156 const data = res.data as { totp?: string };1157 expect(data.totp).toBe("JBSWY3DPEHPK3PXP");1158 });11591160 it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {1161 const vault = await import("./vault");1162 await vault.createVault("correct-pass-12345");11631164 const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });1165 expect(res).toMatchObject({ ok: true });11661167 expect(storage.peach_vault_a).toBeUndefined();1168 expect(storage.peach_license).toBeUndefined();1169 expect(storage.peach_pro_celebrated).toBeUndefined();1170 });11711172 11731174 function tabSender(url: string, tabId = 1, frameId = 0): chrome.runtime.MessageSender {1175 return { id: "peach-extension", tab: { id: tabId, url } as chrome.tabs.Tab, url, frameId };1176 }11771178 async function addPasskeyOnlyEntry(title: string, url: string) {1179 const vault = await import("./vault");1180 const generated = await generatePasskeyKeypair(-7);1181 return vault.addEntry({1182 type: "login",1183 title,1184 url,1185 passkey: {1186 rpId: new URL(url).hostname,1187 rpName: title,1188 credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1189 publicKeyCose: base64urlEncode(generated.publicKeyCose),1190 privateKeyJwk: generated.privateKeyJwk,1191 userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1192 userName: "june",1193 userDisplayName: "June",1194 algorithm: -7,1195 signCount: 0,1196 aaguid: "AAAAAAAAAAAAAAAAAAAAAA",1197 backupEligible: true,1198 backupState: false,1199 createdAt: Date.now(),1200 },1201 });1202 }12031204 it("PB-26-201: normal inventory ignores forged domain and gesture booleans", async () => {1205 const vault = await import("./vault");1206 await vault.createVault("correct-pass-12345");1207 await vault.addEntry({ title: "GitHub", username: "victim", password: "secret", url: "https://github.com" });1208 await vault.addEntry({ title: "Evil", username: "attacker", password: "known", url: "https://evil.example.com" });12091210 const result = await dispatch(1211 {1212 type: "AUTOFILL_QUERY",1213 payload: {1214 domain: "github.com",1215 includeAll: true,1216 userGesture: true,1217 explicitSummon: true,1218 },1219 },1220 tabSender("https://evil.example.com/phish"),1221 );12221223 expect(result).toMatchObject({ ok: true });1224 expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil"]);1225 });12261227 it("PB-26-201: normal TOTP inventory is scoped to the attested frame origin", async () => {1228 const vault = await import("./vault");1229 await vault.createVault("correct-pass-12345");1230 await vault.addEntry({ title: "GitHub 2FA", password: "secret", url: "https://github.com", totp: "JBSWY3DPEHPK3PXP" });1231 await vault.addEntry({ title: "Evil 2FA", password: "known", url: "https://evil.example.com", totp: "JBSWY3DPEHPK3PXP" });12321233 const result = await dispatch(1234 { type: "AUTOFILL_QUERY", payload: { domain: "github.com", kind: "totp" } },1235 tabSender("https://evil.example.com/verify"),1236 );12371238 expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil 2FA"]);1239 });12401241 it("PB-26-201: summon capability is one-shot after inventory authorization", async () => {1242 const vault = await import("./vault");1243 const messages = await import("./messages");1244 await vault.createVault("correct-pass-12345");1245 const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com" });1246 const sender = tabSender("https://evil.example.com/phish");1247 const token = messages.createSummonCapability(1, "https://evil.example.com");12481249 const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1250 expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });12511252 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1253 expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });1254 const replay = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1255 expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1256 });12571258 it("PB-26-201: summon capability is consumed by a same-domain fill", async () => {1259 const vault = await import("./vault");1260 const messages = await import("./messages");1261 await vault.createVault("correct-pass-12345");1262 const entry = await vault.addEntry({ title: "GitHub", password: "secret123", url: "https://evil.example.com" });1263 const sender = tabSender("https://evil.example.com/login");1264 const token = messages.createSummonCapability(1, "https://evil.example.com");12651266 const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1267 expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });1268 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1269 expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });12701271 const offDomainReplay = await dispatch(1272 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1273 tabSender("https://github.com/login"),1274 );1275 expect(offDomainReplay).toMatchObject({ ok: false, error: "entry_not_found" });1276 });12771278 it("PB-26-201: summon capability cannot fill a different entry", async () => {1279 const vault = await import("./vault");1280 const messages = await import("./messages");1281 await vault.createVault("correct-pass-12345");1282 const first = await vault.addEntry({ title: "First", password: "one", url: "https://first.example" });1283 const second = await vault.addEntry({ title: "Second", password: "two", url: "https://second.example" });1284 const sender = tabSender("https://evil.example.com/phish");1285 const token = messages.createSummonCapability(1, "https://evil.example.com");1286 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1287 const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: first.id, capability: token } }, sender);1288 expect(firstFill).toMatchObject({ ok: true, data: { id: first.id } });1289 const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: second.id, capability: token } }, sender);1290 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1291 expect(first.id).not.toBe(second.id);1292 });12931294 it("PB-26-201: fill before inventory is rejected without an entry oracle", async () => {1295 const vault = await import("./vault");1296 const messages = await import("./messages");1297 await vault.createVault("correct-pass-12345");1298 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1299 const token = messages.createSummonCapability(1, "https://evil.example.com");1300 const result = await dispatch(1301 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1302 tabSender("https://evil.example.com/phish"),1303 );1304 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1305 });13061307 it("PB-26-201: an unused summon capability does not block a normal same-origin fill", async () => {1308 const vault = await import("./vault");1309 const messages = await import("./messages");1310 await vault.createVault("correct-pass-12345");1311 const entry = await vault.addEntry({1312 title: "Evil",1313 password: "known",1314 url: "https://evil.example.com",1315 });1316 const sender = tabSender("https://evil.example.com/login");1317 const token = messages.createSummonCapability(1, "https://evil.example.com");13181319 const result = await dispatch(1320 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1321 sender,1322 );13231324 expect(result).toMatchObject({ ok: true, data: { id: entry.id } });1325 });13261327 it("PB-26-201: expired fill capability is rejected before entry-specific errors", async () => {1328 vi.useFakeTimers();1329 vi.setSystemTime(new Date("2026-09-02T12:00:00Z"));1330 const vault = await import("./vault");1331 const messages = await import("./messages");1332 await vault.createVault("correct-pass-12345");1333 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1334 const sender = tabSender("https://evil.example.com/phish");1335 const token = messages.createSummonCapability(1, "https://evil.example.com");1336 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1337 vi.advanceTimersByTime(30_001);13381339 const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1340 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1341 });13421343 it("PB-26-201: wrong-tab fill capability is rejected before entry-specific errors", async () => {1344 const vault = await import("./vault");1345 const messages = await import("./messages");1346 await vault.createVault("correct-pass-12345");1347 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1348 const token = messages.createSummonCapability(1, "https://evil.example.com");1349 await dispatch(1350 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1351 tabSender("https://evil.example.com/phish"),1352 );13531354 const result = await dispatch(1355 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1356 tabSender("https://evil.example.com/phish", 2),1357 );1358 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1359 });13601361 it("PB-26-201: wrong-frame fill capability is rejected before entry-specific errors", async () => {1362 const vault = await import("./vault");1363 const messages = await import("./messages");1364 await vault.createVault("correct-pass-12345");1365 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1366 const token = messages.createSummonCapability(1, "https://evil.example.com", 3);1367 await dispatch(1368 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1369 tabSender("https://evil.example.com/phish", 1, 3),1370 );13711372 const result = await dispatch(1373 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1374 tabSender("https://evil.example.com/phish", 1, 4),1375 );1376 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1377 });13781379 it("PB-26-201: wrong-origin fill capability is rejected before entry-specific errors", async () => {1380 const vault = await import("./vault");1381 const messages = await import("./messages");1382 await vault.createVault("correct-pass-12345");1383 const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1384 const token = messages.createSummonCapability(1, "https://evil.example.com");1385 await dispatch(1386 { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1387 tabSender("https://evil.example.com/phish"),1388 );13891390 const result = await dispatch(1391 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1392 tabSender("https://other.example.com/phish"),1393 );1394 expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1395 });13961397 it("PB-26-201: selecting a missing id consumes the one-shot capability without an oracle", async () => {1398 const vault = await import("./vault");1399 const messages = await import("./messages");1400 await vault.createVault("correct-pass-12345");1401 const existing = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1402 const sender = tabSender("https://evil.example.com/phish");1403 const token = messages.createSummonCapability(1, "https://evil.example.com");1404 await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);14051406 const missing = await dispatch(1407 { type: "GET_ENTRY_FOR_FILL", payload: { id: "missing", capability: token } },1408 sender,1409 );1410 const existingReplay = await dispatch(1411 { type: "GET_ENTRY_FOR_FILL", payload: { id: existing.id, capability: token } },1412 sender,1413 );1414 expect(missing).toMatchObject({ ok: false, error: "entry_not_found" });1415 expect(existingReplay).toEqual(missing);1416 });14171418 it("PB-26-201: inventory capability cannot be reused for a second inventory", async () => {1419 const vault = await import("./vault");1420 const messages = await import("./messages");1421 await vault.createVault("correct-pass-12345");1422 await vault.addEntry({ title: "GitHub", password: "secret", url: "https://github.com" });1423 const sender = tabSender("https://evil.example.com/phish");1424 const token = messages.createSummonCapability(1, "https://evil.example.com");1425 const first = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1426 const second = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);14271428 expect(first).toMatchObject({ ok: true, data: [{ title: "GitHub" }] });1429 expect(second).toMatchObject({ ok: false, error: "entry_not_found" });1430 });14311432 it("PB-26-201: cards require a capability and consume it on the selected fill", async () => {1433 const vault = await import("./vault");1434 const messages = await import("./messages");1435 await vault.createVault("correct-pass-12345");1436 const card = await vault.addEntry({1437 type: "card",1438 title: "Personal Visa",1439 cardholderName: "June Peach",1440 cardNumber: "4111111111111111",1441 cvc: "123",1442 expMonth: "12",1443 expYear: "2030",1444 });1445 const sender = tabSender("https://shop.example.com/checkout");1446 const passive = await dispatch(1447 { type: "AUTOFILL_QUERY", payload: { domain: "shop.example.com", kind: "card" } },1448 sender,1449 );1450 expect(passive).toMatchObject({ ok: true, data: [] });14511452 const token = messages.createSummonCapability(1, "https://shop.example.com");1453 const inventory = await dispatch(1454 { type: "AUTOFILL_QUERY", payload: { domain: "forged.example", kind: "card", capability: token } },1455 sender,1456 );1457 expect(inventory).toMatchObject({ ok: true, data: [{ id: card.id, title: "Personal Visa" }] });1458 expect((inventory.data as Array<Record<string, unknown>>)[0]).not.toHaveProperty("cardNumber");14591460 const fill = await dispatch(1461 { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1462 sender,1463 );1464 expect(fill).toMatchObject({ ok: true, data: { id: card.id, cardNumber: "4111111111111111" } });1465 const replay = await dispatch(1466 { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1467 sender,1468 );1469 expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1470 });14711472 it("PB-26-201: inline edit popup request stores only the selected entry id", async () => {1473 const result = await dispatch(1474 { type: "OPEN_POPUP", payload: { entryId: "entry-123" } },1475 tabSender("https://example.com/login"),1476 );14771478 expect(result).toMatchObject({ ok: true });1479 expect(sessionStorage.peach_open_entry).toBe("entry-123");1480 expect(sessionStorage.peach_inline_unlock).toBeUndefined();1481 });14821483 it("MV-1: tab request with a matching domain returns the entry", async () => {1484 const vault = await import("./vault");1485 await vault.createVault("correct-pass-12345");1486 const entry = await vault.addEntry({1487 title: "GitHub",1488 username: "june",1489 password: "secret123",1490 url: "https://github.com",1491 });14921493 const res = await dispatch(1494 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1495 tabSender("https://github.com/login"),1496 );1497 expect(res).toMatchObject({ ok: true, data: { id: entry.id } });1498 });14991500 it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {1501 const vault = await import("./vault");1502 await vault.createVault("correct-pass-12345");1503 const entry = await vault.addEntry({1504 title: "GitHub",1505 username: "june",1506 password: "secret123",1507 url: "https://github.com",1508 });15091510 const res = await dispatch(1511 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1512 tabSender("https://evil.example.com/phish"),1513 );1514 1515 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1516 });15171518 it("PB-26-201: explicitSummon cannot authorize off-domain fill", async () => {1519 const vault = await import("./vault");1520 await vault.createVault("correct-pass-12345");1521 const entry = await vault.addEntry({1522 title: "GitHub",1523 username: "june",1524 password: "secret123",1525 url: "https://github.com",1526 });15271528 const res = await dispatch(1529 {1530 type: "GET_ENTRY_FOR_FILL",1531 payload: { id: entry.id, explicitSummon: true },1532 },1533 tabSender("https://evil.example.com/phish"),1534 );1535 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1536 });15371538 it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {1539 const vault = await import("./vault");1540 await vault.createVault("correct-pass-12345");1541 const entry = await vault.addEntry({1542 title: "GitHub",1543 username: "june",1544 password: "secret123",1545 url: "https://github.com",1546 });15471548 const res = await dispatch(1549 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1550 tabSender("not a url"),1551 );1552 expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1553 });15541555 15561557 async function seedTwoDomainVault() {1558 const vault = await import("./vault");1559 await vault.createVault("correct-pass-12345");1560 await vault.addEntry({1561 title: "GitHub",1562 username: "june",1563 password: "gh",1564 url: "https://github.com",1565 });1566 await vault.addEntry({1567 title: "Example",1568 username: "june",1569 password: "ex",1570 url: "https://example.com",1571 });1572 }15731574 it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1575 await seedTwoDomainVault();15761577 const res = await dispatch({1578 type: "AUTOFILL_QUERY",1579 payload: { domain: "github.com", includeAll: true },1580 });1581 expect(res.ok).toBe(true);1582 const data = res.data as Array<{ title: string }>;1583 1584 expect(data).toHaveLength(1);1585 expect(data[0].title).toBe("GitHub");1586 });15871588 it("PB-26-201: userGesture cannot authorize vault-wide inventory", async () => {1589 await seedTwoDomainVault();15901591 const res = await dispatch({1592 type: "AUTOFILL_QUERY",1593 payload: { domain: "github.com", includeAll: true, userGesture: true },1594 });1595 expect(res.ok).toBe(true);1596 const data = res.data as Array<{ title: string }>;1597 expect(data.map((d) => d.title)).toEqual(["GitHub"]);1598 });15991600 it("PB-26-104: decoy pairing messages hide devices and prior sync outcome", async () => {1601 const vault = await import("./vault");1602 const { listPairedDevices, upsertPairedDevice } = await import("./paired-devices");1603 const { setSyncRunner, triggerSyncIfReachable } = await import("./sync-trigger");1604 await vault.createVault("correct-pass-12345");1605 await vault.setupDuress("decoy-pass-67890");1606 await upsertPairedDevice({1607 deviceId: "android-real",1608 deviceName: "Real Pixel",1609 deviceType: "android",1610 longTermPublicKeyHex: "a".repeat(64),1611 trustSource: "direct",1612 pairedAt: 1_700_000_000_000,1613 });1614 setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 2 }));1615 await triggerSyncIfReachable({ reason: "manual" });16161617 await vault.lock();1618 expect(await vault.unlock("decoy-pass-67890")).toBe(true);1619 await expect(dispatch({ type: "LIST_PAIRED_DEVICES" })).resolves.toEqual({1620 ok: true,1621 data: { devices: [] },1622 });1623 await expect(dispatch({ type: "GET_SYNC_STATE" })).resolves.toEqual({1624 ok: true,1625 data: { status: "idle" },1626 });1627 expect((await listPairedDevices()).map((device) => device.deviceName)).toEqual(["Real Pixel"]);1628 });16291630 it("PB-26-104: real unlocked pairing messages retain the device list", async () => {1631 const vault = await import("./vault");1632 const { upsertPairedDevice } = await import("./paired-devices");1633 await vault.createVault("correct-pass-12345");1634 await upsertPairedDevice({1635 deviceId: "android-real",1636 deviceName: "Real Pixel",1637 deviceType: "android",1638 longTermPublicKeyHex: "a".repeat(64),1639 trustSource: "direct",1640 pairedAt: 1_700_000_000_000,1641 });16421643 await expect(dispatch({ type: "LIST_PAIRED_DEVICES" })).resolves.toMatchObject({1644 ok: true,1645 data: { devices: [{ deviceId: "android-real", deviceName: "Real Pixel" }] },1646 });1647 });16481649 async function seedPendingVouched(deviceId: string) {1650 await chrome.storage.local.set({1651 peach_pending_vouched_devices_v1: [{1652 deviceId,1653 deviceName: "New Pixel",1654 deviceType: "android",1655 longTermPublicKeyHex: "e".repeat(64),1656 signingPublicKeyHex: "a".repeat(64),1657 wsPort: 51234,1658 pairedAt: 1_700_000_000_020,1659 receivedAt: 1_700_000_000_050,1660 vouchedByDeviceId: "extension-issuer",1661 }],1662 });1663 }16641665 it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1666 const vault = await import("./vault");1667 await vault.createVault("correct-pass-12345");1668 await seedPendingVouched("android-new");1669 const { findPairedDeviceById } = await import("./paired-devices");1670 const { listPendingVouchedDevices } = await import(1671 "./pending-vouched-devices"1672 );16731674 const res = await dispatch({1675 type: "CONFIRM_VOUCHED_DEVICE",1676 payload: { deviceId: "android-new" },1677 });1678 expect(res).toMatchObject({ ok: true });16791680 expect(await findPairedDeviceById("android-new")).toMatchObject({1681 deviceName: "New Pixel",1682 trustSource: "vouched",1683 longTermPublicKeyHex: "e".repeat(64),1684 });1685 expect(await listPendingVouchedDevices()).toHaveLength(0);1686 });16871688 it("S1: CONFIRM_VOUCHED_DEVICE refuses a pending device whose key is revoked", async () => {1689 const vault = await import("./vault");1690 await vault.createVault("correct-pass-12345");1691 await seedPendingVouched("android-relabelled");1692 const { findPairedDeviceById, revokeDeviceById, upsertPairedDevice } =1693 await import("./paired-devices");1694 const { listPendingVouchedDevices } = await import(1695 "./pending-vouched-devices"1696 );16971698 1699 1700 await upsertPairedDevice({1701 deviceId: "android-old",1702 deviceName: "New Pixel",1703 deviceType: "android",1704 longTermPublicKeyHex: "e".repeat(64),1705 signingPublicKeyHex: "a".repeat(64),1706 pairedAt: 1_700_000_000_000,1707 trustSource: "vouched",1708 });1709 await revokeDeviceById("android-old", "self");17101711 const res = await dispatch({1712 type: "CONFIRM_VOUCHED_DEVICE",1713 payload: { deviceId: "android-relabelled" },1714 });1715 expect(res).toMatchObject({ ok: false, error: "device_revoked" });1716 expect(await findPairedDeviceById("android-relabelled")).toBeNull();1717 expect(await listPendingVouchedDevices()).toHaveLength(0);1718 });17191720 it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1721 const vault = await import("./vault");1722 await vault.createVault("correct-pass-12345");1723 await seedPendingVouched("android-new");1724 const { findPairedDeviceById } = await import("./paired-devices");1725 const { listPendingVouchedDevices } = await import(1726 "./pending-vouched-devices"1727 );17281729 const res = await dispatch({1730 type: "REJECT_VOUCHED_DEVICE",1731 payload: { deviceId: "android-new" },1732 });1733 expect(res).toMatchObject({ ok: true });17341735 expect(await findPairedDeviceById("android-new")).toBeNull();1736 expect(await listPendingVouchedDevices()).toHaveLength(0);1737 });17381739 17401741 it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => {1742 const vault = await import("./vault");1743 await vault.createVault("correct-pass-12345");1744 1745 1746 const identity = await import("./device-identity");1747 await identity.getOrCreateDeviceId();1748 const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } =1749 await import("./paired-devices");17501751 await upsertPairedDevice({1752 deviceId: "phone-1",1753 deviceName: "June's Phone",1754 deviceType: "android",1755 longTermPublicKeyHex: "a".repeat(64),1756 signingPublicKeyHex: "b".repeat(64),1757 pairedAt: 1_700_000_000_000,1758 trustSource: "direct",1759 });17601761 const res = await dispatch({1762 type: "UNPAIR_DEVICE",1763 payload: { deviceId: "phone-1" },1764 });1765 expect(res).toMatchObject({ ok: true });17661767 1768 expect(await findPairedDeviceById("phone-1")).toBeNull();1769 1770 1771 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1772 const records = await listDeviceRevocations();1773 expect(records).toHaveLength(1);1774 expect(records[0]).toMatchObject({1775 deviceId: "phone-1",1776 longTermPublicKeyHex: "a".repeat(64),1777 revokedByDeviceId: expect.any(String),1778 });1779 });1780 it("UNPAIR_DEVICE still evicts a peer when the local identity needs migration", async () => {1781 const vault = await import("./vault");1782 await vault.createVault("correct-pass-12345");1783 const identity = await import("./device-identity");1784 await identity.getOrCreateDeviceId();1785 delete storage.peach_local_identity_v4;1786 delete sessionStorage.peach_local_identity_session;1787 const { findPairedDeviceById, isDeviceRevoked, upsertPairedDevice } = await import("./paired-devices");1788 await upsertPairedDevice({1789 deviceId: "phone-1",1790 deviceName: "June's Phone",1791 deviceType: "android",1792 longTermPublicKeyHex: "a".repeat(64),1793 signingPublicKeyHex: "b".repeat(64),1794 pairedAt: 1_700_000_000_000,1795 trustSource: "direct",1796 });17971798 const response = await dispatch({ type: "UNPAIR_DEVICE", payload: { deviceId: "phone-1" } });17991800 expect(response).toMatchObject({ ok: true });1801 expect(await findPairedDeviceById("phone-1")).toBeNull();1802 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1803 });18041805 it("UNPAIR_DEVICE on an unknown id is a no-op (ok) and writes no revocation", async () => {1806 const vault = await import("./vault");1807 await vault.createVault("correct-pass-12345");1808 const { listDeviceRevocations } = await import("./paired-devices");18091810 const res = await dispatch({1811 type: "UNPAIR_DEVICE",1812 payload: { deviceId: "never-paired" },1813 });1814 expect(res).toMatchObject({ ok: true });1815 1816 1817 1818 1819 const records = await listDeviceRevocations();1820 expect(records).toHaveLength(1);1821 expect(records[0]).toMatchObject({1822 deviceId: "never-paired",1823 longTermPublicKeyHex: "0".repeat(64),1824 });1825 });18261827 it("UNPAIR_DEVICE refuses an empty payload with invalid_payload", async () => {1828 const res = await dispatch({1829 type: "UNPAIR_DEVICE",1830 payload: { deviceId: "" },1831 });1832 expect(res).toMatchObject({ ok: false, error: "invalid_payload" });1833 });1834 it("SECURE_RESET archives legacy trust, rotates identity, and reaches the new Sync anchor", async () => {1835 const vault = await import("./vault");1836 await vault.createVault("correct-pass-12345");1837 const identity = await import("./device-identity");1838 const previousDeviceId = await identity.getOrCreateDeviceId();1839 const { upsertPairedDevice } = await import("./paired-devices");1840 await upsertPairedDevice({1841 deviceId: "phone-1",1842 deviceName: "June's Phone",1843 deviceType: "android",1844 longTermPublicKeyHex: "a".repeat(64),1845 signingPublicKeyHex: "b".repeat(64),1846 pairedAt: 1_700_000_000_000,1847 trustSource: "direct",1848 });18491850 const response = await dispatch({1851 type: "SECURE_RESET",1852 payload: { password: "correct-pass-12345" },1853 });18541855 expect(response).toMatchObject({ ok: true, data: { state: "V2_ANCHOR" } });1856 expect(await identity.getOrCreateDeviceId()).not.toBe(previousDeviceId);1857 });1858 it("SECURE_RESET unlocks a locked real vault before rotating identity", async () => {1859 const vault = await import("./vault");1860 await vault.createVault("correct-pass-12345");1861 const identity = await import("./device-identity");1862 const previousDeviceId = await identity.getOrCreateDeviceId();1863 const { upsertPairedDevice } = await import("./paired-devices");1864 await upsertPairedDevice({1865 deviceId: "phone-1",1866 deviceName: "June's Phone",1867 deviceType: "android",1868 longTermPublicKeyHex: "a".repeat(64),1869 signingPublicKeyHex: "b".repeat(64),1870 pairedAt: 1_700_000_000_000,1871 trustSource: "direct",1872 });1873 await vault.lock();1874 expect(vault.isUnlocked()).toBe(false);18751876 const response = await dispatch({1877 type: "SECURE_RESET",1878 payload: { password: "correct-pass-12345" },1879 });18801881 expect(response).toMatchObject({ ok: true, data: { state: "V2_ANCHOR" } });1882 expect(vault.isUnlocked()).toBe(true);1883 expect(await identity.getOrCreateDeviceId()).not.toBe(previousDeviceId);1884 });1885 it("SECURE_RESET completes after fresh unlock when the locked spool hits identity migration", async () => {1886 const vault = await import("./vault");1887 const syncAdapter = await import("./sync-vault-adapter");1888 vault.setLockedSyncHooks({1889 refresh: syncAdapter.refreshLockedSyncSpool,1890 afterUnlock: syncAdapter.drainAndRefreshLockedSyncSpool,1891 });1892 await vault.createVault("correct-pass-12345");1893 const identity = await import("./device-identity");1894 const previousDeviceId = await identity.getOrCreateDeviceId();1895 const { upsertPairedDevice } = await import("./paired-devices");1896 await upsertPairedDevice({1897 deviceId: "phone-1",1898 deviceName: "June's Phone",1899 deviceType: "android",1900 longTermPublicKeyHex: "a".repeat(64),1901 signingPublicKeyHex: "b".repeat(64),1902 pairedAt: 1_700_000_000_000,1903 trustSource: "direct",1904 });1905 await vault.addEntry({ type: "login", title: "spooled", password: "secret" });1906 await syncAdapter.refreshLockedSyncSpool();1907 await vault.lock();1908 const { createLockedSyncVaultAdapter } = await import("./locked-sync-spool");1909 const lockedAdapter = await createLockedSyncVaultAdapter();1910 expect(lockedAdapter).not.toBeNull();1911 await lockedAdapter!.applyEntries({ remote: "AA==" }, { remote: Date.now() + 1 });1912 delete storage.peach_local_identity_v4;1913 delete sessionStorage.peach_local_identity_session;1914 const sessionSet = chrome.storage.session.set as unknown as ReturnType<typeof vi.fn>;1915 sessionSet.mockImplementation(async (next: Record<string, unknown>) => {1916 if (Object.hasOwn(next, "peach_local_identity_session")) {1917 throw new Error("QUOTA_BYTES");1918 }1919 Object.assign(sessionStorage, next);1920 });19211922 const unlocked = await dispatch({1923 type: "UNLOCK",1924 payload: { password: "correct-pass-12345" },1925 });1926 const pairSecrets = await import("./pair-secrets");1927 const root = Uint8Array.from((vault.getRootKeyHex() ?? "").match(/../g)!, (part) => Number.parseInt(part, 16));1928 await pairSecrets.setPairSecret("phone-1", "c".repeat(64), root);1929 root.fill(0);1930 expect(storage.peach_pair_secrets_v1).toBeDefined();1931 expect(sessionStorage.peach_pair_secrets_session).toBeDefined();1932 expect(unlocked).toMatchObject({ ok: true });19331934 const response = await dispatch({1935 type: "SECURE_RESET",1936 payload: { password: "correct-pass-12345" },1937 });19381939 expect(response).toMatchObject({ ok: true, data: { state: "V2_ANCHOR" } });1940 expect(storage.peach_local_identity_v4).toBeDefined();1941 expect(storage.peach_paired_devices_v3).toBeUndefined();1942 expect(storage.peach_pair_secrets_v1).toBeUndefined();1943 expect(sessionStorage.peach_pair_secrets_session).toBeUndefined();1944 expect(await identity.getOrCreateDeviceId()).not.toBe(previousDeviceId);1945 });1946 1947 1948 1949 1950 1951 it("F1: privileged handlers reject content-script senders but keep extension-page senders (Codex Security 2026-08-04)", async () => {1952 const vault = await import("./vault");1953 await vault.createVault("correct-pass-12345");1954 const contentScriptSender = {1955 id: "peach-extension",1956 url: "https://evil.example/page",1957 };1958 const extensionPageSender = {1959 id: "peach-extension",1960 url: "chrome-extension://peach-extension/popup.html",1961 };1962 const completion = { requestId: 1, rpId: "evil.example" };19631964 1965 await expect(1966 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, contentScriptSender)1967 ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });19681969 1970 1971 await expect(1972 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1973 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });1974 });19751976 it("F2: COMPLETE_FIREFOX_PASSKEY_CREATE must match the pending ceremony (Codex Security 2026-08-04)", async () => {1977 const vault = await import("./vault");1978 await vault.createVault("correct-pass-12345");1979 const extensionPageSender = {1980 id: "peach-extension",1981 url: "chrome-extension://peach-extension/popup.html",1982 };1983 const completion = {1984 requestId: 123,1985 rpId: "example.com",1986 rpName: "Example",1987 userName: "june",1988 userHandle: "BBBB",1989 credentialId: "cred-1",1990 publicKeyCose: "c3BraQ",1991 algorithm: -7 as const,1992 };19931994 1995 await expect(1996 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1997 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });19981999 2000 sessionStorage.passkey_pending_firefox = {2001 requestId: 123,2002 type: "create",2003 rpId: "example.com",2004 rpName: "Example",2005 userName: "june",2006 browser: "firefox",2007 challenge: "AAAA",2008 userHandle: "BBBB",2009 };20102011 2012 await expect(2013 dispatch(2014 { type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { ...completion, userHandle: "CCCC" } },2015 extensionPageSender2016 )2017 ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });20182019 2020 await expect(2021 dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)2022 ).resolves.toMatchObject({ ok: true });2023 const entries = await dispatch({ type: "GET_ENTRIES" });2024 expect(entries).toMatchObject({ ok: true });2025 const stored = (entries as { data: Array<{ passkey?: { credentialId?: string } }> }).data;2026 expect(stored.some((entry) => entry.passkey?.credentialId === "cred-1")).toBe(true);2027 });20282029 it("F2: INITIATE_FIREFOX_PASSKEY_CREATE rejects an rpId the caller origin does not own (Codex Security 2026-08-04)", async () => {2030 await expect(2031 dispatch(2032 {2033 type: "INITIATE_FIREFOX_PASSKEY_CREATE",2034 payload: { rpId: "bank.example", rpName: "Bank", userName: "june" },2035 },2036 { id: "peach-extension", url: "https://evil.example/page" }2037 )2038 ).resolves.toMatchObject({ ok: false, error: "rpid_origin_mismatch" });2039 });20402041 it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {2042 2043 2044 2045 2046 const vault = await import("./vault");2047 await vault.createVault("correct-pass-12345");2048 const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice } = await import(2049 "./paired-devices"2050 );20512052 await upsertPairedDevice({2053 deviceId: "phone-1",2054 deviceName: "June's Phone",2055 deviceType: "android",2056 longTermPublicKeyHex: "a".repeat(64),2057 signingPublicKeyHex: "b".repeat(64),2058 pairedAt: 1_700_000_000_000,2059 trustSource: "direct",2060 });20612062 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(false);2063 await revokeDeviceById("phone-1", "self");2064 2065 expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);2066 expect(await isDeviceRevoked("fresh-id", "a".repeat(64))).toBe(true);2067 });20682069 20702071 it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {2072 const vault = await import("./vault");2073 await vault.createVault("correct-pass-12345");20742075 const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });2076 expect(res).toMatchObject({ ok: false, error: "password_required" });2077 });20782079 it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {2080 const vault = await import("./vault");2081 await vault.createVault("correct-pass-12345");20822083 const res = await dispatch({2084 type: "GET_RECOVERY_WORDS",2085 payload: { password: "not-the-password" },2086 });2087 expect(res).toMatchObject({ ok: false, error: "wrong_password" });2088 expect(res.data).toBeUndefined();2089 });20902091 it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {2092 const vault = await import("./vault");2093 await vault.createVault("correct-pass-12345");20942095 const res = await dispatch({2096 type: "GET_RECOVERY_WORDS",2097 payload: { password: "correct-pass-12345" },2098 });2099 expect(res.ok).toBe(true);2100 expect((res.data as string[]).length).toBe(12);2101 });21022103 it("PB-26-311: cleartext export requires a fresh master-password proof", async () => {2104 const vault = await import("./vault");2105 await vault.createVault("correct-pass-12345");21062107 await expect(2108 dispatch({ type: "EXPORT_ENTRIES" as never, payload: {} }),2109 ).resolves.toMatchObject({ ok: false, error: "password_required" });2110 await expect(2111 dispatch({2112 type: "EXPORT_ENTRIES" as never,2113 payload: { password: "not-the-password" },2114 }),2115 ).resolves.toMatchObject({ ok: false, error: "wrong_password" });2116 await expect(2117 dispatch({2118 type: "EXPORT_ENTRIES" as never,2119 payload: { password: "correct-pass-12345" },2120 }),2121 ).resolves.toMatchObject({ ok: true, data: [] });2122 });21232124 it("GET_PIN_STATUS reports only a session-available PIN wrapper", async () => {2125 const vault = await import("./vault");2126 const password = "correct-pass-12345";2127 const popupSender = {2128 id: "peach-extension",2129 url: "chrome-extension://peach-extension/src/popup/index.html",2130 } as chrome.runtime.MessageSender;21312132 await vault.createVault(password);2133 await vault.setupPinUnlock(password, "1234");2134 await vault.lock();21352136 await expect(dispatch({ type: "GET_PIN_STATUS" }, popupSender)).resolves.toMatchObject({2137 ok: true,2138 data: { configured: true, available: true, attemptsRemaining: 3 },2139 });2140 });2141});