0a80d4f72fe2

v2.12.0 — bump versions, Projects.md, session notes, tests, dist rebuild

time
2026-04-29 07:13 UTC · 6 months ago
machine
koh2
parent
cca2dc2f164b
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/passkey-firefox-proxy.test.ts+331 −0tests/passkeys-firefox.spec.ts+67 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.11.14",4+  "version": "2.12.0",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.11.14",4+  "version": "2.12.0",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "2.11.14",3+  "version": "2.12.0",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/passkey-firefox-proxy.test.ts+331 −0
@@ -0,0 +1,331 @@1+/**2+ * passkey-firefox-proxy.test.ts — covers the v2.12.0 Firefox content-script3+ * proxy path. We mock isProUser (license verification needs real Ed255194+ * keys we don't ship in tests) and exercise the three handlers via the5+ * messages dispatcher.6+ */7+8+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";9+10+import { base64urlEncode, generatePasskeyKeypair } from "./passkey-crypto";11+import type { Message, MessageResponse } from "../shared/types";12+13+vi.mock("./license", async (importOriginal) => {14+  const actual = await importOriginal<typeof import("./license")>();15+  return {16+    ...actual,17+    isProUser: vi.fn(async () => true),18+  };19+});20+21+// FIREFOX_USE_CONTENT_SCRIPT_PROXY is computed at module init time from the22+// presence of chrome.webAuthenticationProxy + the moz-extension:// origin.23+// The test harness installs a chrome mock without webAuthenticationProxy and24+// stubs runtime.getURL to return a moz-extension URL — so the flag evaluates25+// to true under the same conditions Firefox would see in the wild.26+vi.mock("../shared/passkey-flags", () => ({27+  FIREFOX_USE_CONTENT_SCRIPT_PROXY: true,28+}));29+30+type StorageShape = Record<string, unknown>;31+type MessageListener = (32+  msg: unknown,33+  sender: chrome.runtime.MessageSender,34+  sendResponse: (response: MessageResponse) => void35+) => boolean;36+37+describe("firefox passkey proxy handlers", () => {38+  let storage: StorageShape;39+  let sessionStorage: StorageShape;40+  let listener: MessageListener;41+42+  beforeEach(async () => {43+    vi.resetModules();44+    vi.useRealTimers();45+    storage = { peach_settings: { breachMonitoring: false } };46+    sessionStorage = {};47+48+    const tabs = [{ id: 1 }];49+    const chromeMock = {50+      runtime: {51+        id: "peach-extension",52+        getURL: vi.fn((_path: string) => "moz-extension://abcd/"),53+        onMessage: {54+          addListener: vi.fn((fn: MessageListener) => {55+            listener = fn;56+          }),57+        },58+        sendMessage: vi.fn(async () => ({ ok: true })),59+      },60+      tabs: {61+        query: vi.fn((q: unknown, callback?: (t: Array<{ id?: number }>) => void) => {62+          if (typeof callback === "function") { callback(tabs); return; }63+          return Promise.resolve(tabs);64+        }),65+        sendMessage: vi.fn(async () => ({ ok: true })),66+      },67+      storage: {68+        local: {69+          get: vi.fn(async (key?: string | string[]) => {70+            if (!key) return storage;71+            if (typeof key === "string") return { [key]: storage[key] };72+            return key.reduce<Record<string, unknown>>((r, k) => { r[k] = storage[k]; return r; }, {});73+          }),74+          set: vi.fn(async (next: Record<string, unknown>) => Object.assign(storage, next)),75+          remove: vi.fn(async (keys: string | string[]) => {76+            const list = Array.isArray(keys) ? keys : [keys];77+            for (const k of list) delete storage[k];78+          }),79+        },80+        session: {81+          get: vi.fn(async (key?: string | string[]) => {82+            if (!key) return sessionStorage;83+            if (typeof key === "string") return { [key]: sessionStorage[key] };84+            return key.reduce<Record<string, unknown>>((r, k) => { r[k] = sessionStorage[k]; return r; }, {});85+          }),86+          set: vi.fn(async (next: Record<string, unknown>) => Object.assign(sessionStorage, next)),87+          remove: vi.fn(async (keys: string | string[]) => {88+            const list = Array.isArray(keys) ? keys : [keys];89+            for (const k of list) delete sessionStorage[k];90+          }),91+          clear: vi.fn(async () => { sessionStorage = {}; }),92+        },93+      },94+      action: {95+        setBadgeText: vi.fn(async () => {}),96+        setBadgeBackgroundColor: vi.fn(async () => {}),97+        openPopup: vi.fn(async () => {}),98+      },99+      alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },100+      idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },101+      scripting: { executeScript: vi.fn(async () => []) },102+    } as unknown as typeof chrome;103+104+    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;105+    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);106+107+    const messages = await import("./messages");108+    messages.setupMessageHandler();109+    const vault = await import("./vault");110+    await vault.ensureSettingsLoaded();111+  });112+113+  afterEach(() => {114+    vi.unstubAllGlobals();115+  });116+117+  async function dispatch(message: Message): Promise<MessageResponse> {118+    return await new Promise<MessageResponse>((resolve) => {119+      const open = listener(message, { id: "peach-extension" }, (r) => resolve(r));120+      expect(open).toBe(true);121+    });122+  }123+124+  // Auto-approve the next ceremony by resolving the pending request as125+  // approved as soon as the badge is set. Avoids depending on the popup.126+  async function autoApprove() {127+    const passkey = await import("./passkey");128+    const wait = async () => {129+      for (let i = 0; i < 50; i += 1) {130+        const pending = await passkey.getPendingPasskeyRequest();131+        if (pending) {132+          await passkey.resolvePendingPasskeyRequest(pending.requestId, true);133+          return;134+        }135+        await new Promise((r) => setTimeout(r, 10));136+      }137+    };138+    void wait();139+  }140+141+  it("PASSKEY_HAS_CREDENTIALS returns false when vault has no passkey for rpId", async () => {142+    const vault = await import("./vault");143+    await vault.createVault("correct-pass-12345");144+145+    await expect(146+      dispatch({147+        type: "PASSKEY_HAS_CREDENTIALS",148+        payload: { rpId: "github.com", allowCredentials: [] },149+      })150+    ).resolves.toMatchObject({ ok: true, hasCredentials: false });151+  });152+153+  it("PASSKEY_HAS_CREDENTIALS returns true when allowCredentials is empty and rpId matches", async () => {154+    const vault = await import("./vault");155+    await vault.createVault("correct-pass-12345");156+    const generated = await generatePasskeyKeypair(-7);157+    await vault.addEntry({158+      type: "login",159+      title: "GitHub",160+      passkey: {161+        rpId: "github.com",162+        rpName: "GitHub",163+        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),164+        publicKeyCose: base64urlEncode(generated.publicKeyCose),165+        privateKeyJwk: generated.privateKeyJwk,166+        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),167+        userName: "june",168+        userDisplayName: "June",169+        algorithm: -7,170+        signCount: 0,171+        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",172+        backupEligible: true,173+        backupState: false,174+        createdAt: Date.now(),175+      },176+    });177+178+    await expect(179+      dispatch({180+        type: "PASSKEY_HAS_CREDENTIALS",181+        payload: { rpId: "github.com", allowCredentials: [] },182+      })183+    ).resolves.toMatchObject({ ok: true, hasCredentials: true });184+  });185+186+  it("PASSKEY_HAS_CREDENTIALS matches by credentialId in allowCredentials", async () => {187+    const vault = await import("./vault");188+    await vault.createVault("correct-pass-12345");189+    const generated = await generatePasskeyKeypair(-7);190+    const credentialId = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));191+    await vault.addEntry({192+      type: "login",193+      title: "GitHub",194+      passkey: {195+        rpId: "github.com",196+        rpName: "GitHub",197+        credentialId,198+        publicKeyCose: base64urlEncode(generated.publicKeyCose),199+        privateKeyJwk: generated.privateKeyJwk,200+        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),201+        userName: "june",202+        userDisplayName: "June",203+        algorithm: -7,204+        signCount: 0,205+        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",206+        backupEligible: true,207+        backupState: false,208+        createdAt: Date.now(),209+      },210+    });211+212+    await expect(213+      dispatch({214+        type: "PASSKEY_HAS_CREDENTIALS",215+        payload: {216+          rpId: "github.com",217+          allowCredentials: [{ id: "different-id", type: "public-key" }],218+        },219+      })220+    ).resolves.toMatchObject({ ok: true, hasCredentials: false });221+222+    await expect(223+      dispatch({224+        type: "PASSKEY_HAS_CREDENTIALS",225+        payload: {226+          rpId: "github.com",227+          allowCredentials: [{ id: credentialId, type: "public-key" }],228+        },229+      })230+    ).resolves.toMatchObject({ ok: true, hasCredentials: true });231+  });232+233+  it("PASSKEY_HAS_CREDENTIALS returns hasCredentials:false when vault is locked", async () => {234+    const vault = await import("./vault");235+    await vault.createVault("correct-pass-12345");236+    vault.lock();237+238+    await expect(239+      dispatch({240+        type: "PASSKEY_HAS_CREDENTIALS",241+        payload: { rpId: "github.com", allowCredentials: [] },242+      })243+    ).resolves.toMatchObject({ ok: true, hasCredentials: false });244+  });245+246+  it("PASSKEY_GET returns no_credentials when nothing matches", async () => {247+    const vault = await import("./vault");248+    await vault.createVault("correct-pass-12345");249+250+    const clientDataJSON = Array.from(new TextEncoder().encode(251+      JSON.stringify({ type: "webauthn.get", challenge: "x", origin: "https://github.com", crossOrigin: false })252+    ));253+254+    await expect(255+      dispatch({256+        type: "PASSKEY_GET",257+        payload: {258+          rpId: "github.com",259+          challenge: "x",260+          clientDataJSON,261+          origin: "https://github.com",262+          allowCredentials: [],263+        },264+      })265+    ).resolves.toMatchObject({ ok: false, error: "no_credentials" });266+  });267+268+  it("PASSKEY_CREATE registers a passkey and increments signCount on PASSKEY_GET", async () => {269+    const vault = await import("./vault");270+    await vault.createVault("correct-pass-12345");271+272+    const challenge = "challenge-base64url";273+    const origin = "https://github.com";274+    const clientDataCreate = Array.from(new TextEncoder().encode(275+      JSON.stringify({ type: "webauthn.create", challenge, origin, crossOrigin: false })276+    ));277+278+    autoApprove();279+    const createRes = await dispatch({280+      type: "PASSKEY_CREATE",281+      payload: {282+        rpId: "github.com",283+        rpName: "GitHub",284+        userId: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),285+        userName: "june",286+        userDisplayName: "June",287+        challenge,288+        clientDataJSON: clientDataCreate,289+        origin,290+        pubKeyCredParams: [{ alg: -7, type: "public-key" }],291+      },292+    });293+294+    expect(createRes).toMatchObject({ ok: true });295+    const cred = (createRes as any).credential;296+    expect(cred.type).toBe("create");297+    expect(typeof cred.id).toBe("string");298+    expect(cred.algorithm).toBe(-7);299+300+    const stored = vault.getEntries().find((e) => e.passkey?.rpId === "github.com");301+    expect(stored).toBeDefined();302+    expect(stored!.passkey!.signCount).toBe(0);303+    expect(stored!.passkey!.browser).toBe("firefox");304+305+    const clientDataGet = Array.from(new TextEncoder().encode(306+      JSON.stringify({ type: "webauthn.get", challenge, origin, crossOrigin: false })307+    ));308+309+    autoApprove();310+    const getRes = await dispatch({311+      type: "PASSKEY_GET",312+      payload: {313+        rpId: "github.com",314+        challenge,315+        clientDataJSON: clientDataGet,316+        origin,317+        allowCredentials: [{ id: cred.id, type: "public-key" }],318+      },319+    });320+321+    expect(getRes).toMatchObject({ ok: true });322+    const assertion = (getRes as any).credential;323+    expect(assertion.type).toBe("get");324+    expect(typeof assertion.signature).toBe("string");325+    expect(typeof assertion.authenticatorData).toBe("string");326+327+    const after = vault.getEntries().find((e) => e.passkey?.rpId === "github.com");328+    expect(after!.passkey!.signCount).toBe(1);329+    expect(after!.passkey!.lastUsedAt).toBeDefined();330+  });331+});

