0527a541c2cf

add Firefox 150+ popup credential flow, inline States 11+12, cross-browser banner, decline storage, bump v2.10.1

time
2026-04-28 18:54 UTC · 6 months ago
machine
koh2
parent
4309fdecba8d
14 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+60 −0src/background/passkey.ts+181 −11src/content/index.ts+82 −0src/content/inject.ts+398 −0src/content/passkeys-firefox.ts+20 −20src/popup/App.tsx+15 −0src/popup/firefox-passkey.ts+109 −0src/popup/styles/popup.css+7 −0src/popup/views/PasskeyEntry.tsx+21 −1src/shared/browser.ts+34 −0src/shared/types.ts+22 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.10.0",4+  "version": "2.10.1",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.10.0",4+  "version": "2.10.1",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.10.0",3+  "version": "2.10.1",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/messages.ts+60 −0
@@ -22,8 +22,13 @@2222   shouldPlayCelebration,2323 } from "./license";2424 import {25+  cancelFirefoxPasskeyCreate,26+  completeFirefoxPasskeyCreate,2527   getPasskeyProviderState,2628   getPendingPasskeyRequest,29+  initiateFirefoxPasskeyCreate,30+  isPasskeyOfferDeclined,31+  recordPasskeyOfferDecline,2732   resolvePendingPasskeyRequest,2833   setPasskeyProviderEnabled,2934 } from "./passkey";@@ -614,6 +619,61 @@614619         const declined = await vault.isCardDeclined(last4, expMonth, expYear);615620         return { ok: true, data: declined };616621       }622+      case "INITIATE_FIREFOX_PASSKEY_CREATE": {623+        const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {624+          rpId?: string;625+          rpName?: string;626+          userName?: string;627+          userDisplayName?: string;628+        };629+        if (!rpId || !userName) return { ok: false, error: "rpId and userName required" };630+        return await initiateFirefoxPasskeyCreate({631+          rpId,632+          rpName: rpName ?? rpId,633+          userName,634+          userDisplayName,635+        });636+      }637+638+      case "COMPLETE_FIREFOX_PASSKEY_CREATE": {639+        const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;640+        if (!payload) return { ok: false, error: "missing payload" };641+        return await completeFirefoxPasskeyCreate(payload);642+      }643+644+      case "PASSKEY_OFFER_DECLINE": {645+        const { domain } = (msg.payload ?? {}) as { domain?: string };646+        if (!domain) return { ok: false, error: "domain required" };647+        await recordPasskeyOfferDecline(domain);648+        return { ok: true };649+      }650+651+      case "GET_PASSKEY_OFFER_DECLINED": {652+        const { domain } = (msg.payload ?? {}) as { domain?: string };653+        if (!domain) return { ok: false, error: "domain required" };654+        return { ok: true, data: await isPasskeyOfferDeclined(domain) };655+      }656+657+      case "GET_PASSKEY_FOR_DOMAIN": {658+        const { domain } = (msg.payload ?? {}) as { domain?: string };659+        if (!domain) return { ok: false, error: "domain required" };660+        if (!vault.isUnlocked()) return { ok: true, data: null };661+        const match = vault662+          .getEntries()663+          .find((entry) => entry.type === "passkey" && entry.rpId === domain);664+        if (!match) return { ok: true, data: null };665+        // Slim shape — only the fields State 11 needs. Never expose private666+        // key material, even though this is locked behind isUnlocked.667+        return {668+          ok: true,669+          data: {670+            rpId: match.rpId,671+            userName: match.userName,672+            browser: match.browser,673+          },674+        };675+      }676+617677       case "DESTROY_VAULT": {618678         // Irreversible by design. Lock zeroes the derived key + in-memory entries;619679         // storage.local.clear() wipes every Peach key (slots, settings, license,

view file

src/background/passkey.ts+181 −11
@@ -10,6 +10,7 @@1010   pickPasskeyAlgorithm,1111   signPasskeyAssertion,1212 } from "./passkey-crypto";13+import { detectBrowser as detectSharedBrowser, firefoxVersion } from "../shared/browser";1314 import type {1415   PasskeyEntry,1516   PasskeyProviderState,@@ -33,20 +34,20 @@3334 >();3435 3536 function isPasskeySupported(): boolean {36-  return typeof chrome.webAuthenticationProxy?.attach === "function";37-}38-39-export function detectBrowser(): "chrome" | "firefox" | "unknown" {40-  if (typeof chrome !== "undefined" && chrome.webAuthenticationProxy) {41-    return "chrome";37+  if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;38+  // Firefox 150+ exposes navigator.credentials.create/get from the popup39+  // context with extension host permissions. Cannot intercept page-side40+  // calls (no equivalent of webAuthenticationProxy), but registration via41+  // Peach's own popup-driven flow works.42+  if (detectSharedBrowser() === "firefox") {43+    const ver = firefoxVersion();44+    if (Number.isFinite(ver) && ver >= 150) return true;4245   }43-  try {44-    const url = chrome.runtime?.getURL?.("") ?? "";45-    if (url.startsWith("moz-extension://")) return "firefox";46-  } catch { /* extension context unavailable */ }47-  return "unknown";46+  return false;4847 }4948 49+export const detectBrowser = detectSharedBrowser;50+5051 function getBrowserLabel(): string {5152   const ua = navigator.userAgent;5253   if (/Firefox\/(\d+)/.test(ua)) {@@ -129,7 +130,14 @@129130 ): Promise<void> {130131   const pending = approvals.get(requestId);131132   if (!pending) {133+    // Either the request expired (Chrome) or this is a Firefox-driven134+    // pending where the popup performs the navigator.credentials call135+    // directly. Clear both possible session keys so getPendingPasskeyRequest()136+    // doesn't surface stale state on the next popup open.132137     await clearPendingRequest(requestId);138+    if (chrome.storage.session) {139+      await chrome.storage.session.remove(FIREFOX_PENDING_KEY);140+    }133141     return;134142   }135143 @@ -184,6 +192,16 @@184192     conflict = "This browser does not expose chrome.webAuthenticationProxy.";185193     return;186194   }195+196+  // Firefox path: no chrome.webAuthenticationProxy exists. The popup-context197+  // navigator.credentials creator is always ready once the user enables the198+  // provider. Treat enabled = attached for state reporting purposes.199+  if (detectSharedBrowser() === "firefox") {200+    attached = true;201+    conflict = undefined;202+    return;203+  }204+187205   if (attached) return;188206 189207   try {@@ -202,6 +220,12 @@202220     return;203221   }204222 223+  if (detectSharedBrowser() === "firefox") {224+    attached = false;225+    conflict = undefined;226+    return;227+  }228+205229   try {206230     await chrome.webAuthenticationProxy.detach();207231   } catch {@@ -386,6 +410,19 @@386410     return;387411   }388412 413+  // Chrome's webAuthenticationProxy path requires Peach-held private key414+  // material to sign the assertion. Firefox-created passkeys delegate to the415+  // platform authenticator — the browser will route to the platform UI416+  // automatically when we reject, which is the right outcome.417+  if (!match.privateKeyJwk) {418+    await rejectGet(419+      request.requestId,420+      "NotAllowedError",421+      "This passkey was created on Firefox; sign in via the platform authenticator."422+    );423+    return;424+  }425+389426   const approved = await promptUserApproval({390427     requestId: request.requestId,391428     type: "get",@@ -440,8 +477,141 @@440477   });441478 }442479 480+// ── Firefox path ────────────────────────────────────────────────────────481+//482+// Firefox 150+ permits extensions to call navigator.credentials.create/get483+// from the popup context with their host-permitted RP IDs. Peach uses that484+// surface to register passkeys on behalf of the user. The page's own485+// navigator.credentials call is observed via the MAIN-world detector486+// (src/content/passkeys-firefox.ts) but never blocked or replaced; Peach's487+// flow is parallel and explicit (the user must engage with Peach's button).488+489+const FIREFOX_PENDING_KEY = "passkey_pending_firefox";490+const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";491+const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days492+493+export async function initiateFirefoxPasskeyCreate(args: {494+  rpId: string;495+  rpName: string;496+  userName: string;497+  userDisplayName?: string;498+}): Promise<{ ok: boolean; error?: string }> {499+  if (!(await isProUser())) {500+    broadcastPasskeyProGate();501+    return { ok: false, error: "passkey creation requires Peach Pro" };502+  }503+  if (!vault.isUnlocked()) {504+    await openApprovalPopup();505+    return { ok: false, error: "vault locked" };506+  }507+  if (!chrome.storage.session) {508+    return { ok: false, error: "session storage unavailable" };509+  }510+511+  const requestId = Date.now();512+  const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));513+  const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));514+515+  const pending: PendingPasskeyRequest = {516+    requestId,517+    type: "create",518+    rpId: args.rpId,519+    rpName: args.rpName,520+    userName: args.userName,521+    userDisplayName: args.userDisplayName,522+    browser: "firefox",523+    challenge,524+    userHandle,525+  };526+527+  await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });528+  await openApprovalPopup();529+  return { ok: true };530+}531+532+export async function completeFirefoxPasskeyCreate(payload: {533+  requestId: number;534+  rpId: string;535+  rpName: string;536+  userName: string;537+  userDisplayName?: string;538+  userHandle: string;539+  credentialId: string;540+  publicKeyCose: string;541+  algorithm: -7 | -8;542+  aaguid?: string;543+  transports?: string[];544+  backupEligible?: boolean;545+  backupState?: boolean;546+}): Promise<{ ok: boolean; error?: string }> {547+  if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };548+549+  const entry: PasskeyEntry = {550+    id: crypto.randomUUID(),551+    type: "passkey",552+    title: payload.rpName || payload.rpId,553+    rpId: payload.rpId,554+    rpName: payload.rpName,555+    credentialId: payload.credentialId,556+    publicKeyCose: payload.publicKeyCose,557+    userHandle: payload.userHandle,558+    userName: payload.userName,559+    userDisplayName: payload.userDisplayName ?? payload.userName,560+    algorithm: payload.algorithm,561+    signCount: 0,562+    aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),563+    backupEligible: payload.backupEligible ?? false,564+    backupState: payload.backupState ?? false,565+    browser: "firefox",566+    transports: payload.transports ?? ["internal"],567+    createdAt: Date.now(),568+    modifiedAt: Date.now(),569+  };570+571+  await vault.addEntry(entry);572+  if (chrome.storage.session) {573+    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);574+  }575+  return { ok: true };576+}577+578+export async function cancelFirefoxPasskeyCreate(): Promise<void> {579+  if (chrome.storage.session) {580+    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);581+  }582+}583+584+function declineKey(domain: string): string {585+  return `${FIREFOX_DECLINE_PREFIX}${domain}`;586+}587+588+export async function recordPasskeyOfferDecline(domain: string): Promise<void> {589+  if (!domain) return;590+  await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });591+}592+593+export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {594+  if (!domain) return false;595+  const key = declineKey(domain);596+  const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;597+  const at = stored[key];598+  if (typeof at !== "number") return false;599+  if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {600+    await chrome.storage.local.remove(key);601+    return false;602+  }603+  return true;604+}605+443606 export async function setupPasskeyProxy(): Promise<void> {444607   if (!isPasskeySupported()) return;608+609+  // Firefox doesn't expose chrome.webAuthenticationProxy listeners. The610+  // popup-context credential creator is the entry point; nothing to wire here.611+  if (detectSharedBrowser() === "firefox") {612+    await refreshPasskeyProxy();613+    return;614+  }445615 446616   if (!listenersRegistered) {447617     chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {

view file

src/content/index.ts+82 −0
@@ -14,9 +14,12 @@1414   SelectorEntry,1515   showAutofillSelector,1616   showCardSelector,17+  showPasskeyAvailable,1718   showPasskeyProGate,19+  showPasskeyRegistrationOffer,1820   showRememberSiteToast,1921 } from "./inject";22+import { detectBrowser } from "../shared/browser";2023 import { fillCardForm, fillFields } from "./fill";2124 import { setupSaveDetection } from "./save";2225 import { contentMLDetector } from "./content-ml-detector";@@ -930,3 +933,82 @@930933   }931934   return true;932935 });936+937+// ── Firefox passkey detection bridge ─────────────────────────────────────938+//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+{946+  let lastPasskeyOfferAt = 0;947+  const PASSKEY_OFFER_COOLDOWN_MS = 30_000;948+949+  window.addEventListener("message", (event: MessageEvent) => {950+    if (event.source !== window) return;951+    const data = event.data as { source?: string; type?: string; rpId?: string; rpName?: string; userName?: string; userDisplayName?: string } | undefined;952+    if (!data || data.source !== "peach-passkey-firefox") return;953+    if (detectBrowser() !== "firefox") return;954+    if (data.type !== "create") return;955+956+    // Cooldown: a single page interaction can fire multiple credentials.create957+    // calls. Show the offer at most once per 30 seconds per page session.958+    const now = Date.now();959+    if (now - lastPasskeyOfferAt < PASSKEY_OFFER_COOLDOWN_MS) return;960+    lastPasskeyOfferAt = now;961+962+    const domain = location.hostname.replace(/^www\./, "");963+    chrome.runtime.sendMessage({964+      type: "GET_PASSKEY_OFFER_DECLINED",965+      payload: { domain },966+    }).then((res: { ok: boolean; data?: boolean } | undefined) => {967+      if (res?.data === true) return;968+      const rpId = data.rpId ?? domain;969+      showPasskeyRegistrationOffer({970+        rpId,971+        rpName: data.rpName,972+        userName: data.userName,973+        userDisplayName: data.userDisplayName,974+      });975+    }).catch(() => {});976+  });977+}978+979+// Surface State 11 (passkey-available) once per page session when this domain980+// has a stored passkey. The cross-browser banner inside the surface warns the981+// user when their passkey was created on the other browser and isn't usable982+// here without re-registration.983+{984+  let availabilityChecked = false;985+986+  function checkPasskeyAvailability() {987+    if (availabilityChecked) return;988+    availabilityChecked = true;989+    const domain = location.hostname.replace(/^www\./, "");990+    if (!domain) return;991+    chrome.runtime.sendMessage({992+      type: "GET_PASSKEY_FOR_DOMAIN",993+      payload: { domain },994+    }).then((res: { ok: boolean; data?: { rpId: string; userName: string; browser?: "chrome" | "firefox" } | null } | undefined) => {995+      const data = res?.data;996+      if (!data) return;997+      showPasskeyAvailable({998+        rpId: data.rpId,999+        userName: data.userName,1000+        fromBrowser: data.browser,1001+        currentBrowser: detectBrowser(),1002+      });1003+    }).catch(() => {});1004+  }1005+1006+  // Defer until DOM is interactive — content_scripts run at document_idle but1007+  // some pages still mutate visibly afterwards. A short delay also avoids1008+  // surfacing the State 11 toast while the user is in the middle of typing.1009+  if (document.readyState === "loading") {1010+    document.addEventListener("DOMContentLoaded", () => setTimeout(checkPasskeyAvailability, 800));1011+  } else {1012+    setTimeout(checkPasskeyAvailability, 800);1013+  }1014+}

