d485bbc3e255
add regression tests for passive auto-lock refresh and passkey offer-decline origin binding (PB-26-107, PB-26-109, v5.0.49)
- time
- 2026-09-02 06:42 UTC · 1 month ago
- machine
- koh2
- agent
- hy4-preview
- session
- assessment remediation corrections
- parent
- 0b68b0540185
↓ peach-browser-d485bbc3.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.autolock-refresh.test.ts+128 −0src/background/messages.passkey-offer-decline.test.ts+135 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.0.48",4+ "version": "5.0.49",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -2,7 +2,7 @@22 "manifest_version": 3,33 "minimum_chrome_version": "116",44 "name": "Peach Password Manager",5- "version": "5.0.48",5+ "version": "5.0.49",66 "description": "Zero-knowledge password manager",77 "action": {88 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "5.0.48",3+ "version": "5.0.49",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/messages.autolock-refresh.test.ts+128 −0
@@ -0,0 +1,128 @@1+// PB-26-107: passive page traffic must not refresh the auto-lock timer.2+//3+// Every inbound message used to call touchAutoLock(), which reschedules the4+// autolock alarm. The content script fires AUTOFILL_QUERY and ML_PREDICT_BATCH5+// on every `focusin` — an event browsers deliver for programmatic6+// element.focus() too — so a page running7+// setInterval(() => passwordField.focus(), 1000)8+// pinned the timer open for as long as the tab existed. Idle auto-lock is the9+// control a shared-machine user relies on, and it silently never fired.10+//11+// Both passive types are now excluded from the refresh; genuinely user-initiated12+// traffic still reschedules it.13+14+import { beforeEach, describe, expect, it, vi } from "vitest";15+16+type StorageShape = Record<string, unknown>;17+18+function makeChromeStorageMock() {19+ const storage: StorageShape = {};20+ const chrome = {21+ storage: {22+ local: {23+ get: vi.fn(async (key?: string | string[]) => {24+ if (!key) return { ...storage };25+ const keys = Array.isArray(key) ? key : [key];26+ return keys.reduce<StorageShape>((acc, k) => { acc[k] = storage[k]; return acc; }, {});27+ }),28+ set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),29+ remove: vi.fn(async () => {}),30+ },31+ session: {32+ get: vi.fn(async () => ({})),33+ set: vi.fn(async () => {}),34+ remove: vi.fn(async () => {}),35+ },36+ },37+ alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },38+ idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },39+ runtime: {40+ id: "test-extension-id",41+ getURL: vi.fn((path: string) => `chrome-extension://test-extension-id/${path}`),42+ },43+ } as unknown as typeof globalThis.chrome;44+ return { chrome };45+}46+47+const pageSender = {48+ tab: { url: "https://example.com/login" },49+ url: "https://example.com/login",50+} as unknown as chrome.runtime.MessageSender;51+52+describe("PB-26-107: passive page traffic does not refresh auto-lock", () => {53+ let vault: typeof import("./vault");54+ let messages: typeof import("./messages");55+56+ beforeEach(async () => {57+ vi.resetModules();58+ (globalThis as Record<string, unknown>).chrome = makeChromeStorageMock().chrome;59+ vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));60+ vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);61+62+ vault = await import("./vault");63+ messages = await import("./messages");64+ await vault.createVault("test-autolock-pass");65+ });66+67+ function alarmCreations(): number {68+ return (chrome.alarms.create as unknown as ReturnType<typeof vi.fn>).mock.calls.length;69+ }70+71+ it("repeated AUTOFILL_QUERY does not reschedule the lock alarm", async () => {72+ (chrome.alarms.create as unknown as ReturnType<typeof vi.fn>).mockClear();73+74+ for (let i = 0; i < 50; i += 1) {75+ await messages.handleMessage(76+ { type: "AUTOFILL_QUERY", payload: { domain: "example.com", kind: "login" } },77+ pageSender,78+ );79+ }80+81+ expect(alarmCreations()).toBe(0);82+ });83+84+ it("repeated ML_PREDICT_BATCH does not reschedule the lock alarm", async () => {85+ (chrome.alarms.create as unknown as ReturnType<typeof vi.fn>).mockClear();86+87+ for (let i = 0; i < 50; i += 1) {88+ await messages.handleMessage(89+ { type: "ML_PREDICT_BATCH", payload: { fields: [] } },90+ pageSender,91+ );92+ }93+94+ expect(alarmCreations()).toBe(0);95+ });96+97+ // The control: this is not a blanket stop-refreshing. A user-initiated98+ // message still pushes the deadline out.99+ it("a user-initiated message still reschedules the lock alarm", async () => {100+ (chrome.alarms.create as unknown as ReturnType<typeof vi.fn>).mockClear();101+102+ await messages.handleMessage({ type: "TOUCH_AUTO_LOCK", payload: {} }, pageSender);103+104+ expect(alarmCreations()).toBe(1);105+ });106+107+ // A focus loop interleaved with genuine activity must not be able to build a108+ // longer session than the activity alone would.109+ it("a focus loop between real actions adds nothing to the deadline", async () => {110+ (chrome.alarms.create as unknown as ReturnType<typeof vi.fn>).mockClear();111+112+ for (let i = 0; i < 20; i += 1) {113+ await messages.handleMessage(114+ { type: "AUTOFILL_QUERY", payload: { domain: "example.com", kind: "login" } },115+ pageSender,116+ );117+ }118+ await messages.handleMessage({ type: "TOUCH_AUTO_LOCK", payload: {} }, pageSender);119+ for (let i = 0; i < 20; i += 1) {120+ await messages.handleMessage(121+ { type: "AUTOFILL_QUERY", payload: { domain: "example.com", kind: "login" } },122+ pageSender,123+ );124+ }125+126+ expect(alarmCreations()).toBe(1);127+ });128+});src/background/messages.passkey-offer-decline.test.ts+135 −0
@@ -0,0 +1,135 @@1+// PB-26-109: the passkey offer-decline handler is page-relay, so any page's2+// content script may send it. It used to take `payload.domain` — any non-empty3+// string — and write peach_passkey_offer_declined:<domain> into storage.local4+// with no origin binding and no length bound.5+//6+// Two things followed. Keys for domains never re-queried accumulated without7+// limit (the 30-day TTL is only swept lazily), and a page could pre-decline a8+// domain it does not own, silently suppressing Peach's "save passkey to Peach"9+// offer on a site the user visits later.10+//11+// The handler now validates the domain against the browser-attested frame origin12+// with the same isValidRpId check the sibling GET_PASSKEY_FOR_DOMAIN uses, and13+// the stored key is length-bounded.14+15+import { beforeEach, describe, expect, it, vi } from "vitest";16+17+type StorageShape = Record<string, unknown>;18+19+const DECLINE_PREFIX = "peach_passkey_offer_declined:";20+21+function makeChromeMock(storage: StorageShape) {22+ const chrome = {23+ storage: {24+ local: {25+ get: vi.fn(async (key?: string | string[]) => {26+ if (!key) return { ...storage };27+ const keys = Array.isArray(key) ? key : [key];28+ return keys.reduce<StorageShape>((acc, k) => { acc[k] = storage[k]; return acc; }, {});29+ }),30+ set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),31+ remove: vi.fn(async (keys: string | string[]) => {32+ for (const k of Array.isArray(keys) ? keys : [keys]) delete storage[k];33+ }),34+ },35+ session: {36+ get: vi.fn(async () => ({})),37+ set: vi.fn(async () => {}),38+ remove: vi.fn(async () => {}),39+ },40+ },41+ alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },42+ idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },43+ runtime: {44+ id: "test-extension-id",45+ getURL: vi.fn((path: string) => `chrome-extension://test-extension-id/${path}`),46+ },47+ } as unknown as typeof globalThis.chrome;48+ return chrome;49+}50+51+function pageSender(url: string): chrome.runtime.MessageSender {52+ return { tab: { url }, url } as unknown as chrome.runtime.MessageSender;53+}54+55+describe("PB-26-109: passkey offer-decline origin binding", () => {56+ let storage: StorageShape;57+ let messages: typeof import("./messages");58+59+ beforeEach(async () => {60+ vi.resetModules();61+ storage = {};62+ (globalThis as Record<string, unknown>).chrome = makeChromeMock(storage);63+ vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));64+ vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);65+ messages = await import("./messages");66+ });67+68+ it("a cross-origin decline is refused and writes nothing", async () => {69+ const res = await messages.handleMessage(70+ { type: "PASSKEY_OFFER_DECLINE", payload: { domain: "bank.example" } },71+ pageSender("https://attacker.example.net/page"),72+ );73+74+ expect(res).toMatchObject({ ok: false, error: "invalid_domain" });75+ expect(storage[`${DECLINE_PREFIX}bank.example`]).toBeUndefined();76+ });77+78+ it("an own-origin decline is accepted", async () => {79+ const res = await messages.handleMessage(80+ { type: "PASSKEY_OFFER_DECLINE", payload: { domain: "example.com" } },81+ pageSender("https://example.com/login"),82+ );83+84+ expect(res).toMatchObject({ ok: true });85+ expect(storage[`${DECLINE_PREFIX}example.com`]).toEqual(expect.any(Number));86+ });87+88+ // A page may decline a parent domain it sits under, matching WebAuthn's own89+ // related-origin rules — that is what makes the offer meaningful on90+ // accounts.example.com for an example.com credential.91+ it("a subdomain may decline its registrable parent", async () => {92+ const res = await messages.handleMessage(93+ { type: "PASSKEY_OFFER_DECLINE", payload: { domain: "example.com" } },94+ pageSender("https://login.example.com/auth"),95+ );96+97+ expect(res).toMatchObject({ ok: true });98+ expect(storage[`${DECLINE_PREFIX}example.com`]).toEqual(expect.any(Number));99+ });100+101+ // The suffix trap: this host ends with ".attacker.example", not ".example.com".102+ it("a lookalike host cannot decline example.com", async () => {103+ const res = await messages.handleMessage(104+ { type: "PASSKEY_OFFER_DECLINE", payload: { domain: "example.com" } },105+ pageSender("https://example.com.attacker.example/login"),106+ );107+108+ expect(res).toMatchObject({ ok: false, error: "invalid_domain" });109+ expect(storage[`${DECLINE_PREFIX}example.com`]).toBeUndefined();110+ });111+112+ it("a bare public suffix is refused", async () => {113+ const res = await messages.handleMessage(114+ { type: "PASSKEY_OFFER_DECLINE", payload: { domain: "com" } },115+ pageSender("https://example.com/login"),116+ );117+118+ expect(res).toMatchObject({ ok: false, error: "invalid_domain" });119+ });120+121+ // The bound is enforced at the write, not by echoing an error: an over-long122+ // domain must not become a storage key.123+ it("an over-long domain is never written", async () => {124+ const long = `${"a".repeat(250)}.example`;125+ expect(long.length).toBeGreaterThan(253);126+127+ await messages.handleMessage(128+ { type: "PASSKEY_OFFER_DECLINE", payload: { domain: long } },129+ pageSender(`https://${long}/login`),130+ );131+132+ expect(storage[`${DECLINE_PREFIX}${long}`]).toBeUndefined();133+ expect(Object.keys(storage)).toHaveLength(0);134+ });135+});