cca2dc2f164b

rewrite MAIN-world proxy, add relay, wire manifest, gate legacy offer

time
2026-04-29 07:08 UTC · 6 months ago
machine
koh2
parent
8a118743fab0
4 files manifest.firefox.json+9 −0src/content/index.ts+12 −8src/content/passkeys-firefox-relay.ts+47 −0src/content/passkeys-firefox.ts+305 −50
manifest.firefox.json+9 −0
@@ -39,6 +39,15 @@3939       ],4040       "run_at": "document_start",4141       "world": "MAIN"42+    },43+    {44+      "matches": [45+        "<all_urls>"46+      ],47+      "js": [48+        "src/content/passkeys-firefox-relay.ts"49+      ],50+      "run_at": "document_start"4251     }4352   ],4453   "host_permissions": [

view file

src/content/index.ts+12 −8
@@ -20,6 +20,7 @@2020   showRememberSiteToast,2121 } from "./inject";2222 import { detectBrowser } from "../shared/browser";23+import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";2324 import { fillCardForm, fillFields } from "./fill";2425 import { setupSaveDetection } from "./save";2526 import { contentMLDetector } from "./content-ml-detector";@@ -934,15 +935,18 @@934935   return true;935936 });936937 937-// ── Firefox passkey detection bridge ─────────────────────────────────────938+// ── Firefox passkey detection bridge (legacy observer path) ─────────────938939 //939-// `src/content/passkeys-firefox.ts` runs in the page's MAIN world and940-// monkey-patches navigator.credentials. It postMessages here when the page941-// invokes create/get; we surface the inline registration offer (State 12)942-// after a per-domain decline + dedupe check. Only relevant on Firefox; on943-// Chrome the existing webAuthenticationProxy flow handles registration944-// without needing this bridge.945-{940+// Pre-v2.12.0 the MAIN-world `passkeys-firefox.ts` was an observer — it941+// emitted `peach-passkey-firefox` postMessages so this content script could942+// surface State 12 (an inline "Save passkey to Peach" offer). v2.12.0943+// replaced the observer with a full interception proxy (the page never944+// reaches a real navigator.credentials.create on Firefox anymore), and the945+// approval UI is opened by the background as part of the ceremony itself.946+// When the proxy is active the offer would be redundant noise, so the947+// listener gates on the flag. Once the proxy is removed (Firefox ships948+// webAuthenticationProxy) this whole block goes too.949+if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {946950   let lastPasskeyOfferAt = 0;947951   const PASSKEY_OFFER_COOLDOWN_MS = 30_000;948952 

view file

