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 } else {430 await openApprovalPopup();431 }432433 return new Promise<boolean>((resolve) => {434 const timeoutId = setTimeout(async () => {435 approvals.delete(request.requestId);436 await clearPendingRequest(request.requestId);437 resolve(false);438 }, APPROVAL_TIMEOUT_MS);439440 approvals.set(request.requestId, { resolve, timeoutId });441 });442}443444async function handleGetRequest(445 request: chrome.webAuthenticationProxy.GetRequest446): Promise<void> {447 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;448 if (!vault.isUnlocked()) {449 await openApprovalPopup();450 await rejectGet(request.requestId, "NotAllowedError", "Unlock Peach first.");451 return;452 }453454 if (!(await isProUser())) {455 await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");456 broadcastPasskeyProGate();457 return;458 }459460 const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>461 entry.passkey !== undefined && !entry.deletedAt462 );463 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);464465 const match = passkeyBearing.find((entry) => {466 if (allowedCredentialIds.length > 0) {467 return allowedCredentialIds.includes(entry.passkey.credentialId);468 }469 if (!options.rpId) return false;470 return entry.passkey.rpId === options.rpId;471 });472473 if (!match) {474 await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");475 return;476 }477478 const credential = match.passkey;479 480 481 482 483 if (!credential.privateKeyJwk) {484 await rejectGet(485 request.requestId,486 "NotAllowedError",487 "This passkey was created on Firefox; sign in via the platform authenticator."488 );489 return;490 }491492 const approved = await promptViaToast({493 requestId: request.requestId,494 type: "get",495 rpId: credential.rpId,496 rpName: credential.rpName,497 userName: credential.userName,498 userDisplayName: credential.userDisplayName,499 });500 if (!approved) {501 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");502 return;503 }504505 const nextSignCount = credential.signCount + 1;506 const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);507 const authData = await buildAuthenticatorData({508 rpId: credential.rpId,509 flags,510 signCount: nextSignCount,511 });512 const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(credential.rpId));513 const clientDataHash = new Uint8Array(514 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))515 );516 const signature = await signPasskeyAssertion(517 credential.privateKeyJwk,518 credential.algorithm,519 authData,520 clientDataHash521 );522523 await vault.updateEntry(match.id, {524 passkey: {525 ...credential,526 signCount: nextSignCount,527 lastUsedAt: Date.now(),528 },529 });530531 const responseJson = JSON.stringify({532 id: credential.credentialId,533 rawId: credential.credentialId,534 type: "public-key",535 response: {536 clientDataJSON: base64urlEncode(clientDataJSON),537 authenticatorData: base64urlEncode(authData),538 signature: base64urlEncode(signature),539 userHandle: credential.userHandle,540 },541 authenticatorAttachment: "platform",542 clientExtensionResults: {},543 });544545 await chrome.webAuthenticationProxy.completeGetRequest({546 requestId: request.requestId,547 responseJson,548 });549}550551552553554555556557558559560const FIREFOX_PENDING_KEY = "passkey_pending_firefox";561const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";562const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; 563564export async function initiateFirefoxPasskeyCreate(args: {565 rpId: string;566 rpName: string;567 userName: string;568 userDisplayName?: string;569}): Promise<{ ok: boolean; error?: string }> {570 if (!(await isProUser())) {571 broadcastPasskeyProGate();572 return { ok: false, error: "passkey creation requires Peach Pro" };573 }574 if (!vault.isUnlocked()) {575 await openApprovalPopup();576 return { ok: false, error: "vault locked" };577 }578 if (!chrome.storage.session) {579 return { ok: false, error: "session storage unavailable" };580 }581582 const requestId = Date.now();583 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));584 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));585586 const pending: PendingPasskeyRequest = {587 requestId,588 type: "create",589 rpId: args.rpId,590 rpName: args.rpName,591 userName: args.userName,592 userDisplayName: args.userDisplayName,593 browser: "firefox",594 challenge,595 userHandle,596 };597598 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });599 await openApprovalPopup();600 return { ok: true };601}602603export async function completeFirefoxPasskeyCreate(payload: {604 requestId: number;605 rpId: string;606 rpName: string;607 userName: string;608 userDisplayName?: string;609 userHandle: string;610 credentialId: string;611 publicKeyCose: string;612 algorithm: -7 | -8;613 aaguid?: string;614 transports?: string[];615 backupEligible?: boolean;616 backupState?: boolean;617}): Promise<{ ok: boolean; error?: string }> {618 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };619620 const passkey: PasskeyData = {621 rpId: payload.rpId,622 rpName: payload.rpName,623 credentialId: payload.credentialId,624 publicKeyCose: payload.publicKeyCose,625 userHandle: payload.userHandle,626 userName: payload.userName,627 userDisplayName: payload.userDisplayName ?? payload.userName,628 algorithm: payload.algorithm,629 signCount: 0,630 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),631 backupEligible: payload.backupEligible ?? false,632 backupState: payload.backupState ?? false,633 browser: "firefox",634 transports: payload.transports ?? ["internal"],635 createdAt: Date.now(),636 };637638 639 640 641 const target = findAttachTarget(payload.rpId);642 if (target) {643 await vault.updateEntry(target.id, { passkey });644 } else {645 await vault.addEntry({646 type: "login",647 title: payload.rpName || payload.rpId,648 url: `https://${payload.rpId}`,649 username: payload.userName,650 passkey,651 });652 }653654 if (chrome.storage.session) {655 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);656 }657 return { ok: true };658}659660export async function cancelFirefoxPasskeyCreate(): Promise<void> {661 if (chrome.storage.session) {662 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);663 }664}665666function declineKey(domain: string): string {667 return `${FIREFOX_DECLINE_PREFIX}${domain}`;668}669670export async function recordPasskeyOfferDecline(domain: string): Promise<void> {671 if (!domain) return;672 await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });673}674675export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {676 if (!domain) return false;677 const key = declineKey(domain);678 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;679 const at = stored[key];680 if (typeof at !== "number") return false;681 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {682 await chrome.storage.local.remove(key);683 return false;684 }685 return true;686}687688689690691692693694695696697698699700701interface FirefoxAllowCredential {702 id: string;703 type?: string;704}705706interface FirefoxPubKeyCredParam {707 type?: string;708 alg: number;709}710711export interface FirefoxPasskeyHasCredentialsPayload {712 rpId: string;713 allowCredentials: FirefoxAllowCredential[];714}715716export interface FirefoxPasskeyCreatePayload {717 rpId: string;718 rpName?: string;719 userId: string;720 userName: string;721 userDisplayName?: string;722 challenge: string;723 clientDataJSON: number[];724 origin: string;725 pubKeyCredParams?: FirefoxPubKeyCredParam[];726 authenticatorSelection?: Record<string, unknown>;727 timeout?: number;728 attestation?: string;729}730731export interface FirefoxPasskeyGetPayload {732 rpId: string;733 challenge: string;734 clientDataJSON: number[];735 origin: string;736 allowCredentials: FirefoxAllowCredential[];737 userVerification?: string;738}739740interface FirefoxCreateCredentialResponse {741 type: "create";742 id: string;743 clientDataJSON: number[];744 attestationObject: string;745 authenticatorData: string;746 publicKey: string;747 algorithm: -7 | -8;748}749750interface FirefoxGetCredentialResponse {751 type: "get";752 id: string;753 clientDataJSON: number[];754 authenticatorData: string;755 signature: string;756 userHandle: string;757}758759type FirefoxPasskeyResult<T> =760 | { ok: true; credential: T }761 | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };762763function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {764 return vault.getEntries().filter(765 (entry): entry is VaultEntry & { passkey: PasskeyData } =>766 entry.passkey !== undefined && !entry.deletedAt767 );768}769770export async function handlePasskeyHasCredentials(771 payload: FirefoxPasskeyHasCredentialsPayload772): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {773 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {774 return { ok: false, error: "not_applicable" };775 }776 if (!vault.isUnlocked()) {777 778 779 780 return { ok: true, hasCredentials: false };781 }782 const passkeys = listPasskeyEntries().filter(783 (entry) => entry.passkey.rpId === payload.rpId784 );785 if (passkeys.length === 0) return { ok: true, hasCredentials: false };786787 const allow = payload.allowCredentials ?? [];788 if (allow.length === 0) return { ok: true, hasCredentials: true };789790 const hasMatch = passkeys.some((entry) =>791 allow.some((ac) => ac.id === entry.passkey.credentialId)792 );793 return { ok: true, hasCredentials: hasMatch };794}795796797798799800801802803async function requestFirefoxApproval(args: {804 type: "create" | "get";805 rpId: string;806 rpName?: string;807 userName: string;808 userDisplayName?: string;809}): Promise<boolean> {810 811 812 const requestId = Date.now() + Math.floor(Math.random() * 1000);813 return promptUserApproval({814 requestId,815 type: args.type,816 rpId: args.rpId,817 rpName: args.rpName ?? args.rpId,818 userName: args.userName,819 userDisplayName: args.userDisplayName,820 });821}822823export async function handleFirefoxPasskeyCreate(824 payload: FirefoxPasskeyCreatePayload825): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {826 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {827 return { ok: false, error: "not_applicable" };828 }829 if (!(await isProUser())) {830 broadcastPasskeyProGate();831 return { ok: false, error: "pro_required" };832 }833 if (!vault.isUnlocked()) {834 await openApprovalPopup();835 return { ok: false, error: "vault_locked" };836 }837838 const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);839 if (!algorithm) {840 return { ok: false, error: "internal_error", message: "No supported algorithm offered" };841 }842843 const approved = await requestFirefoxApproval({844 type: "create",845 rpId: payload.rpId,846 rpName: payload.rpName,847 userName: payload.userName,848 userDisplayName: payload.userDisplayName,849 });850 if (!approved) return { ok: false, error: "user_cancelled" };851852 const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();853 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);854855 856 857 const authenticatorData = await buildAuthenticatorData({858 rpId: payload.rpId,859 flags: 0x45,860 signCount: 0,861 credentialId: credentialIdBytes,862 publicKeyCose,863 });864 const attestationObject = createAttestationObject(authenticatorData);865866 const passkey: PasskeyData = {867 rpId: payload.rpId,868 rpName: payload.rpName ?? payload.rpId,869 credentialId,870 publicKeyCose: base64urlEncode(publicKeyCose),871 privateKeyJwk,872 userHandle: payload.userId,873 userName: payload.userName,874 userDisplayName: payload.userDisplayName ?? payload.userName,875 algorithm,876 signCount: 0,877 aaguid: base64urlEncode(new Uint8Array(16)),878 backupEligible: false,879 backupState: false,880 browser: "firefox",881 transports: ["internal"],882 createdAt: Date.now(),883 };884885 const target = findAttachTarget(payload.rpId);886 if (target) {887 await vault.updateEntry(target.id, { passkey });888 } else {889 await vault.addEntry({890 type: "login",891 title: payload.rpName || payload.rpId,892 url: `https://${payload.rpId}`,893 username: payload.userName,894 passkey,895 });896 }897898 return {899 ok: true,900 credential: {901 type: "create",902 id: credentialId,903 904 905 906 clientDataJSON: payload.clientDataJSON,907 attestationObject: base64urlEncode(attestationObject),908 authenticatorData: base64urlEncode(authenticatorData),909 publicKey: base64urlEncode(publicKeySpki),910 algorithm,911 },912 };913}914915export async function handleFirefoxPasskeyGet(916 payload: FirefoxPasskeyGetPayload917): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {918 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {919 return { ok: false, error: "not_applicable" };920 }921 if (!(await isProUser())) {922 broadcastPasskeyProGate();923 return { ok: false, error: "pro_required" };924 }925 if (!vault.isUnlocked()) {926 await openApprovalPopup();927 return { ok: false, error: "vault_locked" };928 }929930 const allow = payload.allowCredentials ?? [];931 const candidates = listPasskeyEntries().filter((entry) => {932 if (entry.passkey.rpId !== payload.rpId) return false;933 if (allow.length === 0) return true;934 return allow.some((ac) => ac.id === entry.passkey.credentialId);935 });936937 if (candidates.length === 0) return { ok: false, error: "no_credentials" };938939 940 941 942 candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));943 const match = candidates[0]!;944 const credential = match.passkey;945946 if (!credential.privateKeyJwk) {947 948 949 950 return { ok: false, error: "no_credentials" };951 }952953 const approved = await requestFirefoxApproval({954 type: "get",955 rpId: credential.rpId,956 rpName: credential.rpName,957 userName: credential.userName,958 userDisplayName: credential.userDisplayName,959 });960 if (!approved) return { ok: false, error: "user_cancelled" };961962 const nextSignCount = credential.signCount + 1;963 964 965 const flags =966 0x05 |967 (credential.backupEligible ? 0x08 : 0) |968 (credential.backupState ? 0x10 : 0);969 const authenticatorData = await buildAuthenticatorData({970 rpId: credential.rpId,971 flags,972 signCount: nextSignCount,973 });974975 976 977 978 const clientDataBytes = new Uint8Array(payload.clientDataJSON);979 const clientDataHash = new Uint8Array(980 await crypto.subtle.digest("SHA-256", clientDataBytes)981 );982 const signature = await signPasskeyAssertion(983 credential.privateKeyJwk,984 credential.algorithm,985 authenticatorData,986 clientDataHash987 );988989 await vault.updateEntry(match.id, {990 passkey: {991 ...credential,992 signCount: nextSignCount,993 lastUsedAt: Date.now(),994 },995 });996997 return {998 ok: true,999 credential: {1000 type: "get",1001 id: credential.credentialId,1002 clientDataJSON: payload.clientDataJSON,1003 authenticatorData: base64urlEncode(authenticatorData),1004 signature: base64urlEncode(signature),1005 userHandle: credential.userHandle,1006 },1007 };1008}10091010export async function setupPasskeyProxy(): Promise<void> {1011 if (!isPasskeySupported()) return;10121013 1014 1015 if (detectSharedBrowser() === "firefox") {1016 await refreshPasskeyProxy();1017 return;1018 }10191020 if (!listenersRegistered) {1021 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1022 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1023 requestId: requestInfo.requestId,1024 isUvpaa: vault.isUnlocked(),1025 });1026 });1027 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1028 void handleCreateRequest(requestInfo).catch(async (error) => {1029 console.error("Peach passkey create failed", error);1030 await rejectCreate(1031 requestInfo.requestId,1032 "UnknownError",1033 error instanceof Error ? error.message : "Passkey creation failed."1034 );1035 });1036 });1037 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1038 void handleGetRequest(requestInfo).catch(async (error) => {1039 console.error("Peach passkey get failed", error);1040 await rejectGet(1041 requestInfo.requestId,1042 "UnknownError",1043 error instanceof Error ? error.message : "Passkey authentication failed."1044 );1045 });1046 });1047 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1048 void resolvePendingPasskeyRequest(requestId, false);1049 });1050 listenersRegistered = true;1051 }10521053 await refreshPasskeyProxy();1054}