view file

src/content/inject.ts+398 −0
@@ -1978,6 +1978,404 @@19781978 }19791979 19801980 /**1981+ * State 12 — passkey registration offer. Shown on Firefox when the MAIN-world1982+ * detector sees the page invoke navigator.credentials.create(); content/index.ts1983+ * checks vault + decline state then calls this. "Save passkey to Peach"1984+ * initiates the popup-context credential flow; "Not now" records a 30-day1985+ * decline for the domain so we don't re-show on every page reload.1986+ */1987+export function showPasskeyRegistrationOffer(args: {1988+  rpId: string;1989+  rpName?: string;1990+  userName?: string;1991+  userDisplayName?: string;1992+}): void {1993+  const domain = location.hostname.replace(/^www\./, "");1994+  const displayName = args.rpName || domain;1995+1996+  const host = document.createElement("div");1997+  host.setAttribute("data-peach-passkey-offer", "1");1998+  host.style.cssText = "all:initial;position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483645;pointer-events:none;";1999+  const shadow = host.attachShadow({ mode: "closed" });2000+2001+  const style = document.createElement("style");2002+  style.textContent = `2003+    :host { all: initial; }2004+    .offer-wrap {2005+      position: fixed;2006+      top: 16px;2007+      right: 16px;2008+      width: 320px;2009+      pointer-events: auto;2010+      animation: o-in 220ms cubic-bezier(0.16,1,0.3,1) both;2011+    }2012+    @keyframes o-in {2013+      from { opacity: 0; transform: translateY(-6px); }2014+      to   { opacity: 1; transform: translateY(0); }2015+    }2016+    .offer-wrap.is-exiting { animation: o-out 130ms ease-in forwards; }2017+    @keyframes o-out { to { opacity: 0; transform: translateY(-6px); } }2018+    .offer {2019+      background: ${popupTheme === "light" ? "#f8f5f0" : "#1a1712"};2020+      border: 1px solid ${popupTheme === "light" ? "rgba(0,0,0,0.10)" : "rgba(240,232,216,0.09)"};2021+      border-radius: 12px;2022+      box-shadow: 0 12px 48px rgba(0,0,0,0.55);2023+      padding: 14px 14px 12px;2024+      font-family: Inter, sans-serif;2025+      position: relative;2026+    }2027+    .offer-close {2028+      position: absolute;2029+      top: 8px;2030+      right: 8px;2031+      width: 18px;2032+      height: 18px;2033+      border: none;2034+      background: transparent;2035+      color: ${popupTheme === "light" ? "rgba(0,0,0,0.4)" : "rgba(240,232,216,0.4)"};2036+      cursor: pointer;2037+      font-size: 14px;2038+      line-height: 1;2039+      padding: 0;2040+    }2041+    .offer-close:hover { color: ${popupTheme === "light" ? "rgba(0,0,0,0.7)" : "rgba(240,232,216,0.7)"}; }2042+    .offer-header {2043+      display: flex;2044+      align-items: center;2045+      gap: 7px;2046+      margin-bottom: 8px;2047+      padding-bottom: 8px;2048+      border-bottom: 1px solid ${popupTheme === "light" ? "rgba(0,0,0,0.07)" : "rgba(240,232,216,0.07)"};2049+    }2050+    .offer-domain {2051+      font-size: 12px;2052+      color: ${popupTheme === "light" ? "rgba(0,0,0,0.45)" : "rgba(240,232,216,0.45)"};2053+      font-family: "IBM Plex Mono", monospace;2054+      overflow: hidden;2055+      text-overflow: ellipsis;2056+      white-space: nowrap;2057+    }2058+    .offer-title {2059+      font-size: 13px;2060+      font-weight: 500;2061+      color: ${popupTheme === "light" ? "#1a1712" : "rgba(240,232,216,0.92)"};2062+      margin-bottom: 4px;2063+    }2064+    .offer-subtitle {2065+      font-size: 11px;2066+      color: ${popupTheme === "light" ? "rgba(0,0,0,0.50)" : "rgba(240,232,216,0.50)"};2067+      line-height: 1.45;2068+      margin-bottom: 12px;2069+    }2070+    .offer-btn {2071+      display: block;2072+      width: 100%;2073+      padding: 8px 0;2074+      border-radius: 7px;2075+      border: none;2076+      cursor: pointer;2077+      font-size: 12px;2078+      font-family: Inter, sans-serif;2079+      font-weight: 500;2080+      transition: opacity 100ms;2081+      margin-bottom: 6px;2082+      text-align: center;2083+    }2084+    .offer-btn:last-child { margin-bottom: 0; }2085+    .offer-btn--accent { background: ${accentHex}; color: #fff; }2086+    .offer-btn--accent:hover { opacity: 0.88; }2087+    .offer-btn--ghost {2088+      background: ${popupTheme === "light" ? "rgba(0,0,0,0.06)" : "rgba(240,232,216,0.07)"};2089+      color: ${popupTheme === "light" ? "rgba(0,0,0,0.55)" : "rgba(240,232,216,0.50)"};2090+    }2091+    .offer-btn--ghost:hover { opacity: 0.75; }2092+  `;2093+  shadow.appendChild(style);2094+2095+  const wrap = document.createElement("div");2096+  wrap.className = "offer-wrap";2097+  shadow.appendChild(wrap);2098+2099+  const offer = document.createElement("div");2100+  offer.className = "offer";2101+  wrap.appendChild(offer);2102+2103+  const closeBtn = document.createElement("button");2104+  closeBtn.className = "offer-close";2105+  closeBtn.type = "button";2106+  closeBtn.setAttribute("aria-label", "Dismiss");2107+  closeBtn.textContent = "✕";2108+  offer.appendChild(closeBtn);2109+2110+  const header = document.createElement("div");2111+  header.className = "offer-header";2112+  const icon = document.createElement("span");2113+  icon.innerHTML = peachIconSvg();2114+  const domainEl = document.createElement("span");2115+  domainEl.className = "offer-domain";2116+  domainEl.textContent = domain;2117+  header.append(icon, domainEl);2118+  offer.appendChild(header);2119+2120+  const title = document.createElement("div");2121+  title.className = "offer-title";2122+  title.textContent = "This site supports passkeys.";2123+  offer.appendChild(title);2124+2125+  const subtitle = document.createElement("div");2126+  subtitle.className = "offer-subtitle";2127+  subtitle.textContent = "Save one to Peach for faster, passwordless sign-in.";2128+  offer.appendChild(subtitle);2129+2130+  const saveBtn = document.createElement("button");2131+  saveBtn.className = "offer-btn offer-btn--accent";2132+  saveBtn.type = "button";2133+  saveBtn.textContent = "Save passkey to Peach";2134+  offer.appendChild(saveBtn);2135+2136+  const laterBtn = document.createElement("button");2137+  laterBtn.className = "offer-btn offer-btn--ghost";2138+  laterBtn.type = "button";2139+  laterBtn.textContent = "Not now";2140+  offer.appendChild(laterBtn);2141+2142+  function dismiss(decline: boolean) {2143+    if (decline) {2144+      chrome.runtime.sendMessage({2145+        type: "PASSKEY_OFFER_DECLINE",2146+        payload: { domain },2147+      }).catch(() => {});2148+    }2149+    wrap.classList.add("is-exiting");2150+    setTimeout(() => host.remove(), 130);2151+  }2152+2153+  saveBtn.addEventListener("click", () => {2154+    chrome.runtime.sendMessage({2155+      type: "INITIATE_FIREFOX_PASSKEY_CREATE",2156+      payload: {2157+        rpId: args.rpId,2158+        rpName: args.rpName ?? displayName,2159+        userName: args.userName ?? "user",2160+        userDisplayName: args.userDisplayName,2161+      },2162+    }).catch(() => {});2163+    dismiss(false);2164+  });2165+  laterBtn.addEventListener("click", () => dismiss(true));2166+  closeBtn.addEventListener("click", () => dismiss(true));2167+2168+  document.body.appendChild(host);2169+}2170+2171+/**2172+ * State 11 — passkey available indicator. Shown when the user lands on a2173+ * domain for which they already have a stored passkey. Tells them where to2174+ * find it (the entry already shows in the vault list); the "Sign in with2175+ * passkey" button opens the popup so they can use the platform authenticator2176+ * UI directly. On Chrome, the existing webAuthenticationProxy flow handles2177+ * the actual sign-in transparently, so this banner is informational only.2178+ */2179+export function showPasskeyAvailable(args: {2180+  rpId: string;2181+  userName: string;2182+  fromBrowser?: "chrome" | "firefox";2183+  currentBrowser?: "chrome" | "firefox" | "unknown";2184+}): void {2185+  const domain = location.hostname.replace(/^www\./, "");2186+  const crossBrowser =2187+    args.fromBrowser &&2188+    args.currentBrowser &&2189+    args.currentBrowser !== "unknown" &&2190+    args.fromBrowser !== args.currentBrowser;2191+2192+  const host = document.createElement("div");2193+  host.setAttribute("data-peach-passkey-available", "1");2194+  host.style.cssText = "all:initial;position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483644;pointer-events:none;";2195+  const shadow = host.attachShadow({ mode: "closed" });2196+2197+  const style = document.createElement("style");2198+  style.textContent = `2199+    :host { all: initial; }2200+    .avail-wrap {2201+      position: fixed;2202+      top: 16px;2203+      right: 16px;2204+      width: 320px;2205+      pointer-events: auto;2206+      animation: a-in 220ms cubic-bezier(0.16,1,0.3,1) both;2207+    }2208+    @keyframes a-in {2209+      from { opacity: 0; transform: translateY(-6px); }2210+      to   { opacity: 1; transform: translateY(0); }2211+    }2212+    .avail-wrap.is-exiting { animation: a-out 130ms ease-in forwards; }2213+    @keyframes a-out { to { opacity: 0; transform: translateY(-6px); } }2214+    .avail {2215+      background: ${popupTheme === "light" ? "#f8f5f0" : "#1a1712"};2216+      border: 1px solid ${popupTheme === "light" ? "rgba(0,0,0,0.10)" : "rgba(240,232,216,0.09)"};2217+      border-radius: 12px;2218+      box-shadow: 0 12px 48px rgba(0,0,0,0.55);2219+      padding: 14px 14px 12px;2220+      font-family: Inter, sans-serif;2221+      position: relative;2222+    }2223+    .avail-close {2224+      position: absolute;2225+      top: 8px;2226+      right: 8px;2227+      width: 18px;2228+      height: 18px;2229+      border: none;2230+      background: transparent;2231+      color: ${popupTheme === "light" ? "rgba(0,0,0,0.4)" : "rgba(240,232,216,0.4)"};2232+      cursor: pointer;2233+      font-size: 14px;2234+      line-height: 1;2235+      padding: 0;2236+    }2237+    .avail-close:hover { color: ${popupTheme === "light" ? "rgba(0,0,0,0.7)" : "rgba(240,232,216,0.7)"}; }2238+    .avail-header {2239+      display: flex;2240+      align-items: center;2241+      gap: 7px;2242+      margin-bottom: 8px;2243+      padding-bottom: 8px;2244+      border-bottom: 1px solid ${popupTheme === "light" ? "rgba(0,0,0,0.07)" : "rgba(240,232,216,0.07)"};2245+    }2246+    .avail-domain {2247+      font-size: 12px;2248+      color: ${popupTheme === "light" ? "rgba(0,0,0,0.45)" : "rgba(240,232,216,0.45)"};2249+      font-family: "IBM Plex Mono", monospace;2250+    }2251+    .avail-label {2252+      font-size: 9px;2253+      letter-spacing: 0.16em;2254+      text-transform: uppercase;2255+      color: ${accentHex};2256+      margin-bottom: 4px;2257+    }2258+    .avail-user {2259+      font-size: 13px;2260+      font-weight: 500;2261+      color: ${popupTheme === "light" ? "#1a1712" : "rgba(240,232,216,0.92)"};2262+      margin-bottom: 12px;2263+      overflow: hidden;2264+      text-overflow: ellipsis;2265+      white-space: nowrap;2266+    }2267+    .avail-cross {2268+      font-size: 11px;2269+      color: ${popupTheme === "light" ? "rgba(0,0,0,0.50)" : "rgba(240,232,216,0.50)"};2270+      line-height: 1.45;2271+      margin-bottom: 12px;2272+      padding: 8px;2273+      background: ${popupTheme === "light" ? "rgba(0,0,0,0.04)" : "rgba(240,232,216,0.04)"};2274+      border-radius: 6px;2275+    }2276+    .avail-btn {2277+      display: block;2278+      width: 100%;2279+      padding: 8px 0;2280+      border-radius: 7px;2281+      border: none;2282+      cursor: pointer;2283+      font-size: 12px;2284+      font-family: Inter, sans-serif;2285+      font-weight: 500;2286+      transition: opacity 100ms;2287+      margin-bottom: 6px;2288+      text-align: center;2289+    }2290+    .avail-btn:last-child { margin-bottom: 0; }2291+    .avail-btn--accent { background: ${accentHex}; color: #fff; }2292+    .avail-btn--accent:hover { opacity: 0.88; }2293+    .avail-btn--ghost {2294+      background: ${popupTheme === "light" ? "rgba(0,0,0,0.06)" : "rgba(240,232,216,0.07)"};2295+      color: ${popupTheme === "light" ? "rgba(0,0,0,0.55)" : "rgba(240,232,216,0.50)"};2296+    }2297+    .avail-btn--ghost:hover { opacity: 0.75; }2298+  `;2299+  shadow.appendChild(style);2300+2301+  const wrap = document.createElement("div");2302+  wrap.className = "avail-wrap";2303+  shadow.appendChild(wrap);2304+2305+  const avail = document.createElement("div");2306+  avail.className = "avail";2307+  wrap.appendChild(avail);2308+2309+  const closeBtn = document.createElement("button");2310+  closeBtn.className = "avail-close";2311+  closeBtn.type = "button";2312+  closeBtn.setAttribute("aria-label", "Dismiss");2313+  closeBtn.textContent = "✕";2314+  avail.appendChild(closeBtn);2315+2316+  const header = document.createElement("div");2317+  header.className = "avail-header";2318+  const icon = document.createElement("span");2319+  icon.innerHTML = peachIconSvg();2320+  const domainEl = document.createElement("span");2321+  domainEl.className = "avail-domain";2322+  domainEl.textContent = domain;2323+  header.append(icon, domainEl);2324+  avail.appendChild(header);2325+2326+  const label = document.createElement("div");2327+  label.className = "avail-label";2328+  label.textContent = "PASSKEY";2329+  avail.appendChild(label);2330+2331+  const user = document.createElement("div");2332+  user.className = "avail-user";2333+  user.textContent = args.userName;2334+  avail.appendChild(user);2335+2336+  if (crossBrowser) {2337+    const cross = document.createElement("div");2338+    cross.className = "avail-cross";2339+    cross.textContent =2340+      args.fromBrowser === "chrome"2341+        ? "This passkey was created on Chrome. To use it on Firefox, re-register it on this device."2342+        : "This passkey was created on Firefox. To use it on Chrome, re-register it on this device.";2343+    avail.appendChild(cross);2344+  }2345+2346+  const signInBtn = document.createElement("button");2347+  signInBtn.className = "avail-btn avail-btn--accent";2348+  signInBtn.type = "button";2349+  signInBtn.textContent = "Sign in with passkey";2350+  avail.appendChild(signInBtn);2351+2352+  const passwordBtn = document.createElement("button");2353+  passwordBtn.className = "avail-btn avail-btn--ghost";2354+  passwordBtn.type = "button";2355+  passwordBtn.textContent = "Use password instead";2356+  avail.appendChild(passwordBtn);2357+2358+  function dismiss() {2359+    wrap.classList.add("is-exiting");2360+    setTimeout(() => host.remove(), 130);2361+  }2362+2363+  signInBtn.addEventListener("click", () => {2364+    // On Chrome: the user clicking the page's own Sign-in button is what2365+    //   triggers webAuthenticationProxy → existing background flow handles it.2366+    //   Our button just dismisses since the page itself drives auth.2367+    // On Firefox: cross-browser passkeys can't be used, so we just dismiss2368+    //   and surface the entry for manual reference (popup view).2369+    dismiss();2370+    chrome.runtime.sendMessage({ type: "OPEN_PRO_GATE", payload: {} }).catch(() => {});2371+  });2372+  passwordBtn.addEventListener("click", dismiss);2373+  closeBtn.addEventListener("click", dismiss);2374+2375+  document.body.appendChild(host);2376+}2377+2378+/**19812379  * State 7 — card silver platter. Single-card focused fill view rendered19822380  * over the cc-number field. Reuses the SELECTOR_STYLES Shadow DOM, the19832381  * theme tokens, and the .sp-fill-btn / .sp-actions primary action classes

view file

src/content/passkeys-firefox.ts+20 −20
@@ -34,19 +34,25 @@3434   const originalCreate = credentials.create.bind(credentials);3535   const originalGet = credentials.get.bind(credentials);3636 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+3746   credentials.create = async function patchedCreate(options) {3847     const publicKey = options?.publicKey;3948     if (publicKey) {40-      try {41-        window.dispatchEvent(42-          new CustomEvent("peach:passkey-create", {43-            detail: {44-              rpId: publicKey.rp?.id ?? location.hostname,45-              userName: publicKey.user?.name,46-            },47-          })48-        );49-      } catch { /* dispatching must never break the page */ }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+      });5056     }5157     return originalCreate(options);5258   };@@ -54,16 +60,10 @@5460   credentials.get = async function patchedGet(options) {5561     const publicKey = options?.publicKey;5662     if (publicKey) {57-      try {58-        window.dispatchEvent(59-          new CustomEvent("peach:passkey-get", {60-            detail: {61-              rpId: publicKey.rpId ?? location.hostname,62-              allowCredentials: publicKey.allowCredentials,63-            },64-          })65-        );66-      } catch { /* dispatching must never break the page */ }63+      emit({64+        type: "get",65+        rpId: publicKey.rpId ?? location.hostname,66+      });6767     }6868     return originalGet(options);6969   };

view file

src/popup/App.tsx+15 −0
@@ -823,6 +823,21 @@823823   async function handleApprovePendingPasskey() {824824     const pending = pendingPasskey();825825     if (!pending) return;826+    if (pending.browser === "firefox" && pending.type === "create") {827+      // Firefox path: popup itself runs navigator.credentials.create. The828+      // background never approved/rejected anything — it just stashed829+      // intent. After the popup-driven flow resolves (success or cancel),830+      // clear the pending key so the popup doesn't re-show this on next open.831+      const { createFirefoxPasskey } = await import("./firefox-passkey");832+      const result = await createFirefoxPasskey(pending);833+      if (!result.ok) {834+        console.warn("Peach Firefox passkey create failed:", result.error);835+      }836+      await sendMessage({ type: "DENY_PASSKEY", payload: { requestId: pending.requestId } });837+      setPendingPasskey(null);838+      void loadEntries();839+      return;840+    }826841     await sendMessage({ type: "APPROVE_PASSKEY", payload: { requestId: pending.requestId } });827842     setPendingPasskey(null);828843   }

view file

src/popup/firefox-passkey.ts+109 −0
@@ -0,0 +1,109 @@1+/**2+ * firefox-passkey.ts — popup-context WebAuthn caller for the Firefox path.3+ *4+ * Runs inside the extension popup (the only context, on Firefox 150+, where5+ * `navigator.credentials.create` accepts a host-permitted RP ID supplied by6+ * the extension). The background handler `INITIATE_FIREFOX_PASSKEY_CREATE`7+ * stashes a pending request in session storage; this module reads it,8+ * triggers the platform authenticator, and sends the resulting credential9+ * metadata back to the background via `COMPLETE_FIREFOX_PASSKEY_CREATE`.10+ *11+ * The private key never leaves the platform authenticator (Touch ID,12+ * Windows Hello, security key). Peach stores only public metadata.13+ */14+15+import { sendMessage } from "./App";16+import type { PendingPasskeyRequest } from "../shared/types";17+18+function base64urlEncode(bytes: Uint8Array | ArrayBuffer): string {19+  const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);20+  let str = "";21+  for (let i = 0; i < u8.length; i++) str += String.fromCharCode(u8[i]!);22+  return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");23+}24+25+function base64urlDecode(value: string): ArrayBuffer {26+  const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((value.length + 3) % 4);27+  const bin = atob(padded);28+  const buf = new ArrayBuffer(bin.length);29+  const view = new Uint8Array(buf);30+  for (let i = 0; i < bin.length; i++) view[i] = bin.charCodeAt(i);31+  return buf;32+}33+34+export async function createFirefoxPasskey(35+  pending: PendingPasskeyRequest36+): Promise<{ ok: boolean; error?: string }> {37+  if (!pending.challenge || !pending.userHandle) {38+    return { ok: false, error: "Firefox pending request missing challenge or userHandle" };39+  }40+41+  const challenge = base64urlDecode(pending.challenge);42+  const userHandle = base64urlDecode(pending.userHandle);43+44+  let credential: PublicKeyCredential;45+  try {46+    const result = await navigator.credentials.create({47+      publicKey: {48+        challenge,49+        rp: { id: pending.rpId, name: pending.rpName },50+        user: {51+          id: userHandle,52+          name: pending.userName,53+          displayName: pending.userDisplayName ?? pending.userName,54+        },55+        pubKeyCredParams: [56+          { type: "public-key", alg: -7 },  // ES25657+          { type: "public-key", alg: -8 },  // EdDSA58+        ],59+        authenticatorSelection: {60+          residentKey: "preferred",61+          userVerification: "preferred",62+        },63+        timeout: 60_000,64+      },65+    });66+    if (!result || !(result instanceof PublicKeyCredential)) {67+      return { ok: false, error: "no credential returned" };68+    }69+    credential = result;70+  } catch (err) {71+    if (err instanceof DOMException && err.name === "NotAllowedError") {72+      return { ok: false, error: "user cancelled" };73+    }74+    return { ok: false, error: err instanceof Error ? err.message : "credential creation failed" };75+  }76+77+  const response = credential.response as AuthenticatorAttestationResponse;78+  const algorithm = response.getPublicKeyAlgorithm() === -8 ? -8 : -7;79+  const publicKeySpki = response.getPublicKey();80+  const transports = typeof response.getTransports === "function"81+    ? response.getTransports()82+    : ["internal"];83+84+  const completion = await sendMessage({85+    type: "COMPLETE_FIREFOX_PASSKEY_CREATE",86+    payload: {87+      requestId: pending.requestId,88+      rpId: pending.rpId,89+      rpName: pending.rpName,90+      userName: pending.userName,91+      userDisplayName: pending.userDisplayName,92+      userHandle: pending.userHandle,93+      credentialId: credential.id,94+      // SPKI-formatted public key, not strictly COSE. Stored for reference;95+      // Peach does not sign assertions for Firefox passkeys (the platform96+      // authenticator handles that on every use).97+      publicKeyCose: publicKeySpki ? base64urlEncode(publicKeySpki) : "",98+      algorithm,99+      transports,100+      backupEligible: false,101+      backupState: false,102+    },103+  });104+105+  if (!completion?.ok) {106+    return { ok: false, error: completion?.error ?? "vault store failed" };107+  }108+  return { ok: true };109+}

view file

src/popup/styles/popup.css+7 −0
@@ -1200,6 +1200,13 @@12001200   padding: 8px 10px;12011201 }12021202 1203+.peach-info-notice--warn {1204+  border-color: rgba(220, 38, 38, 0.4);1205+  background: rgba(220, 38, 38, 0.06);1206+  color: rgba(220, 38, 38, 0.92);1207+  margin-bottom: 8px;1208+}1209+12031210 .peach-strength-row {12041211   display: flex;12051212   align-items: center;

view file

src/popup/views/PasskeyEntry.tsx+21 −1
@@ -1,6 +1,7 @@11 import { createSignal, Show } from "solid-js";22 33 import type { PasskeyEntry } from "../../shared/types";4+import { detectBrowser } from "../../shared/browser";45 56 interface PasskeyEntryProps {67   entry: PasskeyEntry | null;@@ -30,7 +31,17 @@3031 3132 export function PasskeyEntryView(props: PasskeyEntryProps) {3233   const [deleteConfirmPending, setDeleteConfirmPending] = createSignal(false);34+  const currentBrowser = detectBrowser();3335 36+  function crossBrowserMessage(entryBrowser: "chrome" | "firefox" | undefined): string | null {37+    if (!entryBrowser) return null;38+    if (currentBrowser === "unknown") return null;39+    if (entryBrowser === currentBrowser) return null;40+    const from = entryBrowser === "chrome" ? "Chrome" : "Firefox";41+    const to = currentBrowser === "chrome" ? "Chrome" : "Firefox";42+    return `This passkey was created on ${from}. To use it on ${to}, re-register it on this device.`;43+  }44+3445   return (3546     <div class="peach-stage" style="position:relative;">3647       <Show when={props.entry} fallback={<div class="peach-list-empty"><span>no passkey selected</span></div>}>@@ -90,8 +101,17 @@90101             </section>91102 92103             <div class="peach-passkey-actions">104+              <Show when={crossBrowserMessage(entry().browser)}>105+                {(msg) => (106+                  <div class="peach-info-notice peach-info-notice--warn">107+                    {msg()}108+                  </div>109+                )}110+              </Show>93111               <div class="peach-info-notice">94-                The private key is stored encrypted in your Peach vault and is never shown in the UI.112+                {entry().browser === "firefox"113+                  ? "This passkey is held by your platform authenticator. Peach stores only the metadata."114+                  : "The private key is stored encrypted in your Peach vault and is never shown in the UI."}95115               </div>96116               <Show97117                 when={deleteConfirmPending()}

view file

src/shared/browser.ts+34 −0
@@ -0,0 +1,34 @@1+/**2+ * browser.ts — feature-detected browser identifier shared by background,3+ * popup, and content scripts.4+ *5+ * The discriminator is feature presence + extension URL scheme, not user6+ * agent string. `chrome.webAuthenticationProxy` is Chrome-only; the7+ * `moz-extension://` URL scheme tells us we are running under Firefox.8+ */9+10+export type Browser = "chrome" | "firefox" | "unknown";11+12+export function detectBrowser(): Browser {13+  if (typeof chrome === "undefined") return "unknown";14+  // chrome.webAuthenticationProxy exists on Chrome 122+ regardless of15+  // whether it has been attached. Treat its mere presence as the discriminator.16+  if ((chrome as unknown as { webAuthenticationProxy?: unknown }).webAuthenticationProxy) {17+    return "chrome";18+  }19+  try {20+    const url = chrome.runtime?.getURL?.("") ?? "";21+    if (url.startsWith("moz-extension://")) return "firefox";22+  } catch { /* extension context unavailable */ }23+  return "unknown";24+}25+26+/**27+ * Best-effort Firefox version detection. Used to gate the Firefox passkey28+ * path on Firefox 150+. Returns NaN for non-Firefox or when we can't parse.29+ */30+export function firefoxVersion(): number {31+  if (detectBrowser() !== "firefox") return NaN;32+  const match = navigator.userAgent.match(/Firefox\/(\d+)/);33+  return match ? Number(match[1]) : NaN;34+}

view file

src/shared/types.ts+22 −1
@@ -111,7 +111,13 @@111111   rpName: string;112112   credentialId: string;113113   publicKeyCose: string;114-  privateKeyJwk: JsonWebKey;114+  /**115+   * Encrypted vault holds the private key only for Chrome-created passkeys,116+   * where Peach is the authenticator. Firefox-created passkeys delegate to117+   * the platform authenticator, so this field is absent — `browser`118+   * distinguishes the two paths.119+   */120+  privateKeyJwk?: JsonWebKey;115121   userHandle: string;116122   userName: string;117123   userDisplayName: string;@@ -133,6 +139,16 @@133139   rpName: string;134140   userName: string;135141   userDisplayName?: string;142+  /**143+   * Firefox path: when present, the popup performs the navigator.credentials144+   * call itself rather than completing a chrome.webAuthenticationProxy145+   * request. Absent for Chrome (the existing webAuthenticationProxy flow).146+   */147+  browser?: "chrome" | "firefox";148+  /** Firefox create: base64url challenge minted by the popup-context flow. */149+  challenge?: string;150+  /** Firefox create: base64url user handle. Generated if absent. */151+  userHandle?: string;136152 }137153 138154 export interface PasskeyProviderState {@@ -219,6 +235,11 @@219235   | "DEACTIVATE_LICENSE"220236   | "GET_IDENTITIES"221237   | "DESTROY_VAULT"238+  | "INITIATE_FIREFOX_PASSKEY_CREATE"239+  | "COMPLETE_FIREFOX_PASSKEY_CREATE"240+  | "PASSKEY_OFFER_DECLINE"241+  | "GET_PASSKEY_OFFER_DECLINED"242+  | "GET_PASSKEY_FOR_DOMAIN"222243   // Content-script-bound message types — sent BY the background TO content223244   // scripts. They never reach messages.ts's switch. Kept in this union so224245   // existing call sites type-check; do not add cases for them in the

view file