src/content/passkeys-firefox-relay.ts+47 −0
@@ -0,0 +1,47 @@1+/**2+ * passkeys-firefox-relay.ts — extension-world relay for the MAIN-world3+ * passkey proxy (passkeys-firefox.ts).4+ *5+ * The MAIN-world script cannot call chrome.runtime.sendMessage — it lives in6+ * the page's JS context, not the extension's. This relay listens for the7+ * MAIN-world's PEACH_PASSKEY_REQUEST window messages, forwards each payload8+ * to the background, and posts the result back as PEACH_PASSKEY_RESPONSE9+ * with the same request id.10+ *11+ * Trust boundary: anything the page can synthesize on `window.message` lands12+ * here. The `type` and `id` fields gate dispatch; the `payload` is forwarded13+ * verbatim to the background, which validates required fields and runs the14+ * Pro/vault checks before performing any cryptographic work. Vault data is15+ * never sent the other direction over postMessage — only the credential16+ * shape the RP needs.17+ *18+ * Removal: deleted alongside the MAIN-world proxy when Firefox ships19+ * chrome.webAuthenticationProxy. See src/shared/passkey-flags.ts.20+ */21+22+window.addEventListener("message", async (event: MessageEvent) => {23+  if (event.source !== window) return;24+  const data = event.data as25+    | { type?: string; id?: string; payload?: unknown }26+    | undefined;27+  if (!data || data.type !== "PEACH_PASSKEY_REQUEST") return;28+  const { id, payload } = data;29+  if (typeof id !== "string" || !payload || typeof payload !== "object") return;30+31+  const reply = (replyPayload: unknown) => {32+    window.postMessage(33+      { type: "PEACH_PASSKEY_RESPONSE", id, payload: replyPayload },34+      window.location.origin35+    );36+  };37+38+  try {39+    const response = await chrome.runtime.sendMessage(payload);40+    reply(response);41+  } catch {42+    // The background may be momentarily unreachable (service worker spin-up,43+    // extension reload). Tell the proxy to fall through to the platform44+    // rather than throwing into the page.45+    reply({ ok: false, error: "extension_error" });46+  }47+});

view file

src/content/passkeys-firefox.ts+305 −50
@@ -1,70 +1,325 @@11 /**2- * passkeys-firefox.ts — MAIN-world detection layer for Firefox 150+.2+ * passkeys-firefox.ts — MAIN-world WebAuthn interception for Firefox.33  *4- * Firefox does not expose `chrome.webAuthenticationProxy`, so Peach cannot5- * intercept WebAuthn calls the way it does on Chrome. Instead, this script6- * runs in the page's main world (per `manifest.firefox.json`'s `world: "MAIN"`)7- * and monkey-patches `navigator.credentials.create` / `.get` to *observe*8- * passkey activity and emit CustomEvents the rest of Peach can react to.4+ * Firefox does not yet expose `chrome.webAuthenticationProxy`, so Peach5+ * cannot intercept passkey ceremonies the way it does on Chrome. Until that6+ * API ships, this script runs in the page's main world (per7+ * `manifest.firefox.json`'s `world: "MAIN"`) and replaces8+ * `navigator.credentials.create` and `navigator.credentials.get`.99  *10- * The original calls always proceed unchanged. Peach never blocks, modifies,11- * or replaces the page's own WebAuthn flow from here. Registration and12- * authentication on behalf of the user happen separately, from the popup13- * context, where Firefox 150+ allows extensions to call `navigator.credentials`14- * directly with their own host-permitted RP ID.10+ * Architecture:11+ *   - This file owns the page-side patch. It cannot import anything that12+ *     touches extension APIs (different world). All extension-bound calls13+ *     go through window.postMessage to the relay content script14+ *     (passkeys-firefox-relay.ts) running in the extension world.15+ *   - The relay forwards each request to the background service worker via16+ *     chrome.runtime.sendMessage and posts the response back.17+ *   - The background owns the vault, runs the Pro gate, shows the approval18+ *     UI, performs the cryptographic operation, and returns a serialized19+ *     PublicKeyCredential payload.1520  *16- * Events emitted:17- *   `peach:passkey-create` — page invoked navigator.credentials.create with18- *                            a publicKey option set. detail = { rpId, userName }19- *   `peach:passkey-get`    — page invoked navigator.credentials.get with a20- *                            publicKey option set. detail = { rpId, allowCredentials }21+ * Origin guarantee: clientDataJSON is built here with `origin =22+ * window.location.origin`. The RP hashes the same bytes the background23+ * signed, and verifies origin against its registered RP — never against24+ * `moz-extension://`. This is what makes the assertion validate.25+ *26+ * Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime27+ * flag in src/shared/passkey-flags.ts evaluates to false and this entire28+ * file (along with the relay) gets deleted. See that file for the full29+ * checklist.2130  */2231 23-(() => {32+export function installPeachPasskeyProxy(): void {33+  const w = window as unknown as Record<string, unknown>;34+  if (w.__peachPasskeyProxyInstalled) return;35+  w.__peachPasskeyProxyInstalled = true;36+2437   const credentials = navigator.credentials;2538   if (!credentials || typeof credentials.create !== "function" || typeof credentials.get !== "function") {2639     return;2740   }2841 29-  const FLAG = "__peachPasskeysFirefoxAttached";30-  const w = window as unknown as Record<string, unknown>;31-  if (w[FLAG]) return;32-  w[FLAG] = true;33-3442   const originalCreate = credentials.create.bind(credentials);3543   const originalGet = credentials.get.bind(credentials);3644 37-  function emit(payload: Record<string, unknown>) {38-    try {39-      // postMessage crosses the MAIN/ISOLATED-world boundary on Firefox; a40-      // CustomEvent on `window` does not. Same-origin postMessage is the41-      // documented bridge between page-world detectors and content scripts.42-      window.postMessage({ source: "peach-passkey-firefox", ...payload }, location.origin);43-    } catch { /* postMessage must never break the page */ }44-  }45+  credentials.create = async function patchedCreate(46+    options?: CredentialCreationOptions47+  ): Promise<Credential | null> {48+    if (!options?.publicKey) return originalCreate(options);4549 46-  credentials.create = async function patchedCreate(options) {47-    const publicKey = options?.publicKey;48-    if (publicKey) {49-      emit({50-        type: "create",51-        rpId: publicKey.rp?.id ?? location.hostname,52-        rpName: publicKey.rp?.name,53-        userName: publicKey.user?.name,54-        userDisplayName: publicKey.user?.displayName,55-      });50+    const publicKey = options.publicKey;51+    const challenge = bufferToBase64url(toArrayBuffer(publicKey.challenge));52+    const clientDataJSON = encodeJsonBytes({53+      type: "webauthn.create",54+      challenge,55+      origin: window.location.origin,56+      crossOrigin: false,57+    });58+59+    const userId = bufferToBase64url(toArrayBuffer(publicKey.user.id));60+61+    const response = await sendToBackground({62+      type: "PASSKEY_CREATE",63+      payload: {64+        rpId: publicKey.rp.id ?? window.location.hostname,65+        rpName: publicKey.rp.name,66+        userId,67+        userName: publicKey.user.name,68+        userDisplayName: publicKey.user.displayName,69+        challenge,70+        clientDataJSON: Array.from(clientDataJSON),71+        origin: window.location.origin,72+        pubKeyCredParams: publicKey.pubKeyCredParams,73+        authenticatorSelection: publicKey.authenticatorSelection,74+        timeout: publicKey.timeout,75+        attestation: publicKey.attestation,76+      },77+    });78+79+    if (!response?.ok) {80+      const error = response?.error;81+      if (error === "user_cancelled") {82+        // The user denied via Peach. Match the WebAuthn contract — this is83+        // the same exception the platform throws when the user dismisses84+        // the OS prompt.85+        throw new DOMException(86+          "The operation either timed out or was not allowed.",87+          "NotAllowedError"88+        );89+      }90+      if (error === "pro_required") {91+        // Notify the extension-world script to show the inline Pro gate, then92+        // hand the call back to the platform so the user is not stranded.93+        window.dispatchEvent(94+          new CustomEvent("peach:passkey-pro-gate", { detail: { feature: "passkeys" } })95+        );96+        return originalCreate(options);97+      }98+      // vault_locked / no_credentials / not_applicable / timeout / unknown:99+      // fall through to the platform authenticator. Peach is not in a100+      // position to fulfill this ceremony right now and the user should101+      // still be able to register / authenticate against the RP.102+      return originalCreate(options);56103     }57-    return originalCreate(options);104+105+    return buildPublicKeyCredential(response.credential);58106   };59107 60-  credentials.get = async function patchedGet(options) {61-    const publicKey = options?.publicKey;62-    if (publicKey) {63-      emit({64-        type: "get",65-        rpId: publicKey.rpId ?? location.hostname,66-      });108+  credentials.get = async function patchedGet(109+    options?: CredentialRequestOptions110+  ): Promise<Credential | null> {111+    if (!options?.publicKey) return originalGet(options);112+113+    const publicKey = options.publicKey;114+    const rpId = publicKey.rpId ?? window.location.hostname;115+    const allowCredentials = (publicKey.allowCredentials ?? []).map((c) => ({116+      id: bufferToBase64url(toArrayBuffer(c.id)),117+      type: c.type,118+    }));119+120+    // Cheap pre-check before showing any Peach UI. If the vault has no match121+    // for this rpId, hand the ceremony to the platform — no point prompting.122+    const has = await sendToBackground({123+      type: "PASSKEY_HAS_CREDENTIALS",124+      payload: { rpId, allowCredentials },125+    });126+    if (!has?.ok || !has.hasCredentials) {127+      return originalGet(options);67128     }68-    return originalGet(options);129+130+    const challenge = bufferToBase64url(toArrayBuffer(publicKey.challenge));131+    const clientDataJSON = encodeJsonBytes({132+      type: "webauthn.get",133+      challenge,134+      origin: window.location.origin,135+      crossOrigin: false,136+    });137+138+    const response = await sendToBackground({139+      type: "PASSKEY_GET",140+      payload: {141+        rpId,142+        challenge,143+        clientDataJSON: Array.from(clientDataJSON),144+        origin: window.location.origin,145+        allowCredentials,146+        userVerification: publicKey.userVerification,147+      },148+    });149+150+    if (!response?.ok) {151+      const error = response?.error;152+      if (error === "user_cancelled") {153+        throw new DOMException(154+          "The operation either timed out or was not allowed.",155+          "NotAllowedError"156+        );157+      }158+      if (error === "pro_required") {159+        window.dispatchEvent(160+          new CustomEvent("peach:passkey-pro-gate", { detail: { feature: "passkeys" } })161+        );162+        return originalGet(options);163+      }164+      return originalGet(options);165+    }166+167+    return buildPublicKeyCredential(response.credential);69168   };70-})();169+170+  // ── helpers ────────────────────────────────────────────────────────────171+172+  function toArrayBuffer(view: BufferSource): ArrayBuffer {173+    if (view instanceof ArrayBuffer) return view;174+    const u8 = view as ArrayBufferView;175+    const out = new Uint8Array(u8.byteLength);176+    out.set(new Uint8Array(u8.buffer, u8.byteOffset, u8.byteLength));177+    return out.buffer;178+  }179+180+  function bufferToBase64url(buffer: ArrayBuffer): string {181+    const bytes = new Uint8Array(buffer);182+    let str = "";183+    for (let i = 0; i < bytes.length; i += 1) str += String.fromCharCode(bytes[i]!);184+    return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");185+  }186+187+  function base64urlToBuffer(value: string): ArrayBuffer {188+    const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(189+      Math.ceil(value.length / 4) * 4,190+      "="191+    );192+    const raw = atob(padded);193+    const bytes = new Uint8Array(raw.length);194+    for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i);195+    return bytes.buffer;196+  }197+198+  function encodeJsonBytes(value: unknown): Uint8Array {199+    return new TextEncoder().encode(JSON.stringify(value));200+  }201+202+  /**203+   * postMessage round-trip with a per-call UUID. The relay (extension-world204+   * content script) listens for PEACH_PASSKEY_REQUEST and posts back205+   * PEACH_PASSKEY_RESPONSE with the same id. A 60s timeout matches the206+   * background's approval window — outliving the user's decision time207+   * without leaking the listener forever.208+   */209+  function sendToBackground(message: object): Promise<any> {210+    return new Promise((resolve) => {211+      const id = generateRequestId();212+      const handler = (event: MessageEvent) => {213+        if (event.source !== window) return;214+        const data = event.data;215+        if (!data || typeof data !== "object") return;216+        if ((data as { type?: string }).type !== "PEACH_PASSKEY_RESPONSE") return;217+        if ((data as { id?: string }).id !== id) return;218+        cleanup();219+        resolve((data as { payload?: unknown }).payload);220+      };221+      const timer = setTimeout(() => {222+        cleanup();223+        resolve({ ok: false, error: "timeout" });224+      }, 60_000);225+      function cleanup() {226+        window.removeEventListener("message", handler);227+        clearTimeout(timer);228+      }229+      window.addEventListener("message", handler);230+      window.postMessage({ type: "PEACH_PASSKEY_REQUEST", id, payload: message }, "*");231+    });232+  }233+234+  function generateRequestId(): string {235+    if (typeof crypto.randomUUID === "function") return crypto.randomUUID();236+    const bytes = crypto.getRandomValues(new Uint8Array(16));237+    return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");238+  }239+240+  /**241+   * Reconstruct a PublicKeyCredential-shaped object from the background's242+   * serialized response. The shape must satisfy what RPs read off the243+   * credential — id, rawId, response.{clientDataJSON, attestationObject |244+   * authenticatorData, signature, userHandle}, plus the helper getters that245+   * some RPs call (getPublicKey, getTransports, etc.).246+   *247+   * `instanceof PublicKeyCredential` will be false because we cannot248+   * construct a real one from page script. RPs that perform that check249+   * directly will reject — but no major RP does, because the original250+   * Spec/JS-API path on iOS Safari produced non-instance credentials for251+   * years. RPs read the duck-typed shape.252+   */253+  function buildPublicKeyCredential(cred: any): PublicKeyCredential {254+    const credentialId = base64urlToBuffer(cred.id);255+    const clientDataJSON = new Uint8Array(cred.clientDataJSON).buffer;256+257+    if (cred.type === "create") {258+      const attestationObject = base64urlToBuffer(cred.attestationObject);259+      const authenticatorData = base64urlToBuffer(cred.authenticatorData);260+      const publicKey = base64urlToBuffer(cred.publicKey);261+      return {262+        id: cred.id,263+        rawId: credentialId,264+        type: "public-key",265+        authenticatorAttachment: "platform",266+        response: {267+          clientDataJSON,268+          attestationObject,269+          getTransports: () => ["internal"],270+          getAuthenticatorData: () => authenticatorData,271+          getPublicKey: () => publicKey,272+          getPublicKeyAlgorithm: () => cred.algorithm ?? -7,273+        } as AuthenticatorAttestationResponse,274+        getClientExtensionResults: () => ({}),275+        toJSON: () => ({276+          id: cred.id,277+          rawId: cred.id,278+          type: "public-key",279+          response: {280+            clientDataJSON: bufferToBase64url(clientDataJSON),281+            attestationObject: cred.attestationObject,282+            authenticatorData: cred.authenticatorData,283+            publicKey: cred.publicKey,284+            publicKeyAlgorithm: cred.algorithm ?? -7,285+            transports: ["internal"],286+          },287+          authenticatorAttachment: "platform",288+          clientExtensionResults: {},289+        }),290+      } as unknown as PublicKeyCredential;291+    }292+293+    const authenticatorData = base64urlToBuffer(cred.authenticatorData);294+    const signature = base64urlToBuffer(cred.signature);295+    const userHandle = cred.userHandle ? base64urlToBuffer(cred.userHandle) : null;296+    return {297+      id: cred.id,298+      rawId: credentialId,299+      type: "public-key",300+      authenticatorAttachment: "platform",301+      response: {302+        clientDataJSON,303+        authenticatorData,304+        signature,305+        userHandle,306+      } as AuthenticatorAssertionResponse,307+      getClientExtensionResults: () => ({}),308+      toJSON: () => ({309+        id: cred.id,310+        rawId: cred.id,311+        type: "public-key",312+        response: {313+          clientDataJSON: bufferToBase64url(clientDataJSON),314+          authenticatorData: cred.authenticatorData,315+          signature: cred.signature,316+          userHandle: cred.userHandle ?? null,317+        },318+        authenticatorAttachment: "platform",319+        clientExtensionResults: {},320+      }),321+    } as unknown as PublicKeyCredential;322+  }323+}324+325+installPeachPasskeyProxy();

view file