view file

tests/passkeys-firefox.spec.ts+67 −0
@@ -0,0 +1,67 @@1+/**2+ * passkeys-firefox.spec.ts — Firefox content-script proxy E2E suite.3+ *4+ * The MAIN-world monkey-patch only runs under Firefox with a real Gecko5+ * credentials API to delegate to. Playwright's bundled chromium runner cannot6+ * exercise it. To run this suite, build with `npm run build:firefox` and7+ * launch via `web-ext run --target=firefox-desktop --source-dir=dist-firefox`,8+ * then drive Firefox with playwright's `firefox` browser type plus the9+ * preferences below to load the temporary add-on. Until that harness lands10+ * in CI the suite is gated by PEACH_E2E_FIREFOX_PASSKEYS=1, so the default11+ * `npx playwright test` run skips it cleanly.12+ *13+ * The list below is the verification matrix from the v2.12.0 spec. Each14+ * item maps to a manual smoke-test step on a real RP (github.com,15+ * webauthn.io, google.com account settings).16+ */17+18+import { test } from "@playwright/test";19+20+const ENABLED = process.env.PEACH_E2E_FIREFOX_PASSKEYS === "1";21+const skip = ENABLED ? test : test.skip;22+23+test.describe("Firefox passkey content-script proxy (MAIN world)", () => {24+  // 1. navigator.credentials.create is replaced on document_start.25+  skip("navigator.credentials.create is replaced on page load (MAIN world)", async () => {});26+27+  // 2. navigator.credentials.get is replaced on document_start.28+  skip("navigator.credentials.get is replaced on page load (MAIN world)", async () => {});29+30+  // 3. Registration: approval UI appears, vault gets a passkey entry with31+  //    privateKeyJwk and browser:'firefox'.32+  skip("registration: approval UI appears, passkey stored in vault", async () => {});33+34+  // 4. Authentication: approval UI appears, RP receives a valid assertion.35+  skip("authentication: approval UI appears, assertion returned", async () => {});36+37+  // 5. clientDataJSON.origin equals the page origin in both responses.38+  skip("clientDataJSON.origin equals the page origin in both responses", async () => {});39+40+  // 6. clientDataJSON.origin must NEVER contain `moz-extension://`.41+  skip("clientDataJSON.origin does not contain moz-extension://", async () => {});42+43+  // 7. signCount strictly increments across two consecutive get ceremonies.44+  skip("signCount increments between two authentication ceremonies", async () => {});45+46+  // 8. user_cancelled: page receives DOMException with name 'NotAllowedError'.47+  skip("user_cancelled: DOMException with NotAllowedError name thrown", async () => {});48+49+  // 9. Non-publicKey credentials.create requests fall through to the50+  //    platform unchanged (PasswordCredential, FederatedCredential).51+  skip("non-passkey credentials.create falls through to original", async () => {});52+53+  // 10. Non-publicKey credentials.get falls through.54+  skip("non-passkey credentials.get falls through to original", async () => {});55+56+  // 11. No passkey for rpId: PASSKEY_HAS_CREDENTIALS returns false → fall57+  //     through to the platform authenticator. No Peach UI appears.58+  skip("no passkey for rpId: falls through to platform authenticator", async () => {});59+60+  // 12. Free tier: pro_required fires; page-side dispatches61+  //     'peach:passkey-pro-gate' event; ceremony falls through to platform.62+  skip("free tier: pro gate fires, falls through to platform", async () => {});63+64+  // 13. Round-trip: register passkey → authenticate → vault entry's65+  //     signCount and lastUsedAt update.66+  skip("round-trip: register passkey then authenticate updates vault entry", async () => {});67+});

view file