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 handleGetRequest(410 request: chrome.webAuthenticationProxy.GetRequest411): Promise<void> {412 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;413 if (!vault.isUnlocked()) {414 await openApprovalPopup();415 await rejectGet(request.requestId, "NotAllowedError", "Unlock Peach first.");416 return;417 }418419 if (!(await isProUser())) {420 await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");421 broadcastPasskeyProGate();422 return;423 }424425 const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>426 entry.passkey !== undefined && !entry.deletedAt427 );428 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);429430 const match = passkeyBearing.find((entry) => {431 if (allowedCredentialIds.length > 0) {432 return allowedCredentialIds.includes(entry.passkey.credentialId);433 }434 if (!options.rpId) return false;435 return entry.passkey.rpId === options.rpId;436 });437438 if (!match) {439 await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");440 return;441 }442443 const credential = match.passkey;444 445 446 447 448 if (!credential.privateKeyJwk) {449 await rejectGet(450 request.requestId,451 "NotAllowedError",452 "This passkey was created on Firefox; sign in via the platform authenticator."453 );454 return;455 }456457 const approved = await promptUserApproval({458 requestId: request.requestId,459 type: "get",460 rpId: credential.rpId,461 rpName: credential.rpName,462 userName: credential.userName,463 userDisplayName: credential.userDisplayName,464 });465 if (!approved) {466 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");467 return;468 }469470 const nextSignCount = credential.signCount + 1;471 const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);472 const authData = await buildAuthenticatorData({473 rpId: credential.rpId,474 flags,475 signCount: nextSignCount,476 });477 const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(credential.rpId));478 const clientDataHash = new Uint8Array(479 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))480 );481 const signature = await signPasskeyAssertion(482 credential.privateKeyJwk,483 credential.algorithm,484 authData,485 clientDataHash486 );487488 await vault.updateEntry(match.id, {489 passkey: {490 ...credential,491 signCount: nextSignCount,492 lastUsedAt: Date.now(),493 },494 });495496 const responseJson = JSON.stringify({497 id: credential.credentialId,498 rawId: credential.credentialId,499 type: "public-key",500 response: {501 clientDataJSON: base64urlEncode(clientDataJSON),502 authenticatorData: base64urlEncode(authData),503 signature: base64urlEncode(signature),504 userHandle: credential.userHandle,505 },506 authenticatorAttachment: "platform",507 clientExtensionResults: {},508 });509510 console.log("[Peach passkey] completing get assertion", {511 rpId: credential.rpId,512 credentialId: credential.credentialId,513 algorithm: credential.algorithm,514 signCount: nextSignCount,515 flags: flags.toString(16),516 signatureBytes: signature.length,517 userHandle: credential.userHandle,518 });519520 await chrome.webAuthenticationProxy.completeGetRequest({521 requestId: request.requestId,522 responseJson,523 });524}525526527528529530531532533534535const FIREFOX_PENDING_KEY = "passkey_pending_firefox";536const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";537const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; 538539export async function initiateFirefoxPasskeyCreate(args: {540 rpId: string;541 rpName: string;542 userName: string;543 userDisplayName?: string;544}): Promise<{ ok: boolean; error?: string }> {545 if (!(await isProUser())) {546 broadcastPasskeyProGate();547 return { ok: false, error: "passkey creation requires Peach Pro" };548 }549 if (!vault.isUnlocked()) {550 await openApprovalPopup();551 return { ok: false, error: "vault locked" };552 }553 if (!chrome.storage.session) {554 return { ok: false, error: "session storage unavailable" };555 }556557 const requestId = Date.now();558 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));559 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));560561 const pending: PendingPasskeyRequest = {562 requestId,563 type: "create",564 rpId: args.rpId,565 rpName: args.rpName,566 userName: args.userName,567 userDisplayName: args.userDisplayName,568 browser: "firefox",569 challenge,570 userHandle,571 };572573 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });574 await openApprovalPopup();575 return { ok: true };576}577578export async function completeFirefoxPasskeyCreate(payload: {579 requestId: number;580 rpId: string;581 rpName: string;582 userName: string;583 userDisplayName?: string;584 userHandle: string;585 credentialId: string;586 publicKeyCose: string;587 algorithm: -7 | -8;588 aaguid?: string;589 transports?: string[];590 backupEligible?: boolean;591 backupState?: boolean;592}): Promise<{ ok: boolean; error?: string }> {593 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };594595 const passkey: PasskeyData = {596 rpId: payload.rpId,597 rpName: payload.rpName,598 credentialId: payload.credentialId,599 publicKeyCose: payload.publicKeyCose,600 userHandle: payload.userHandle,601 userName: payload.userName,602 userDisplayName: payload.userDisplayName ?? payload.userName,603 algorithm: payload.algorithm,604 signCount: 0,605 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),606 backupEligible: payload.backupEligible ?? false,607 backupState: payload.backupState ?? false,608 browser: "firefox",609 transports: payload.transports ?? ["internal"],610 createdAt: Date.now(),611 };612613 614 615 616 const target = findAttachTarget(payload.rpId);617 if (target) {618 await vault.updateEntry(target.id, { passkey });619 } else {620 await vault.addEntry({621 type: "login",622 title: payload.rpName || payload.rpId,623 url: `https://${payload.rpId}`,624 username: payload.userName,625 passkey,626 });627 }628629 if (chrome.storage.session) {630 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);631 }632 return { ok: true };633}634635export async function cancelFirefoxPasskeyCreate(): Promise<void> {636 if (chrome.storage.session) {637 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);638 }639}640641function declineKey(domain: string): string {642 return `${FIREFOX_DECLINE_PREFIX}${domain}`;643}644645export async function recordPasskeyOfferDecline(domain: string): Promise<void> {646 if (!domain) return;647 await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });648}649650export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {651 if (!domain) return false;652 const key = declineKey(domain);653 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;654 const at = stored[key];655 if (typeof at !== "number") return false;656 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {657 await chrome.storage.local.remove(key);658 return false;659 }660 return true;661}662663664665666667668669670671672673674675676interface FirefoxAllowCredential {677 id: string;678 type?: string;679}680681interface FirefoxPubKeyCredParam {682 type?: string;683 alg: number;684}685686export interface FirefoxPasskeyHasCredentialsPayload {687 rpId: string;688 allowCredentials: FirefoxAllowCredential[];689}690691export interface FirefoxPasskeyCreatePayload {692 rpId: string;693 rpName?: string;694 userId: string;695 userName: string;696 userDisplayName?: string;697 challenge: string;698 clientDataJSON: number[];699 origin: string;700 pubKeyCredParams?: FirefoxPubKeyCredParam[];701 authenticatorSelection?: Record<string, unknown>;702 timeout?: number;703 attestation?: string;704}705706export interface FirefoxPasskeyGetPayload {707 rpId: string;708 challenge: string;709 clientDataJSON: number[];710 origin: string;711 allowCredentials: FirefoxAllowCredential[];712 userVerification?: string;713}714715interface FirefoxCreateCredentialResponse {716 type: "create";717 id: string;718 clientDataJSON: number[];719 attestationObject: string;720 authenticatorData: string;721 publicKey: string;722 algorithm: -7 | -8;723}724725interface FirefoxGetCredentialResponse {726 type: "get";727 id: string;728 clientDataJSON: number[];729 authenticatorData: string;730 signature: string;731 userHandle: string;732}733734type FirefoxPasskeyResult<T> =735 | { ok: true; credential: T }736 | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };737738function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {739 return vault.getEntries().filter(740 (entry): entry is VaultEntry & { passkey: PasskeyData } =>741 entry.passkey !== undefined && !entry.deletedAt742 );743}744745export async function handlePasskeyHasCredentials(746 payload: FirefoxPasskeyHasCredentialsPayload747): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {748 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {749 return { ok: false, error: "not_applicable" };750 }751 if (!vault.isUnlocked()) {752 753 754 755 return { ok: true, hasCredentials: false };756 }757 const passkeys = listPasskeyEntries().filter(758 (entry) => entry.passkey.rpId === payload.rpId759 );760 if (passkeys.length === 0) return { ok: true, hasCredentials: false };761762 const allow = payload.allowCredentials ?? [];763 if (allow.length === 0) return { ok: true, hasCredentials: true };764765 const hasMatch = passkeys.some((entry) =>766 allow.some((ac) => ac.id === entry.passkey.credentialId)767 );768 return { ok: true, hasCredentials: hasMatch };769}770771772773774775776777778async function requestFirefoxApproval(args: {779 type: "create" | "get";780 rpId: string;781 rpName?: string;782 userName: string;783 userDisplayName?: string;784}): Promise<boolean> {785 786 787 const requestId = Date.now() + Math.floor(Math.random() * 1000);788 return promptUserApproval({789 requestId,790 type: args.type,791 rpId: args.rpId,792 rpName: args.rpName ?? args.rpId,793 userName: args.userName,794 userDisplayName: args.userDisplayName,795 });796}797798export async function handleFirefoxPasskeyCreate(799 payload: FirefoxPasskeyCreatePayload800): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {801 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {802 return { ok: false, error: "not_applicable" };803 }804 if (!(await isProUser())) {805 broadcastPasskeyProGate();806 return { ok: false, error: "pro_required" };807 }808 if (!vault.isUnlocked()) {809 await openApprovalPopup();810 return { ok: false, error: "vault_locked" };811 }812813 const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);814 if (!algorithm) {815 return { ok: false, error: "internal_error", message: "No supported algorithm offered" };816 }817818 const approved = await requestFirefoxApproval({819 type: "create",820 rpId: payload.rpId,821 rpName: payload.rpName,822 userName: payload.userName,823 userDisplayName: payload.userDisplayName,824 });825 if (!approved) return { ok: false, error: "user_cancelled" };826827 const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();828 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);829830 831 832 const authenticatorData = await buildAuthenticatorData({833 rpId: payload.rpId,834 flags: 0x45,835 signCount: 0,836 credentialId: credentialIdBytes,837 publicKeyCose,838 });839 const attestationObject = createAttestationObject(authenticatorData);840841 const passkey: PasskeyData = {842 rpId: payload.rpId,843 rpName: payload.rpName ?? payload.rpId,844 credentialId,845 publicKeyCose: base64urlEncode(publicKeyCose),846 privateKeyJwk,847 userHandle: payload.userId,848 userName: payload.userName,849 userDisplayName: payload.userDisplayName ?? payload.userName,850 algorithm,851 signCount: 0,852 aaguid: base64urlEncode(new Uint8Array(16)),853 backupEligible: false,854 backupState: false,855 browser: "firefox",856 transports: ["internal"],857 createdAt: Date.now(),858 };859860 const target = findAttachTarget(payload.rpId);861 if (target) {862 await vault.updateEntry(target.id, { passkey });863 } else {864 await vault.addEntry({865 type: "login",866 title: payload.rpName || payload.rpId,867 url: `https://${payload.rpId}`,868 username: payload.userName,869 passkey,870 });871 }872873 return {874 ok: true,875 credential: {876 type: "create",877 id: credentialId,878 879 880 881 clientDataJSON: payload.clientDataJSON,882 attestationObject: base64urlEncode(attestationObject),883 authenticatorData: base64urlEncode(authenticatorData),884 publicKey: base64urlEncode(publicKeySpki),885 algorithm,886 },887 };888}889890export async function handleFirefoxPasskeyGet(891 payload: FirefoxPasskeyGetPayload892): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {893 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {894 return { ok: false, error: "not_applicable" };895 }896 if (!(await isProUser())) {897 broadcastPasskeyProGate();898 return { ok: false, error: "pro_required" };899 }900 if (!vault.isUnlocked()) {901 await openApprovalPopup();902 return { ok: false, error: "vault_locked" };903 }904905 const allow = payload.allowCredentials ?? [];906 const candidates = listPasskeyEntries().filter((entry) => {907 if (entry.passkey.rpId !== payload.rpId) return false;908 if (allow.length === 0) return true;909 return allow.some((ac) => ac.id === entry.passkey.credentialId);910 });911912 if (candidates.length === 0) return { ok: false, error: "no_credentials" };913914 915 916 917 candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));918 const match = candidates[0]!;919 const credential = match.passkey;920921 if (!credential.privateKeyJwk) {922 923 924 925 return { ok: false, error: "no_credentials" };926 }927928 const approved = await requestFirefoxApproval({929 type: "get",930 rpId: credential.rpId,931 rpName: credential.rpName,932 userName: credential.userName,933 userDisplayName: credential.userDisplayName,934 });935 if (!approved) return { ok: false, error: "user_cancelled" };936937 const nextSignCount = credential.signCount + 1;938 939 940 const flags =941 0x05 |942 (credential.backupEligible ? 0x08 : 0) |943 (credential.backupState ? 0x10 : 0);944 const authenticatorData = await buildAuthenticatorData({945 rpId: credential.rpId,946 flags,947 signCount: nextSignCount,948 });949950 951 952 953 const clientDataBytes = new Uint8Array(payload.clientDataJSON);954 const clientDataHash = new Uint8Array(955 await crypto.subtle.digest("SHA-256", clientDataBytes)956 );957 const signature = await signPasskeyAssertion(958 credential.privateKeyJwk,959 credential.algorithm,960 authenticatorData,961 clientDataHash962 );963964 await vault.updateEntry(match.id, {965 passkey: {966 ...credential,967 signCount: nextSignCount,968 lastUsedAt: Date.now(),969 },970 });971972 return {973 ok: true,974 credential: {975 type: "get",976 id: credential.credentialId,977 clientDataJSON: payload.clientDataJSON,978 authenticatorData: base64urlEncode(authenticatorData),979 signature: base64urlEncode(signature),980 userHandle: credential.userHandle,981 },982 };983}984985export async function setupPasskeyProxy(): Promise<void> {986 if (!isPasskeySupported()) return;987988 989 990 if (detectSharedBrowser() === "firefox") {991 await refreshPasskeyProxy();992 return;993 }994995 if (!listenersRegistered) {996 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {997 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({998 requestId: requestInfo.requestId,999 isUvpaa: vault.isUnlocked(),1000 });1001 });1002 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1003 void handleCreateRequest(requestInfo).catch(async (error) => {1004 console.error("Peach passkey create failed", error);1005 await rejectCreate(1006 requestInfo.requestId,1007 "UnknownError",1008 error instanceof Error ? error.message : "Passkey creation failed."1009 );1010 });1011 });1012 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1013 void handleGetRequest(requestInfo).catch(async (error) => {1014 console.error("Peach passkey get failed", error);1015 await rejectGet(1016 requestInfo.requestId,1017 "UnknownError",1018 error instanceof Error ? error.message : "Passkey authentication failed."1019 );1020 });1021 });1022 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1023 void resolvePendingPasskeyRequest(requestId, false);1024 });1025 listenersRegistered = true;1026 }10271028 await refreshPasskeyProxy();1029}