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
↓ peach-browser-0a80d4f7.tar.gzatlas at this save
files changed5 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 −0manifest.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": {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",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": {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+});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+});