1import { isProUser } from "./license";2import * as vault from "./vault";3import {4 base64urlEncode,5 buildAuthenticatorData,6 buildClientDataJSON,7 createAttestationObject,8 generateCredentialId,9 generatePasskeyKeypair,10 pickPasskeyAlgorithm,11 signPasskeyAssertion,12} from "./passkey-crypto";13import { detectBrowser as detectSharedBrowser, firefoxVersion } from "../shared/browser";14import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";15import type {16 PasskeyData,17 PasskeyProviderState,18 PendingPasskeyRequest,19 VaultEntry,20} from "../shared/types";2122declare const __PEACH_E2E_PASSKEYS__: boolean;2324const PENDING_PREFIX = "passkey_pending_";25const APPROVAL_TIMEOUT_MS = 60_000;2627let listenersRegistered = false;28let attached = false;29let conflict: string | undefined;30const PASSKEY_CREATE_E2E_BYPASS = __PEACH_E2E_PASSKEYS__ === true;3132const approvals = new Map<33 number,34 { resolve: (approved: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }35>();36373839const unlockWaiters = new Map<number, { resolve: (ok: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }>();4041export function onVaultUnlocked(): void {42 for (const { resolve, timeoutId } of unlockWaiters.values()) {43 clearTimeout(timeoutId);44 resolve(true);45 }46 unlockWaiters.clear();47}4849505152535455export async function denyAllPendingPasskeyRequests(): Promise<void> {56 if (approvals.size === 0 && unlockWaiters.size === 0) return;57 console.log("[Peach passkey] denyAllPendingPasskeyRequests (popup disconnected)", {58 approvals: approvals.size,59 unlockWaiters: unlockWaiters.size,60 });61 for (const [rid, { resolve, timeoutId }] of approvals.entries()) {62 clearTimeout(timeoutId);63 resolve(false);64 approvals.delete(rid);65 await clearPendingRequest(rid).catch(() => {});66 }67 for (const { resolve, timeoutId } of unlockWaiters.values()) {68 clearTimeout(timeoutId);69 resolve(false);70 }71 unlockWaiters.clear();72}7374function isPasskeySupported(): boolean {75 if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;76 77 78 79 80 if (detectSharedBrowser() === "firefox") {81 const ver = firefoxVersion();82 if (Number.isFinite(ver) && ver >= 150) return true;83 }84 return false;85}8687export const detectBrowser = detectSharedBrowser;8889function getBrowserLabel(): string {90 const ua = navigator.userAgent;91 if (/Firefox\/(\d+)/.test(ua)) {92 const version = ua.match(/Firefox\/(\d+)/)?.[1];93 return version ? `Firefox ${version}` : "Firefox";94 }95 const version = ua.match(/Chrome\/(\d+)/)?.[1];96 return version ? `Chrome ${version}` : "Chromium";97}9899function pendingStorageKey(requestId: number): string {100 return `${PENDING_PREFIX}${requestId}`;101}102103function originFromRpId(rpId: string): string {104 return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)105 ? `http://${rpId}`106 : `https://${rpId}`;107}108109110111112113114115116117118119120121122123124async function resolvePageOrigin(rpId: string): Promise<string> {125 try {126 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>127 chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)128 );129 const url = tabs[0]?.url;130 if (url) {131 const parsed = new URL(url);132 const host = parsed.hostname.toLowerCase();133 const rp = rpId.toLowerCase();134 if (host === rp || host.endsWith(`.${rp}`)) {135 return parsed.origin;136 }137 }138 } catch {139 140 }141 return originFromRpId(rpId);142}143144async function setBadge(text: string): Promise<void> {145 if (!chrome.action?.setBadgeText) return;146 await chrome.action.setBadgeText({ text });147 if (text) {148 await chrome.action.setBadgeBackgroundColor({ color: "#E5805C" });149 }150}151152async function clearPendingRequest(requestId: number): Promise<void> {153 if (chrome.storage.session) {154 await chrome.storage.session.remove(pendingStorageKey(requestId));155 }156 await setBadge("");157}158159async function openApprovalPopup(): Promise<void> {160 try {161 await chrome.action.openPopup();162 } catch {163 await setBadge("PK");164 }165}166167async function rejectCreate(168 requestId: number,169 name: string,170 message = ""171): Promise<void> {172 await chrome.webAuthenticationProxy.completeCreateRequest({173 requestId,174 error: { name, message },175 });176}177178async function rejectGet(179 requestId: number,180 name: string,181 message = ""182): Promise<void> {183 await chrome.webAuthenticationProxy.completeGetRequest({184 requestId,185 error: { name, message },186 });187}188189export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {190 if (!chrome.storage.session) return null; 191 const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;192 const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));193 if (!key) return null;194 const pending = stored[key];195 return pending && typeof pending === "object"196 ? (pending as PendingPasskeyRequest)197 : null;198}199200export async function resolvePendingPasskeyRequest(201 requestId: number,202 approved: boolean203): Promise<void> {204 const pending = approvals.get(requestId);205 if (!pending) {206 207 208 209 210 await clearPendingRequest(requestId);211 if (chrome.storage.session) {212 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);213 }214 return;215 }216217 clearTimeout(pending.timeoutId);218 approvals.delete(requestId);219 await clearPendingRequest(requestId);220 pending.resolve(approved);221}222223async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {224 if (chrome.storage.session) {225 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });226 }227 await openApprovalPopup();228229 return new Promise<boolean>((resolve) => {230 const timeoutId = setTimeout(async () => {231 approvals.delete(request.requestId);232 await clearPendingRequest(request.requestId);233 resolve(false);234 }, APPROVAL_TIMEOUT_MS);235236 approvals.set(request.requestId, { resolve, timeoutId });237 });238}239240export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {241 const settings = await vault.ensureSettingsLoaded();242 const supported = isPasskeySupported();243 const enabled = settings.passkeyProviderEnabled === true;244 return {245 supported,246 enabled,247 attached: supported && attached,248 browserLabel: getBrowserLabel(),249 statusLabel: !supported250 ? "Interception unavailable"251 : attached252 ? "Active"253 : conflict254 ? "Another provider is active"255 : enabled256 ? "Inactive"257 : "Disabled",258 conflict,259 };260}261262async function attachPasskeyProxy(): Promise<void> {263 if (!isPasskeySupported()) {264 attached = false;265 conflict = "This browser does not expose chrome.webAuthenticationProxy.";266 return;267 }268269 270 271 272 if (detectSharedBrowser() === "firefox") {273 attached = true;274 conflict = undefined;275 return;276 }277278 if (attached) return;279280 try {281 await chrome.webAuthenticationProxy.attach();282 attached = true;283 conflict = undefined;284 console.log("[Peach passkey] proxy attached successfully");285 } catch (error) {286 attached = false;287 conflict = error instanceof Error ? error.message : "Another provider is active.";288 console.warn("[Peach passkey] proxy attach failed", { conflict });289 }290}291292async function detachPasskeyProxy(): Promise<void> {293 if (!isPasskeySupported()) {294 attached = false;295 return;296 }297298 if (detectSharedBrowser() === "firefox") {299 attached = false;300 conflict = undefined;301 return;302 }303304 try {305 await chrome.webAuthenticationProxy.detach();306 } catch {307 308 }309310 attached = false;311 conflict = undefined;312}313314export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {315 await vault.updateSettings({ passkeyProviderEnabled: enabled });316 if (enabled) {317 await attachPasskeyProxy();318 } else {319 await detachPasskeyProxy();320 }321 return getPasskeyProviderState();322}323324async function refreshPasskeyProxy(): Promise<void> {325 const settings = await vault.ensureSettingsLoaded();326 if (settings.passkeyProviderEnabled === true) {327 await attachPasskeyProxy();328 return;329 }330 await detachPasskeyProxy();331}332333334335336337338339340function findAttachTarget(rpId: string): VaultEntry | null {341 const all = vault.getEntries();342 const passkeyMatch = all.find(343 (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt344 );345 if (passkeyMatch) return passkeyMatch;346 const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);347 if (loginMatches.length === 0) return null;348 349 return loginMatches.reduce((best, e) =>350 (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best351 );352}353354async function handleCreateRequest(355 request: chrome.webAuthenticationProxy.CreateRequest356): Promise<void> {357 console.log("[Peach passkey] create event received", {358 requestId: request.requestId,359 detailsJson: request.requestDetailsJson,360 });361 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;362 const rpId = options.rp.id;363 const rpName = options.rp.name ?? options.rp.id;364 const userName = options.user.name;365 const userDisplayName = options.user.displayName ?? options.user.name;366 console.log("[Peach passkey] create parsed", { rpId, rpName, userName, unlocked: vault.isUnlocked() });367368 if (!rpId) {369 console.warn("[Peach passkey] reject: missing rp.id");370 await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");371 return;372 }373374 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {375 console.warn("[Peach passkey] reject: user is not Pro");376 await rejectCreate(377 request.requestId,378 "NotAllowedError",379 "Passkey creation requires Peach Pro."380 );381 382 broadcastPasskeyProGate();383 return;384 }385386 if (!vault.isUnlocked()) {387 console.log("[Peach passkey] vault locked, summoning inline unlock");388 389 390 391 392 393 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>394 chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)395 );396 const activeTab = tabs[0];397 console.log("[Peach passkey] active tab for unlock summon", { id: activeTab?.id, url: activeTab?.url });398 if (activeTab?.id) {399 chrome.tabs.sendMessage(activeTab.id, {400 type: 'SUMMON_AUTOFILL',401 source: 'passkey-unlock',402 }).catch((err) => {403 console.warn("[Peach passkey] SUMMON_AUTOFILL failed, falling back to popup", err);404 void openApprovalPopup();405 });406 } else {407 await openApprovalPopup();408 }409410 const unlocked = await new Promise<boolean>((resolve) => {411 const timeoutId = setTimeout(() => {412 unlockWaiters.delete(request.requestId);413 resolve(false);414 }, APPROVAL_TIMEOUT_MS);415 unlockWaiters.set(request.requestId, { resolve, timeoutId });416 });417418 console.log("[Peach passkey] unlock wait resolved", { unlocked, vaultUnlocked: vault.isUnlocked() });419 if (!unlocked || !vault.isUnlocked()) {420 console.warn("[Peach passkey] reject: unlock timeout");421 await rejectCreate(request.requestId, "NotAllowedError", "Not unlocked in time.");422 return;423 }424 425 }426427 console.log("[Peach passkey] requesting approval via popup");428 const approved = await promptUserApproval({429 requestId: request.requestId,430 type: "create",431 rpId,432 rpName,433 userName,434 userDisplayName,435 });436 console.log("[Peach passkey] approval resolved", { approved });437 if (!approved) {438 console.warn("[Peach passkey] reject: user denied");439 await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");440 return;441 }442443 const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);444 if (!algorithm) {445 console.warn("[Peach passkey] reject: no supported algorithm", options.pubKeyCredParams);446 await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");447 return;448 }449 console.log("[Peach passkey] algorithm selected", algorithm);450451 const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));452 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(453 algorithm454 );455 const authData = await buildAuthenticatorData({456 rpId,457 flags: 0x4d,458 signCount: 0,459 credentialId: credentialIdBytes,460 publicKeyCose,461 });462 const attestationObject = createAttestationObject(authData);463 const clientDataJSON = buildClientDataJSON(464 "webauthn.create",465 options.challenge,466 await resolvePageOrigin(rpId)467 );468469 const passkey: PasskeyData = {470 rpId,471 rpName,472 credentialId: base64urlEncode(credentialIdBytes),473 publicKeyCose: base64urlEncode(publicKeyCose),474 privateKeyJwk,475 userHandle: options.user.id,476 userName,477 userDisplayName,478 algorithm,479 signCount: 0,480 aaguid: base64urlEncode(new Uint8Array(16)),481 backupEligible: false,482 backupState: false,483 browser: "chrome",484 transports: ["internal"],485 createdAt: Date.now(),486 };487488 489 490 const target = findAttachTarget(rpId);491 if (target) {492 await vault.updateEntry(target.id, { passkey });493 } else {494 await vault.addEntry({495 type: "login",496 title: rpName,497 url: `https://${rpId}`,498 username: userName,499 passkey,500 });501 }502503 await chrome.webAuthenticationProxy.completeCreateRequest({504 requestId: request.requestId,505 responseJson: JSON.stringify({506 id: passkey.credentialId,507 rawId: passkey.credentialId,508 type: "public-key",509 response: {510 clientDataJSON: base64urlEncode(clientDataJSON),511 authenticatorData: base64urlEncode(authData),512 attestationObject: base64urlEncode(attestationObject),513 transports: ["internal"],514 publicKey: base64urlEncode(publicKeySpki),515 publicKeyAlgorithm: algorithm,516 },517 authenticatorAttachment: "platform",518 clientExtensionResults: {},519 }),520 });521}522523function broadcastPasskeyProGate(): void {524 chrome.tabs.query({}, (tabs) => {525 for (const tab of tabs) {526 if (tab.id !== undefined) {527 chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});528 }529 }530 });531}532533async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {534 if (chrome.storage.session) {535 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });536 }537538 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {539 chrome.tabs.query({ active: true, currentWindow: true }, resolve);540 });541 const tab = tabs[0];542543 if (tab?.id) {544 chrome.tabs.sendMessage(tab.id, {545 type: 'SHOW_PASSKEY_TOAST',546 payload: {547 site: request.rpId,548 displayName: request.userDisplayName ?? request.userName,549 username: request.userName,550 requestId: String(request.requestId),551 },552 }).catch(() => {});553 } else {554 await openApprovalPopup();555 }556557 return new Promise<boolean>((resolve) => {558 const timeoutId = setTimeout(async () => {559 approvals.delete(request.requestId);560 await clearPendingRequest(request.requestId);561 resolve(false);562 }, APPROVAL_TIMEOUT_MS);563564 approvals.set(request.requestId, { resolve, timeoutId });565 });566}567568async function handleGetRequest(569 request: chrome.webAuthenticationProxy.GetRequest570): Promise<void> {571 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;572 if (!vault.isUnlocked()) {573 574 575 576 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>577 chrome.tabs.query({ active: true, currentWindow: true }, resolve)578 );579 const activeTab = tabs[0];580 if (activeTab?.id) {581 chrome.tabs.sendMessage(activeTab.id, {582 type: 'SUMMON_AUTOFILL',583 source: 'passkey-unlock',584 }).catch(() => { void openApprovalPopup(); });585 } else {586 await openApprovalPopup();587 }588589 590 const unlocked = await new Promise<boolean>((resolve) => {591 const timeoutId = setTimeout(() => {592 unlockWaiters.delete(request.requestId);593 resolve(false);594 }, APPROVAL_TIMEOUT_MS);595 unlockWaiters.set(request.requestId, { resolve, timeoutId });596 });597598 if (!unlocked || !vault.isUnlocked()) {599 await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");600 return;601 }602 603 }604605 if (!(await isProUser())) {606 await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");607 broadcastPasskeyProGate();608 return;609 }610611 const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>612 entry.passkey !== undefined && !entry.deletedAt613 );614 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);615616 const match = passkeyBearing.find((entry) => {617 if (allowedCredentialIds.length > 0) {618 return allowedCredentialIds.includes(entry.passkey.credentialId);619 }620 if (!options.rpId) return false;621 return entry.passkey.rpId === options.rpId;622 });623624 if (!match) {625 await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");626 return;627 }628629 const credential = match.passkey;630 631 632 633 634 if (!credential.privateKeyJwk) {635 await rejectGet(636 request.requestId,637 "NotAllowedError",638 "This passkey was created on Firefox; sign in via the platform authenticator."639 );640 return;641 }642643 const approved = await promptViaToast({644 requestId: request.requestId,645 type: "get",646 rpId: credential.rpId,647 rpName: credential.rpName,648 userName: credential.userName,649 userDisplayName: credential.userDisplayName,650 });651 if (!approved) {652 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");653 return;654 }655656 const nextSignCount = credential.signCount + 1;657 const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);658 const authData = await buildAuthenticatorData({659 rpId: credential.rpId,660 flags,661 signCount: nextSignCount,662 });663 const clientDataJSON = buildClientDataJSON(664 "webauthn.get",665 options.challenge,666 await resolvePageOrigin(credential.rpId)667 );668 const clientDataHash = new Uint8Array(669 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))670 );671 const signature = await signPasskeyAssertion(672 credential.privateKeyJwk,673 credential.algorithm,674 authData,675 clientDataHash676 );677678 await vault.updateEntry(match.id, {679 passkey: {680 ...credential,681 signCount: nextSignCount,682 lastUsedAt: Date.now(),683 },684 });685686 const responseJson = JSON.stringify({687 id: credential.credentialId,688 rawId: credential.credentialId,689 type: "public-key",690 response: {691 clientDataJSON: base64urlEncode(clientDataJSON),692 authenticatorData: base64urlEncode(authData),693 signature: base64urlEncode(signature),694 userHandle: credential.userHandle,695 },696 authenticatorAttachment: "platform",697 clientExtensionResults: {},698 });699700 await chrome.webAuthenticationProxy.completeGetRequest({701 requestId: request.requestId,702 responseJson,703 });704}705706707708709710711712713714715const FIREFOX_PENDING_KEY = "passkey_pending_firefox";716const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";717const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; 718719export async function initiateFirefoxPasskeyCreate(args: {720 rpId: string;721 rpName: string;722 userName: string;723 userDisplayName?: string;724}): Promise<{ ok: boolean; error?: string }> {725 if (!(await isProUser())) {726 broadcastPasskeyProGate();727 return { ok: false, error: "passkey creation requires Peach Pro" };728 }729 if (!vault.isUnlocked()) {730 await openApprovalPopup();731 return { ok: false, error: "vault_locked" };732 }733 if (!chrome.storage.session) {734 return { ok: false, error: "session storage unavailable" };735 }736737 738 739 740 const requestId = Date.now() + Math.floor(Math.random() * 1_000_000);741 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));742 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));743744 const pending: PendingPasskeyRequest = {745 requestId,746 type: "create",747 rpId: args.rpId,748 rpName: args.rpName,749 userName: args.userName,750 userDisplayName: args.userDisplayName,751 browser: "firefox",752 challenge,753 userHandle,754 };755756 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });757 await openApprovalPopup();758 return { ok: true };759}760761export async function completeFirefoxPasskeyCreate(payload: {762 requestId: number;763 rpId: string;764 rpName: string;765 userName: string;766 userDisplayName?: string;767 userHandle: string;768 credentialId: string;769 publicKeyCose: string;770 algorithm: -7 | -8;771 aaguid?: string;772 transports?: string[];773 backupEligible?: boolean;774 backupState?: boolean;775}): Promise<{ ok: boolean; error?: string }> {776 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };777778 const passkey: PasskeyData = {779 rpId: payload.rpId,780 rpName: payload.rpName,781 credentialId: payload.credentialId,782 publicKeyCose: payload.publicKeyCose,783 userHandle: payload.userHandle,784 userName: payload.userName,785 userDisplayName: payload.userDisplayName ?? payload.userName,786 algorithm: payload.algorithm,787 signCount: 0,788 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),789 backupEligible: payload.backupEligible ?? false,790 backupState: payload.backupState ?? false,791 browser: "firefox",792 transports: payload.transports ?? ["internal"],793 createdAt: Date.now(),794 };795796 797 798 799 const target = findAttachTarget(payload.rpId);800 if (target) {801 await vault.updateEntry(target.id, { passkey });802 } else {803 await vault.addEntry({804 type: "login",805 title: payload.rpName || payload.rpId,806 url: `https://${payload.rpId}`,807 username: payload.userName,808 passkey,809 });810 }811812 if (chrome.storage.session) {813 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);814 }815 return { ok: true };816}817818export async function cancelFirefoxPasskeyCreate(): Promise<void> {819 if (chrome.storage.session) {820 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);821 }822}823824function declineKey(domain: string): string {825 return `${FIREFOX_DECLINE_PREFIX}${domain}`;826}827828export async function recordPasskeyOfferDecline(domain: string): Promise<void> {829 if (!domain) return;830 await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });831}832833export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {834 if (!domain) return false;835 const key = declineKey(domain);836 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;837 const at = stored[key];838 if (typeof at !== "number") return false;839 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {840 await chrome.storage.local.remove(key);841 return false;842 }843 return true;844}845846847848849850851852853854855856857858859interface FirefoxAllowCredential {860 id: string;861 type?: string;862}863864interface FirefoxPubKeyCredParam {865 type?: string;866 alg: number;867}868869export interface FirefoxPasskeyHasCredentialsPayload {870 rpId: string;871 allowCredentials: FirefoxAllowCredential[];872}873874export interface FirefoxPasskeyCreatePayload {875 rpId: string;876 rpName?: string;877 userId: string;878 userName: string;879 userDisplayName?: string;880 challenge: string;881 clientDataJSON: number[];882 origin: string;883 pubKeyCredParams?: FirefoxPubKeyCredParam[];884 authenticatorSelection?: Record<string, unknown>;885 timeout?: number;886 attestation?: string;887}888889export interface FirefoxPasskeyGetPayload {890 rpId: string;891 challenge: string;892 clientDataJSON: number[];893 origin: string;894 allowCredentials: FirefoxAllowCredential[];895 userVerification?: string;896}897898interface FirefoxCreateCredentialResponse {899 type: "create";900 id: string;901 clientDataJSON: number[];902 attestationObject: string;903 authenticatorData: string;904 publicKey: string;905 algorithm: -7 | -8;906}907908interface FirefoxGetCredentialResponse {909 type: "get";910 id: string;911 clientDataJSON: number[];912 authenticatorData: string;913 signature: string;914 userHandle: string;915}916917type FirefoxPasskeyResult<T> =918 | { ok: true; credential: T }919 | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };920921function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {922 return vault.getEntries().filter(923 (entry): entry is VaultEntry & { passkey: PasskeyData } =>924 entry.passkey !== undefined && !entry.deletedAt925 );926}927928export async function handlePasskeyHasCredentials(929 payload: FirefoxPasskeyHasCredentialsPayload930): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {931 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {932 return { ok: false, error: "not_applicable" };933 }934 if (!vault.isUnlocked()) {935 936 937 938 return { ok: true, hasCredentials: false };939 }940 const passkeys = listPasskeyEntries().filter(941 (entry) => entry.passkey.rpId === payload.rpId942 );943 if (passkeys.length === 0) return { ok: true, hasCredentials: false };944945 const allow = payload.allowCredentials ?? [];946 if (allow.length === 0) return { ok: true, hasCredentials: true };947948 const hasMatch = passkeys.some((entry) =>949 allow.some((ac) => ac.id === entry.passkey.credentialId)950 );951 return { ok: true, hasCredentials: hasMatch };952}953954955956957958959960961async function requestFirefoxApproval(args: {962 type: "create" | "get";963 rpId: string;964 rpName?: string;965 userName: string;966 userDisplayName?: string;967}): Promise<boolean> {968 969 970 const requestId = Date.now() + Math.floor(Math.random() * 1000);971 return promptUserApproval({972 requestId,973 type: args.type,974 rpId: args.rpId,975 rpName: args.rpName ?? args.rpId,976 userName: args.userName,977 userDisplayName: args.userDisplayName,978 });979}980981export async function handleFirefoxPasskeyCreate(982 payload: FirefoxPasskeyCreatePayload983): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {984 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {985 return { ok: false, error: "not_applicable" };986 }987 if (!(await isProUser())) {988 broadcastPasskeyProGate();989 return { ok: false, error: "pro_required" };990 }991 if (!vault.isUnlocked()) {992 await openApprovalPopup();993 return { ok: false, error: "vault_locked" };994 }995996 const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);997 if (!algorithm) {998 return { ok: false, error: "internal_error", message: "No supported algorithm offered" };999 }10001001 const approved = await requestFirefoxApproval({1002 type: "create",1003 rpId: payload.rpId,1004 rpName: payload.rpName,1005 userName: payload.userName,1006 userDisplayName: payload.userDisplayName,1007 });1008 if (!approved) return { ok: false, error: "user_cancelled" };10091010 const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1011 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);10121013 1014 1015 const authenticatorData = await buildAuthenticatorData({1016 rpId: payload.rpId,1017 flags: 0x45,1018 signCount: 0,1019 credentialId: credentialIdBytes,1020 publicKeyCose,1021 });1022 const attestationObject = createAttestationObject(authenticatorData);10231024 const passkey: PasskeyData = {1025 rpId: payload.rpId,1026 rpName: payload.rpName ?? payload.rpId,1027 credentialId,1028 publicKeyCose: base64urlEncode(publicKeyCose),1029 privateKeyJwk,1030 userHandle: payload.userId,1031 userName: payload.userName,1032 userDisplayName: payload.userDisplayName ?? payload.userName,1033 algorithm,1034 signCount: 0,1035 aaguid: base64urlEncode(new Uint8Array(16)),1036 backupEligible: false,1037 backupState: false,1038 browser: "firefox",1039 transports: ["internal"],1040 createdAt: Date.now(),1041 };10421043 const target = findAttachTarget(payload.rpId);1044 if (target) {1045 await vault.updateEntry(target.id, { passkey });1046 } else {1047 await vault.addEntry({1048 type: "login",1049 title: payload.rpName || payload.rpId,1050 url: `https://${payload.rpId}`,1051 username: payload.userName,1052 passkey,1053 });1054 }10551056 return {1057 ok: true,1058 credential: {1059 type: "create",1060 id: credentialId,1061 1062 1063 1064 clientDataJSON: payload.clientDataJSON,1065 attestationObject: base64urlEncode(attestationObject),1066 authenticatorData: base64urlEncode(authenticatorData),1067 publicKey: base64urlEncode(publicKeySpki),1068 algorithm,1069 },1070 };1071}10721073export async function handleFirefoxPasskeyGet(1074 payload: FirefoxPasskeyGetPayload1075): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1076 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1077 return { ok: false, error: "not_applicable" };1078 }1079 if (!(await isProUser())) {1080 broadcastPasskeyProGate();1081 return { ok: false, error: "pro_required" };1082 }1083 if (!vault.isUnlocked()) {1084 await openApprovalPopup();1085 return { ok: false, error: "vault_locked" };1086 }10871088 const allow = payload.allowCredentials ?? [];1089 const candidates = listPasskeyEntries().filter((entry) => {1090 if (entry.passkey.rpId !== payload.rpId) return false;1091 if (allow.length === 0) return true;1092 return allow.some((ac) => ac.id === entry.passkey.credentialId);1093 });10941095 if (candidates.length === 0) return { ok: false, error: "no_credentials" };10961097 1098 1099 1100 candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1101 const match = candidates[0]!;1102 const credential = match.passkey;11031104 if (!credential.privateKeyJwk) {1105 1106 1107 1108 return { ok: false, error: "no_credentials" };1109 }11101111 const approved = await requestFirefoxApproval({1112 type: "get",1113 rpId: credential.rpId,1114 rpName: credential.rpName,1115 userName: credential.userName,1116 userDisplayName: credential.userDisplayName,1117 });1118 if (!approved) return { ok: false, error: "user_cancelled" };11191120 const nextSignCount = credential.signCount + 1;1121 1122 1123 const flags =1124 0x05 |1125 (credential.backupEligible ? 0x08 : 0) |1126 (credential.backupState ? 0x10 : 0);1127 const authenticatorData = await buildAuthenticatorData({1128 rpId: credential.rpId,1129 flags,1130 signCount: nextSignCount,1131 });11321133 1134 1135 1136 const clientDataBytes = new Uint8Array(payload.clientDataJSON);1137 const clientDataHash = new Uint8Array(1138 await crypto.subtle.digest("SHA-256", clientDataBytes)1139 );1140 const signature = await signPasskeyAssertion(1141 credential.privateKeyJwk,1142 credential.algorithm,1143 authenticatorData,1144 clientDataHash1145 );11461147 await vault.updateEntry(match.id, {1148 passkey: {1149 ...credential,1150 signCount: nextSignCount,1151 lastUsedAt: Date.now(),1152 },1153 });11541155 return {1156 ok: true,1157 credential: {1158 type: "get",1159 id: credential.credentialId,1160 clientDataJSON: payload.clientDataJSON,1161 authenticatorData: base64urlEncode(authenticatorData),1162 signature: base64urlEncode(signature),1163 userHandle: credential.userHandle,1164 },1165 };1166}11671168export async function setupPasskeyProxy(): Promise<void> {1169 if (!isPasskeySupported()) {1170 console.log("[Peach passkey] setupPasskeyProxy: not supported on this browser");1171 return;1172 }11731174 1175 1176 if (detectSharedBrowser() === "firefox") {1177 await refreshPasskeyProxy();1178 return;1179 }11801181 if (!listenersRegistered) {1182 console.log("[Peach passkey] registering webAuthenticationProxy listeners");1183 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1184 1185 1186 1187 1188 1189 1190 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1191 requestId: requestInfo.requestId,1192 isUvpaa: true,1193 });1194 });1195 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1196 void handleCreateRequest(requestInfo).catch(async (error) => {1197 console.error("Peach passkey create failed", error);1198 await rejectCreate(1199 requestInfo.requestId,1200 "UnknownError",1201 error instanceof Error ? error.message : "Passkey creation failed."1202 );1203 });1204 });1205 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1206 void handleGetRequest(requestInfo).catch(async (error) => {1207 console.error("Peach passkey get failed", error);1208 await rejectGet(1209 requestInfo.requestId,1210 "UnknownError",1211 error instanceof Error ? error.message : "Passkey authentication failed."1212 );1213 });1214 });1215 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1216 void resolvePendingPasskeyRequest(requestId, false);1217 });1218 listenersRegistered = true;1219 }12201221 await refreshPasskeyProxy();1222}