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
↓ peach-browser-0527a541.tar.gzatlas at this save
files changed14 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 −1manifest.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": {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",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": {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,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) => {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+}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 classessrc/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 };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 }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+}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;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()}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+}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