1import { isProUser } from "./license";2import * as vault from "./vault";3import {4 base64urlDecode,5 base64urlEncode,6 buildAuthenticatorData,7 buildClientDataJSON,8 createAttestationObject,9 generateCredentialId,10 generatePasskeyKeypair,11 pickPasskeyAlgorithm,12 signPasskeyAssertion,13} from "./passkey-crypto";14import { detectBrowser as detectSharedBrowser, firefoxVersion } from "../shared/browser";15import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";16import type {17 PasskeyData,18 PasskeyProviderState,19 PendingPasskeyRequest,20 VaultEntry,21} from "../shared/types";2223declare const __PEACH_E2E_PASSKEYS__: boolean;2425const PENDING_PREFIX = "passkey_pending_";26const APPROVAL_TIMEOUT_MS = 60_000;2728let listenersRegistered = false;29let attached = false;30let conflict: string | undefined;31const PASSKEY_CREATE_E2E_BYPASS = __PEACH_E2E_PASSKEYS__ === true;3233const approvals = new Map<34 number,35 { resolve: (approved: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }36>();3738function isPasskeySupported(): boolean {39 if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;40 41 42 43 44 if (detectSharedBrowser() === "firefox") {45 const ver = firefoxVersion();46 if (Number.isFinite(ver) && ver >= 150) return true;47 }48 return false;49}5051export const detectBrowser = detectSharedBrowser;5253function getBrowserLabel(): string {54 const ua = navigator.userAgent;55 if (/Firefox\/(\d+)/.test(ua)) {56 const version = ua.match(/Firefox\/(\d+)/)?.[1];57 return version ? `Firefox ${version}` : "Firefox";58 }59 const version = ua.match(/Chrome\/(\d+)/)?.[1];60 return version ? `Chrome ${version}` : "Chromium";61}6263function pendingStorageKey(requestId: number): string {64 return `${PENDING_PREFIX}${requestId}`;65}6667function originFromRpId(rpId: string): string {68 return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)69 ? `http://${rpId}`70 : `https://${rpId}`;71}7273async function setBadge(text: string): Promise<void> {74 if (!chrome.action?.setBadgeText) return;75 await chrome.action.setBadgeText({ text });76 if (text) {77 await chrome.action.setBadgeBackgroundColor({ color: "#D96830" });78 }79}8081async function clearPendingRequest(requestId: number): Promise<void> {82 if (chrome.storage.session) {83 await chrome.storage.session.remove(pendingStorageKey(requestId));84 }85 await setBadge("");86}8788async function openApprovalPopup(): Promise<void> {89 try {90 await chrome.action.openPopup();91 } catch {92 await setBadge("PK");93 }94}9596async function rejectCreate(97 requestId: number,98 name: string,99 message = ""100): Promise<void> {101 await chrome.webAuthenticationProxy.completeCreateRequest({102 requestId,103 error: { name, message },104 });105}106107async function rejectGet(108 requestId: number,109 name: string,110 message = ""111): Promise<void> {112 await chrome.webAuthenticationProxy.completeGetRequest({113 requestId,114 error: { name, message },115 });116}117118export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {119 if (!chrome.storage.session) return null; 120 const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;121 const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));122 if (!key) return null;123 const pending = stored[key];124 return pending && typeof pending === "object"125 ? (pending as PendingPasskeyRequest)126 : null;127}128129export async function resolvePendingPasskeyRequest(130 requestId: number,131 approved: boolean132): Promise<void> {133 const pending = approvals.get(requestId);134 if (!pending) {135 136 137 138 139 await clearPendingRequest(requestId);140 if (chrome.storage.session) {141 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);142 }143 return;144 }145146 clearTimeout(pending.timeoutId);147 approvals.delete(requestId);148 await clearPendingRequest(requestId);149 pending.resolve(approved);150}151152async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {153 if (chrome.storage.session) {154 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });155 }156 await openApprovalPopup();157158 return new Promise<boolean>((resolve) => {159 const timeoutId = setTimeout(async () => {160 approvals.delete(request.requestId);161 await clearPendingRequest(request.requestId);162 resolve(false);163 }, APPROVAL_TIMEOUT_MS);164165 approvals.set(request.requestId, { resolve, timeoutId });166 });167}168169export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {170 const settings = await vault.ensureSettingsLoaded();171 const supported = isPasskeySupported();172 const enabled = settings.passkeyProviderEnabled === true;173 return {174 supported,175 enabled,176 attached: supported && attached,177 browserLabel: getBrowserLabel(),178 statusLabel: !supported179 ? "Interception unavailable"180 : attached181 ? "Active"182 : conflict183 ? "Another provider is active"184 : enabled185 ? "Inactive"186 : "Disabled",187 conflict,188 };189}190191async function attachPasskeyProxy(): Promise<void> {192 if (!isPasskeySupported()) {193 attached = false;194 conflict = "This browser does not expose chrome.webAuthenticationProxy.";195 return;196 }197198 199 200 201 if (detectSharedBrowser() === "firefox") {202 attached = true;203 conflict = undefined;204 return;205 }206207 if (attached) return;208209 try {210 await chrome.webAuthenticationProxy.attach();211 attached = true;212 conflict = undefined;213 } catch (error) {214 attached = false;215 conflict = error instanceof Error ? error.message : "Another provider is active.";216 }217}218219async function detachPasskeyProxy(): Promise<void> {220 if (!isPasskeySupported()) {221 attached = false;222 return;223 }224225 if (detectSharedBrowser() === "firefox") {226 attached = false;227 conflict = undefined;228 return;229 }230231 try {232 await chrome.webAuthenticationProxy.detach();233 } catch {234 235 }236237 attached = false;238 conflict = undefined;239}240241export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {242 await vault.updateSettings({ passkeyProviderEnabled: enabled });243 if (enabled) {244 await attachPasskeyProxy();245 } else {246 await detachPasskeyProxy();247 }248 return getPasskeyProviderState();249}250251async function refreshPasskeyProxy(): Promise<void> {252 const settings = await vault.ensureSettingsLoaded();253 if (settings.passkeyProviderEnabled === true) {254 await attachPasskeyProxy();255 return;256 }257 await detachPasskeyProxy();258}259260261262263264265266267function findAttachTarget(rpId: string): VaultEntry | null {268 const all = vault.getEntries();269 const passkeyMatch = all.find(270 (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt271 );272 if (passkeyMatch) return passkeyMatch;273 const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);274 if (loginMatches.length === 0) return null;275 276 return loginMatches.reduce((best, e) =>277 (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best278 );279}280281async function handleCreateRequest(282 request: chrome.webAuthenticationProxy.CreateRequest283): Promise<void> {284 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;285 const rpId = options.rp.id;286 const rpName = options.rp.name ?? options.rp.id;287 const userName = options.user.name;288 const userDisplayName = options.user.displayName ?? options.user.name;289290 if (!rpId) {291 await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");292 return;293 }294295 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {296 await rejectCreate(297 request.requestId,298 "NotAllowedError",299 "Passkey creation requires Peach Pro."300 );301 302 broadcastPasskeyProGate();303 return;304 }305306 if (!vault.isUnlocked()) {307 await openApprovalPopup();308 await rejectCreate(request.requestId, "NotAllowedError", "Unlock Peach first.");309 return;310 }311312 const approved = await promptUserApproval({313 requestId: request.requestId,314 type: "create",315 rpId,316 rpName,317 userName,318 userDisplayName,319 });320 if (!approved) {321 await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");322 return;323 }324325 const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);326 if (!algorithm) {327 await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");328 return;329 }330331 const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));332 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(333 algorithm334 );335 const authData = await buildAuthenticatorData({336 rpId,337 flags: 0x4d,338 signCount: 0,339 credentialId: credentialIdBytes,340 publicKeyCose,341 });342 const attestationObject = createAttestationObject(authData);343 const clientDataJSON = buildClientDataJSON("webauthn.create", options.challenge, originFromRpId(rpId));344345 const passkey: PasskeyData = {346 rpId,347 rpName,348 credentialId: base64urlEncode(credentialIdBytes),349 publicKeyCose: base64urlEncode(publicKeyCose),350 privateKeyJwk,351 userHandle: options.user.id,352 userName,353 userDisplayName,354 algorithm,355 signCount: 0,356 aaguid: base64urlEncode(new Uint8Array(16)),357 backupEligible: false,358 backupState: false,359 browser: "chrome",360 transports: ["internal"],361 createdAt: Date.now(),362 };363364 365 366 const target = findAttachTarget(rpId);367 if (target) {368 await vault.updateEntry(target.id, { passkey });369 } else {370 await vault.addEntry({371 type: "login",372 title: rpName,373 url: `https://${rpId}`,374 username: userName,375 passkey,376 });377 }378379 await chrome.webAuthenticationProxy.completeCreateRequest({380 requestId: request.requestId,381 responseJson: JSON.stringify({382 id: passkey.credentialId,383 rawId: passkey.credentialId,384 type: "public-key",385 response: {386 clientDataJSON: base64urlEncode(clientDataJSON),387 authenticatorData: base64urlEncode(authData),388 attestationObject: base64urlEncode(attestationObject),389 transports: ["internal"],390 publicKey: base64urlEncode(publicKeySpki),391 publicKeyAlgorithm: algorithm,392 },393 authenticatorAttachment: "platform",394 clientExtensionResults: {},395 }),396 });397}398399function broadcastPasskeyProGate(): void {400 chrome.tabs.query({}, (tabs) => {401 for (const tab of tabs) {402 if (tab.id !== undefined) {403 chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});404 }405 }406 });407}408409async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {410 if (chrome.storage.session) {411 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });412 }413414 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {415 chrome.tabs.query({ active: true, currentWindow: true }, resolve);416 });417 const tab = tabs[0];418419 if (tab?.id) {420 chrome.tabs.sendMessage(tab.id, {421 type: 'SHOW_PASSKEY_TOAST',422 payload: {423 site: request.rpId,424 displayName: request.userDisplayName ?? request.userName,425 username: request.userName,426 requestId: String(request.requestId),427 },428 }).catch(() => {429 430 void openApprovalPopup();431 });432 } else {433 await openApprovalPopup();434 }435436 return new Promise<boolean>((resolve) => {437 const timeoutId = setTimeout(async () => {438 approvals.delete(request.requestId);439 await clearPendingRequest(request.requestId);440 resolve(false);441 }, APPROVAL_TIMEOUT_MS);442443 approvals.set(request.requestId, { resolve, timeoutId });444 });445}446447async function handleGetRequest(448 request: chrome.webAuthenticationProxy.GetRequest449): Promise<void> {450 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;451 if (!vault.isUnlocked()) {452 await openApprovalPopup();453 await rejectGet(request.requestId, "NotAllowedError", "Unlock Peach first.");454 return;455 }456457 if (!(await isProUser())) {458 await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");459 broadcastPasskeyProGate();460 return;461 }462463 const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>464 entry.passkey !== undefined && !entry.deletedAt465 );466 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);467468 const match = passkeyBearing.find((entry) => {469 if (allowedCredentialIds.length > 0) {470 return allowedCredentialIds.includes(entry.passkey.credentialId);471 }472 if (!options.rpId) return false;473 return entry.passkey.rpId === options.rpId;474 });475476 if (!match) {477 await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");478 return;479 }480481 const credential = match.passkey;482 483 484 485 486 if (!credential.privateKeyJwk) {487 await rejectGet(488 request.requestId,489 "NotAllowedError",490 "This passkey was created on Firefox; sign in via the platform authenticator."491 );492 return;493 }494495 const approved = await promptViaToast({496 requestId: request.requestId,497 type: "get",498 rpId: credential.rpId,499 rpName: credential.rpName,500 userName: credential.userName,501 userDisplayName: credential.userDisplayName,502 });503 if (!approved) {504 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");505 return;506 }507508 const nextSignCount = credential.signCount + 1;509 const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);510 const authData = await buildAuthenticatorData({511 rpId: credential.rpId,512 flags,513 signCount: nextSignCount,514 });515 const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(credential.rpId));516 const clientDataHash = new Uint8Array(517 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))518 );519 const signature = await signPasskeyAssertion(520 credential.privateKeyJwk,521 credential.algorithm,522 authData,523 clientDataHash524 );525526 await vault.updateEntry(match.id, {527 passkey: {528 ...credential,529 signCount: nextSignCount,530 lastUsedAt: Date.now(),531 },532 });533534 const responseJson = JSON.stringify({535 id: credential.credentialId,536 rawId: credential.credentialId,537 type: "public-key",538 response: {539 clientDataJSON: base64urlEncode(clientDataJSON),540 authenticatorData: base64urlEncode(authData),541 signature: base64urlEncode(signature),542 userHandle: credential.userHandle,543 },544 authenticatorAttachment: "platform",545 clientExtensionResults: {},546 });547548 await chrome.webAuthenticationProxy.completeGetRequest({549 requestId: request.requestId,550 responseJson,551 });552}553554555556557558559560561562563const FIREFOX_PENDING_KEY = "passkey_pending_firefox";564const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";565const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; 566567export async function initiateFirefoxPasskeyCreate(args: {568 rpId: string;569 rpName: string;570 userName: string;571 userDisplayName?: string;572}): Promise<{ ok: boolean; error?: string }> {573 if (!(await isProUser())) {574 broadcastPasskeyProGate();575 return { ok: false, error: "passkey creation requires Peach Pro" };576 }577 if (!vault.isUnlocked()) {578 await openApprovalPopup();579 return { ok: false, error: "vault locked" };580 }581 if (!chrome.storage.session) {582 return { ok: false, error: "session storage unavailable" };583 }584585 const requestId = Date.now();586 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));587 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));588589 const pending: PendingPasskeyRequest = {590 requestId,591 type: "create",592 rpId: args.rpId,593 rpName: args.rpName,594 userName: args.userName,595 userDisplayName: args.userDisplayName,596 browser: "firefox",597 challenge,598 userHandle,599 };600601 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });602 await openApprovalPopup();603 return { ok: true };604}605606export async function completeFirefoxPasskeyCreate(payload: {607 requestId: number;608 rpId: string;609 rpName: string;610 userName: string;611 userDisplayName?: string;612 userHandle: string;613 credentialId: string;614 publicKeyCose: string;615 algorithm: -7 | -8;616 aaguid?: string;617 transports?: string[];618 backupEligible?: boolean;619 backupState?: boolean;620}): Promise<{ ok: boolean; error?: string }> {621 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };622623 const passkey: PasskeyData = {624 rpId: payload.rpId,625 rpName: payload.rpName,626 credentialId: payload.credentialId,627 publicKeyCose: payload.publicKeyCose,628 userHandle: payload.userHandle,629 userName: payload.userName,630 userDisplayName: payload.userDisplayName ?? payload.userName,631 algorithm: payload.algorithm,632 signCount: 0,633 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),634 backupEligible: payload.backupEligible ?? false,635 backupState: payload.backupState ?? false,636 browser: "firefox",637 transports: payload.transports ?? ["internal"],638 createdAt: Date.now(),639 };640641 642 643 644 const target = findAttachTarget(payload.rpId);645 if (target) {646 await vault.updateEntry(target.id, { passkey });647 } else {648 await vault.addEntry({649 type: "login",650 title: payload.rpName || payload.rpId,651 url: `https://${payload.rpId}`,652 username: payload.userName,653 passkey,654 });655 }656657 if (chrome.storage.session) {658 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);659 }660 return { ok: true };661}662663export async function cancelFirefoxPasskeyCreate(): Promise<void> {664 if (chrome.storage.session) {665 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);666 }667}668669function declineKey(domain: string): string {670 return `${FIREFOX_DECLINE_PREFIX}${domain}`;671}672673export async function recordPasskeyOfferDecline(domain: string): Promise<void> {674 if (!domain) return;675 await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });676}677678export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {679 if (!domain) return false;680 const key = declineKey(domain);681 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;682 const at = stored[key];683 if (typeof at !== "number") return false;684 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {685 await chrome.storage.local.remove(key);686 return false;687 }688 return true;689}690691692693694695696697698699700701702703704interface FirefoxAllowCredential {705 id: string;706 type?: string;707}708709interface FirefoxPubKeyCredParam {710 type?: string;711 alg: number;712}713714export interface FirefoxPasskeyHasCredentialsPayload {715 rpId: string;716 allowCredentials: FirefoxAllowCredential[];717}718719export interface FirefoxPasskeyCreatePayload {720 rpId: string;721 rpName?: string;722 userId: string;723 userName: string;724 userDisplayName?: string;725 challenge: string;726 clientDataJSON: number[];727 origin: string;728 pubKeyCredParams?: FirefoxPubKeyCredParam[];729 authenticatorSelection?: Record<string, unknown>;730 timeout?: number;731 attestation?: string;732}733734export interface FirefoxPasskeyGetPayload {735 rpId: string;736 challenge: string;737 clientDataJSON: number[];738 origin: string;739 allowCredentials: FirefoxAllowCredential[];740 userVerification?: string;741}742743interface FirefoxCreateCredentialResponse {744 type: "create";745 id: string;746 clientDataJSON: number[];747 attestationObject: string;748 authenticatorData: string;749 publicKey: string;750 algorithm: -7 | -8;751}752753interface FirefoxGetCredentialResponse {754 type: "get";755 id: string;756 clientDataJSON: number[];757 authenticatorData: string;758 signature: string;759 userHandle: string;760}761762type FirefoxPasskeyResult<T> =763 | { ok: true; credential: T }764 | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };765766function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {767 return vault.getEntries().filter(768 (entry): entry is VaultEntry & { passkey: PasskeyData } =>769 entry.passkey !== undefined && !entry.deletedAt770 );771}772773export async function handlePasskeyHasCredentials(774 payload: FirefoxPasskeyHasCredentialsPayload775): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {776 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {777 return { ok: false, error: "not_applicable" };778 }779 if (!vault.isUnlocked()) {780 781 782 783 return { ok: true, hasCredentials: false };784 }785 const passkeys = listPasskeyEntries().filter(786 (entry) => entry.passkey.rpId === payload.rpId787 );788 if (passkeys.length === 0) return { ok: true, hasCredentials: false };789790 const allow = payload.allowCredentials ?? [];791 if (allow.length === 0) return { ok: true, hasCredentials: true };792793 const hasMatch = passkeys.some((entry) =>794 allow.some((ac) => ac.id === entry.passkey.credentialId)795 );796 return { ok: true, hasCredentials: hasMatch };797}798799800801802803804805806async function requestFirefoxApproval(args: {807 type: "create" | "get";808 rpId: string;809 rpName?: string;810 userName: string;811 userDisplayName?: string;812}): Promise<boolean> {813 814 815 const requestId = Date.now() + Math.floor(Math.random() * 1000);816 return promptUserApproval({817 requestId,818 type: args.type,819 rpId: args.rpId,820 rpName: args.rpName ?? args.rpId,821 userName: args.userName,822 userDisplayName: args.userDisplayName,823 });824}825826export async function handleFirefoxPasskeyCreate(827 payload: FirefoxPasskeyCreatePayload828): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {829 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {830 return { ok: false, error: "not_applicable" };831 }832 if (!(await isProUser())) {833 broadcastPasskeyProGate();834 return { ok: false, error: "pro_required" };835 }836 if (!vault.isUnlocked()) {837 await openApprovalPopup();838 return { ok: false, error: "vault_locked" };839 }840841 const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);842 if (!algorithm) {843 return { ok: false, error: "internal_error", message: "No supported algorithm offered" };844 }845846 const approved = await requestFirefoxApproval({847 type: "create",848 rpId: payload.rpId,849 rpName: payload.rpName,850 userName: payload.userName,851 userDisplayName: payload.userDisplayName,852 });853 if (!approved) return { ok: false, error: "user_cancelled" };854855 const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();856 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);857858 859 860 const authenticatorData = await buildAuthenticatorData({861 rpId: payload.rpId,862 flags: 0x45,863 signCount: 0,864 credentialId: credentialIdBytes,865 publicKeyCose,866 });867 const attestationObject = createAttestationObject(authenticatorData);868869 const passkey: PasskeyData = {870 rpId: payload.rpId,871 rpName: payload.rpName ?? payload.rpId,872 credentialId,873 publicKeyCose: base64urlEncode(publicKeyCose),874 privateKeyJwk,875 userHandle: payload.userId,876 userName: payload.userName,877 userDisplayName: payload.userDisplayName ?? payload.userName,878 algorithm,879 signCount: 0,880 aaguid: base64urlEncode(new Uint8Array(16)),881 backupEligible: false,882 backupState: false,883 browser: "firefox",884 transports: ["internal"],885 createdAt: Date.now(),886 };887888 const target = findAttachTarget(payload.rpId);889 if (target) {890 await vault.updateEntry(target.id, { passkey });891 } else {892 await vault.addEntry({893 type: "login",894 title: payload.rpName || payload.rpId,895 url: `https://${payload.rpId}`,896 username: payload.userName,897 passkey,898 });899 }900901 return {902 ok: true,903 credential: {904 type: "create",905 id: credentialId,906 907 908 909 clientDataJSON: payload.clientDataJSON,910 attestationObject: base64urlEncode(attestationObject),911 authenticatorData: base64urlEncode(authenticatorData),912 publicKey: base64urlEncode(publicKeySpki),913 algorithm,914 },915 };916}917918export async function handleFirefoxPasskeyGet(919 payload: FirefoxPasskeyGetPayload920): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {921 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {922 return { ok: false, error: "not_applicable" };923 }924 if (!(await isProUser())) {925 broadcastPasskeyProGate();926 return { ok: false, error: "pro_required" };927 }928 if (!vault.isUnlocked()) {929 await openApprovalPopup();930 return { ok: false, error: "vault_locked" };931 }932933 const allow = payload.allowCredentials ?? [];934 const candidates = listPasskeyEntries().filter((entry) => {935 if (entry.passkey.rpId !== payload.rpId) return false;936 if (allow.length === 0) return true;937 return allow.some((ac) => ac.id === entry.passkey.credentialId);938 });939940 if (candidates.length === 0) return { ok: false, error: "no_credentials" };941942 943 944 945 candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));946 const match = candidates[0]!;947 const credential = match.passkey;948949 if (!credential.privateKeyJwk) {950 951 952 953 return { ok: false, error: "no_credentials" };954 }955956 const approved = await requestFirefoxApproval({957 type: "get",958 rpId: credential.rpId,959 rpName: credential.rpName,960 userName: credential.userName,961 userDisplayName: credential.userDisplayName,962 });963 if (!approved) return { ok: false, error: "user_cancelled" };964965 const nextSignCount = credential.signCount + 1;966 967 968 const flags =969 0x05 |970 (credential.backupEligible ? 0x08 : 0) |971 (credential.backupState ? 0x10 : 0);972 const authenticatorData = await buildAuthenticatorData({973 rpId: credential.rpId,974 flags,975 signCount: nextSignCount,976 });977978 979 980 981 const clientDataBytes = new Uint8Array(payload.clientDataJSON);982 const clientDataHash = new Uint8Array(983 await crypto.subtle.digest("SHA-256", clientDataBytes)984 );985 const signature = await signPasskeyAssertion(986 credential.privateKeyJwk,987 credential.algorithm,988 authenticatorData,989 clientDataHash990 );991992 await vault.updateEntry(match.id, {993 passkey: {994 ...credential,995 signCount: nextSignCount,996 lastUsedAt: Date.now(),997 },998 });9991000 return {1001 ok: true,1002 credential: {1003 type: "get",1004 id: credential.credentialId,1005 clientDataJSON: payload.clientDataJSON,1006 authenticatorData: base64urlEncode(authenticatorData),1007 signature: base64urlEncode(signature),1008 userHandle: credential.userHandle,1009 },1010 };1011}10121013export async function setupPasskeyProxy(): Promise<void> {1014 if (!isPasskeySupported()) return;10151016 1017 1018 if (detectSharedBrowser() === "firefox") {1019 await refreshPasskeyProxy();1020 return;1021 }10221023 if (!listenersRegistered) {1024 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1025 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1026 requestId: requestInfo.requestId,1027 isUvpaa: vault.isUnlocked(),1028 });1029 });1030 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1031 void handleCreateRequest(requestInfo).catch(async (error) => {1032 console.error("Peach passkey create failed", error);1033 await rejectCreate(1034 requestInfo.requestId,1035 "UnknownError",1036 error instanceof Error ? error.message : "Passkey creation failed."1037 );1038 });1039 });1040 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1041 void handleGetRequest(requestInfo).catch(async (error) => {1042 console.error("Peach passkey get failed", error);1043 await rejectGet(1044 requestInfo.requestId,1045 "UnknownError",1046 error instanceof Error ? error.message : "Passkey authentication failed."1047 );1048 });1049 });1050 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1051 void resolvePendingPasskeyRequest(requestId, false);1052 });1053 listenersRegistered = true;1054 }10551056 await refreshPasskeyProxy();1057}