// PB-26-109: the passkey offer-decline handler is page-relay, so any page's // content script may send it. It used to take `payload.domain` — any non-empty // string — and write peach_passkey_offer_declined: into storage.local // with no origin binding and no length bound. // // Two things followed. Keys for domains never re-queried accumulated without // limit (the 30-day TTL is only swept lazily), and a page could pre-decline a // domain it does not own, silently suppressing Peach's "save passkey to Peach" // offer on a site the user visits later. // // The handler now validates the domain against the browser-attested frame origin // with the same isValidRpId check the sibling GET_PASSKEY_FOR_DOMAIN uses, and // the stored key is length-bounded. import { beforeEach, describe, expect, it, vi } from "vitest"; type StorageShape = Record; const DECLINE_PREFIX = "peach_passkey_offer_declined:"; function makeChromeMock(storage: StorageShape) { const chrome = { storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return { ...storage }; const keys = Array.isArray(key) ? key : [key]; return keys.reduce((acc, k) => { acc[k] = storage[k]; return acc; }, {}); }), set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }), remove: vi.fn(async (keys: string | string[]) => { for (const k of Array.isArray(keys) ? keys : [keys]) delete storage[k]; }), }, session: { get: vi.fn(async () => ({})), set: vi.fn(async () => {}), remove: vi.fn(async () => {}), }, }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } }, runtime: { id: "test-extension-id", getURL: vi.fn((path: string) => `chrome-extension://test-extension-id/${path}`), }, } as unknown as typeof globalThis.chrome; return chrome; } function pageSender(url: string): chrome.runtime.MessageSender { return { tab: { url }, url } as unknown as chrome.runtime.MessageSender; } describe("PB-26-109: passkey offer-decline origin binding", () => { let storage: StorageShape; let messages: typeof import("./messages"); beforeEach(async () => { vi.resetModules(); storage = {}; (globalThis as Record).chrome = makeChromeMock(storage); vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); })); vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false); messages = await import("./messages"); }); it("a cross-origin decline is refused and writes nothing", async () => { const res = await messages.handleMessage( { type: "PASSKEY_OFFER_DECLINE", payload: { domain: "bank.example" } }, pageSender("https://attacker.example.net/page"), ); expect(res).toMatchObject({ ok: false, error: "invalid_domain" }); expect(storage[`${DECLINE_PREFIX}bank.example`]).toBeUndefined(); }); it("an own-origin decline is accepted", async () => { const res = await messages.handleMessage( { type: "PASSKEY_OFFER_DECLINE", payload: { domain: "example.com" } }, pageSender("https://example.com/login"), ); expect(res).toMatchObject({ ok: true }); expect(storage[`${DECLINE_PREFIX}example.com`]).toEqual(expect.any(Number)); }); // A page may decline a parent domain it sits under, matching WebAuthn's own // related-origin rules — that is what makes the offer meaningful on // accounts.example.com for an example.com credential. it("a subdomain may decline its registrable parent", async () => { const res = await messages.handleMessage( { type: "PASSKEY_OFFER_DECLINE", payload: { domain: "example.com" } }, pageSender("https://login.example.com/auth"), ); expect(res).toMatchObject({ ok: true }); expect(storage[`${DECLINE_PREFIX}example.com`]).toEqual(expect.any(Number)); }); // The suffix trap: this host ends with ".attacker.example", not ".example.com". it("a lookalike host cannot decline example.com", async () => { const res = await messages.handleMessage( { type: "PASSKEY_OFFER_DECLINE", payload: { domain: "example.com" } }, pageSender("https://example.com.attacker.example/login"), ); expect(res).toMatchObject({ ok: false, error: "invalid_domain" }); expect(storage[`${DECLINE_PREFIX}example.com`]).toBeUndefined(); }); it("a bare public suffix is refused", async () => { const res = await messages.handleMessage( { type: "PASSKEY_OFFER_DECLINE", payload: { domain: "com" } }, pageSender("https://example.com/login"), ); expect(res).toMatchObject({ ok: false, error: "invalid_domain" }); }); // The bound is enforced at the write, not by echoing an error: an over-long // domain must not become a storage key. it("an over-long domain is never written", async () => { const long = `${"a".repeat(250)}.example`; expect(long.length).toBeGreaterThan(253); await messages.handleMessage( { type: "PASSKEY_OFFER_DECLINE", payload: { domain: long } }, pageSender(`https://${long}/login`), ); expect(storage[`${DECLINE_PREFIX}${long}`]).toBeUndefined(); expect(Object.keys(storage)).toHaveLength(0); }); });