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
5 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 −0
manifest.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": {

view file

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",

view file

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",

view file

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+});

view file

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+});

view file