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
↓ peach-browser-cca2dc2f.tar.gzatlas at this save
files changed4 files
manifest.firefox.json+9 −0src/content/index.ts+12 −8src/content/passkeys-firefox-relay.ts+47 −0src/content/passkeys-firefox.ts+305 −50manifest.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": [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 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+});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();