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 for (const [rid, { resolve, timeoutId }] of approvals.entries()) {57 clearTimeout(timeoutId);58 resolve(false);59 approvals.delete(rid);60 await clearPendingRequest(rid).catch(() => {});61 }62 for (const { resolve, timeoutId } of unlockWaiters.values()) {63 clearTimeout(timeoutId);64 resolve(false);65 }66 unlockWaiters.clear();67}6869function isPasskeySupported(): boolean {70 if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;71 72 73 74 75 if (detectSharedBrowser() === "firefox") {76 const ver = firefoxVersion();77 if (Number.isFinite(ver) && ver >= 150) return true;78 }79 return false;80}8182export const detectBrowser = detectSharedBrowser;8384function getBrowserLabel(): string {85 const ua = navigator.userAgent;86 if (/Firefox\/(\d+)/.test(ua)) {87 const version = ua.match(/Firefox\/(\d+)/)?.[1];88 return version ? `Firefox ${version}` : "Firefox";89 }90 const version = ua.match(/Chrome\/(\d+)/)?.[1];91 return version ? `Chrome ${version}` : "Chromium";92}9394function pendingStorageKey(requestId: number): string {95 return `${PENDING_PREFIX}${requestId}`;96}9798function originFromRpId(rpId: string): string {99 return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)100 ? `http://${rpId}`101 : `https://${rpId}`;102}103104async function setBadge(text: string): Promise<void> {105 if (!chrome.action?.setBadgeText) return;106 await chrome.action.setBadgeText({ text });107 if (text) {108 await chrome.action.setBadgeBackgroundColor({ color: "#E5805C" });109 }110}111112async function clearPendingRequest(requestId: number): Promise<void> {113 if (chrome.storage.session) {114 await chrome.storage.session.remove(pendingStorageKey(requestId));115 }116 await setBadge("");117}118119async function openApprovalPopup(): Promise<void> {120 try {121 await chrome.action.openPopup();122 } catch {123 await setBadge("PK");124 }125}126127async function rejectCreate(128 requestId: number,129 name: string,130 message = ""131): Promise<void> {132 await chrome.webAuthenticationProxy.completeCreateRequest({133 requestId,134 error: { name, message },135 });136}137138async function rejectGet(139 requestId: number,140 name: string,141 message = ""142): Promise<void> {143 await chrome.webAuthenticationProxy.completeGetRequest({144 requestId,145 error: { name, message },146 });147}148149export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {150 if (!chrome.storage.session) return null; 151 const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;152 const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));153 if (!key) return null;154 const pending = stored[key];155 return pending && typeof pending === "object"156 ? (pending as PendingPasskeyRequest)157 : null;158}159160export async function resolvePendingPasskeyRequest(161 requestId: number,162 approved: boolean163): Promise<void> {164 const pending = approvals.get(requestId);165 if (!pending) {166 167 168 169 170 await clearPendingRequest(requestId);171 if (chrome.storage.session) {172 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);173 }174 return;175 }176177 clearTimeout(pending.timeoutId);178 approvals.delete(requestId);179 await clearPendingRequest(requestId);180 pending.resolve(approved);181}182183async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {184 if (chrome.storage.session) {185 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });186 }187 await openApprovalPopup();188189 return new Promise<boolean>((resolve) => {190 const timeoutId = setTimeout(async () => {191 approvals.delete(request.requestId);192 await clearPendingRequest(request.requestId);193 resolve(false);194 }, APPROVAL_TIMEOUT_MS);195196 approvals.set(request.requestId, { resolve, timeoutId });197 });198}199200export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {201 const settings = await vault.ensureSettingsLoaded();202 const supported = isPasskeySupported();203 const enabled = settings.passkeyProviderEnabled === true;204 return {205 supported,206 enabled,207 attached: supported && attached,208 browserLabel: getBrowserLabel(),209 statusLabel: !supported210 ? "Interception unavailable"211 : attached212 ? "Active"213 : conflict214 ? "Another provider is active"215 : enabled216 ? "Inactive"217 : "Disabled",218 conflict,219 };220}221222async function attachPasskeyProxy(): Promise<void> {223 if (!isPasskeySupported()) {224 attached = false;225 conflict = "This browser does not expose chrome.webAuthenticationProxy.";226 return;227 }228229 230 231 232 if (detectSharedBrowser() === "firefox") {233 attached = true;234 conflict = undefined;235 return;236 }237238 if (attached) return;239240 try {241 await chrome.webAuthenticationProxy.attach();242 attached = true;243 conflict = undefined;244 } catch (error) {245 attached = false;246 conflict = error instanceof Error ? error.message : "Another provider is active.";247 }248}249250async function detachPasskeyProxy(): Promise<void> {251 if (!isPasskeySupported()) {252 attached = false;253 return;254 }255256 if (detectSharedBrowser() === "firefox") {257 attached = false;258 conflict = undefined;259 return;260 }261262 try {263 await chrome.webAuthenticationProxy.detach();264 } catch {265 266 }267268 attached = false;269 conflict = undefined;270}271272export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {273 await vault.updateSettings({ passkeyProviderEnabled: enabled });274 if (enabled) {275 await attachPasskeyProxy();276 } else {277 await detachPasskeyProxy();278 }279 return getPasskeyProviderState();280}281282async function refreshPasskeyProxy(): Promise<void> {283 const settings = await vault.ensureSettingsLoaded();284 if (settings.passkeyProviderEnabled === true) {285 await attachPasskeyProxy();286 return;287 }288 await detachPasskeyProxy();289}290291292293294295296297298function findAttachTarget(rpId: string): VaultEntry | null {299 const all = vault.getEntries();300 const passkeyMatch = all.find(301 (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt302 );303 if (passkeyMatch) return passkeyMatch;304 const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);305 if (loginMatches.length === 0) return null;306 307 return loginMatches.reduce((best, e) =>308 (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best309 );310}311312async function handleCreateRequest(313 request: chrome.webAuthenticationProxy.CreateRequest314): Promise<void> {315 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;316 const rpId = options.rp.id;317 const rpName = options.rp.name ?? options.rp.id;318 const userName = options.user.name;319 const userDisplayName = options.user.displayName ?? options.user.name;320321 if (!rpId) {322 await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");323 return;324 }325326 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {327 await rejectCreate(328 request.requestId,329 "NotAllowedError",330 "Passkey creation requires Peach Pro."331 );332 333 broadcastPasskeyProGate();334 return;335 }336337 if (!vault.isUnlocked()) {338 await openApprovalPopup();339 await rejectCreate(request.requestId, "NotAllowedError", "Unlock Peach first.");340 return;341 }342343 const approved = await promptUserApproval({344 requestId: request.requestId,345 type: "create",346 rpId,347 rpName,348 userName,349 userDisplayName,350 });351 if (!approved) {352 await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");353 return;354 }355356 const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);357 if (!algorithm) {358 await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");359 return;360 }361362 const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));363 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(364 algorithm365 );366 const authData = await buildAuthenticatorData({367 rpId,368 flags: 0x4d,369 signCount: 0,370 credentialId: credentialIdBytes,371 publicKeyCose,372 });373 const attestationObject = createAttestationObject(authData);374 const clientDataJSON = buildClientDataJSON("webauthn.create", options.challenge, originFromRpId(rpId));375376 const passkey: PasskeyData = {377 rpId,378 rpName,379 credentialId: base64urlEncode(credentialIdBytes),380 publicKeyCose: base64urlEncode(publicKeyCose),381 privateKeyJwk,382 userHandle: options.user.id,383 userName,384 userDisplayName,385 algorithm,386 signCount: 0,387 aaguid: base64urlEncode(new Uint8Array(16)),388 backupEligible: false,389 backupState: false,390 browser: "chrome",391 transports: ["internal"],392 createdAt: Date.now(),393 };394395 396 397 const target = findAttachTarget(rpId);398 if (target) {399 await vault.updateEntry(target.id, { passkey });400 } else {401 await vault.addEntry({402 type: "login",403 title: rpName,404 url: `https://${rpId}`,405 username: userName,406 passkey,407 });408 }409410 await chrome.webAuthenticationProxy.completeCreateRequest({411 requestId: request.requestId,412 responseJson: JSON.stringify({413 id: passkey.credentialId,414 rawId: passkey.credentialId,415 type: "public-key",416 response: {417 clientDataJSON: base64urlEncode(clientDataJSON),418 authenticatorData: base64urlEncode(authData),419 attestationObject: base64urlEncode(attestationObject),420 transports: ["internal"],421 publicKey: base64urlEncode(publicKeySpki),422 publicKeyAlgorithm: algorithm,423 },424 authenticatorAttachment: "platform",425 clientExtensionResults: {},426 }),427 });428}429430function broadcastPasskeyProGate(): void {431 chrome.tabs.query({}, (tabs) => {432 for (const tab of tabs) {433 if (tab.id !== undefined) {434 chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});435 }436 }437 });438}439440async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {441 if (chrome.storage.session) {442 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });443 }444445 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {446 chrome.tabs.query({ active: true, currentWindow: true }, resolve);447 });448 const tab = tabs[0];449450 if (tab?.id) {451 chrome.tabs.sendMessage(tab.id, {452 type: 'SHOW_PASSKEY_TOAST',453 payload: {454 site: request.rpId,455 displayName: request.userDisplayName ?? request.userName,456 username: request.userName,457 requestId: String(request.requestId),458 },459 }).catch(() => {});460 } else {461 await openApprovalPopup();462 }463464 return new Promise<boolean>((resolve) => {465 const timeoutId = setTimeout(async () => {466 approvals.delete(request.requestId);467 await clearPendingRequest(request.requestId);468 resolve(false);469 }, APPROVAL_TIMEOUT_MS);470471 approvals.set(request.requestId, { resolve, timeoutId });472 });473}474475async function handleGetRequest(476 request: chrome.webAuthenticationProxy.GetRequest477): Promise<void> {478 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;479 if (!vault.isUnlocked()) {480 481 482 483 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>484 chrome.tabs.query({ active: true, currentWindow: true }, resolve)485 );486 const activeTab = tabs[0];487 if (activeTab?.id) {488 chrome.tabs.sendMessage(activeTab.id, {489 type: 'SUMMON_AUTOFILL',490 source: 'passkey-unlock',491 }).catch(() => { void openApprovalPopup(); });492 } else {493 await openApprovalPopup();494 }495496 497 const unlocked = await new Promise<boolean>((resolve) => {498 const timeoutId = setTimeout(() => {499 unlockWaiters.delete(request.requestId);500 resolve(false);501 }, APPROVAL_TIMEOUT_MS);502 unlockWaiters.set(request.requestId, { resolve, timeoutId });503 });504505 if (!unlocked || !vault.isUnlocked()) {506 await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");507 return;508 }509 510 }511512 if (!(await isProUser())) {513 await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");514 broadcastPasskeyProGate();515 return;516 }517518 const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>519 entry.passkey !== undefined && !entry.deletedAt520 );521 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);522523 const match = passkeyBearing.find((entry) => {524 if (allowedCredentialIds.length > 0) {525 return allowedCredentialIds.includes(entry.passkey.credentialId);526 }527 if (!options.rpId) return false;528 return entry.passkey.rpId === options.rpId;529 });530531 if (!match) {532 await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");533 return;534 }535536 const credential = match.passkey;537 538 539 540 541 if (!credential.privateKeyJwk) {542 await rejectGet(543 request.requestId,544 "NotAllowedError",545 "This passkey was created on Firefox; sign in via the platform authenticator."546 );547 return;548 }549550 const approved = await promptViaToast({551 requestId: request.requestId,552 type: "get",553 rpId: credential.rpId,554 rpName: credential.rpName,555 userName: credential.userName,556 userDisplayName: credential.userDisplayName,557 });558 if (!approved) {559 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");560 return;561 }562563 const nextSignCount = credential.signCount + 1;564 const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);565 const authData = await buildAuthenticatorData({566 rpId: credential.rpId,567 flags,568 signCount: nextSignCount,569 });570 const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(credential.rpId));571 const clientDataHash = new Uint8Array(572 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))573 );574 const signature = await signPasskeyAssertion(575 credential.privateKeyJwk,576 credential.algorithm,577 authData,578 clientDataHash579 );580581 await vault.updateEntry(match.id, {582 passkey: {583 ...credential,584 signCount: nextSignCount,585 lastUsedAt: Date.now(),586 },587 });588589 const responseJson = JSON.stringify({590 id: credential.credentialId,591 rawId: credential.credentialId,592 type: "public-key",593 response: {594 clientDataJSON: base64urlEncode(clientDataJSON),595 authenticatorData: base64urlEncode(authData),596 signature: base64urlEncode(signature),597 userHandle: credential.userHandle,598 },599 authenticatorAttachment: "platform",600 clientExtensionResults: {},601 });602603 await chrome.webAuthenticationProxy.completeGetRequest({604 requestId: request.requestId,605 responseJson,606 });607}608609610611612613614615616617618const FIREFOX_PENDING_KEY = "passkey_pending_firefox";619const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";620const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; 621622export async function initiateFirefoxPasskeyCreate(args: {623 rpId: string;624 rpName: string;625 userName: string;626 userDisplayName?: string;627}): Promise<{ ok: boolean; error?: string }> {628 if (!(await isProUser())) {629 broadcastPasskeyProGate();630 return { ok: false, error: "passkey creation requires Peach Pro" };631 }632 if (!vault.isUnlocked()) {633 await openApprovalPopup();634 return { ok: false, error: "vault_locked" };635 }636 if (!chrome.storage.session) {637 return { ok: false, error: "session storage unavailable" };638 }639640 641 642 643 const requestId = Date.now() + Math.floor(Math.random() * 1_000_000);644 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));645 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));646647 const pending: PendingPasskeyRequest = {648 requestId,649 type: "create",650 rpId: args.rpId,651 rpName: args.rpName,652 userName: args.userName,653 userDisplayName: args.userDisplayName,654 browser: "firefox",655 challenge,656 userHandle,657 };658659 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });660 await openApprovalPopup();661 return { ok: true };662}663664export async function completeFirefoxPasskeyCreate(payload: {665 requestId: number;666 rpId: string;667 rpName: string;668 userName: string;669 userDisplayName?: string;670 userHandle: string;671 credentialId: string;672 publicKeyCose: string;673 algorithm: -7 | -8;674 aaguid?: string;675 transports?: string[];676 backupEligible?: boolean;677 backupState?: boolean;678}): Promise<{ ok: boolean; error?: string }> {679 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };680681 const passkey: PasskeyData = {682 rpId: payload.rpId,683 rpName: payload.rpName,684 credentialId: payload.credentialId,685 publicKeyCose: payload.publicKeyCose,686 userHandle: payload.userHandle,687 userName: payload.userName,688 userDisplayName: payload.userDisplayName ?? payload.userName,689 algorithm: payload.algorithm,690 signCount: 0,691 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),692 backupEligible: payload.backupEligible ?? false,693 backupState: payload.backupState ?? false,694 browser: "firefox",695 transports: payload.transports ?? ["internal"],696 createdAt: Date.now(),697 };698699 700 701 702 const target = findAttachTarget(payload.rpId);703 if (target) {704 await vault.updateEntry(target.id, { passkey });705 } else {706 await vault.addEntry({707 type: "login",708 title: payload.rpName || payload.rpId,709 url: `https://${payload.rpId}`,710 username: payload.userName,711 passkey,712 });713 }714715 if (chrome.storage.session) {716 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);717 }718 return { ok: true };719}720721export async function cancelFirefoxPasskeyCreate(): Promise<void> {722 if (chrome.storage.session) {723 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);724 }725}726727function declineKey(domain: string): string {728 return `${FIREFOX_DECLINE_PREFIX}${domain}`;729}730731export async function recordPasskeyOfferDecline(domain: string): Promise<void> {732 if (!domain) return;733 await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });734}735736export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {737 if (!domain) return false;738 const key = declineKey(domain);739 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;740 const at = stored[key];741 if (typeof at !== "number") return false;742 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {743 await chrome.storage.local.remove(key);744 return false;745 }746 return true;747}748749750751752753754755756757758759760761762interface FirefoxAllowCredential {763 id: string;764 type?: string;765}766767interface FirefoxPubKeyCredParam {768 type?: string;769 alg: number;770}771772export interface FirefoxPasskeyHasCredentialsPayload {773 rpId: string;774 allowCredentials: FirefoxAllowCredential[];775}776777export interface FirefoxPasskeyCreatePayload {778 rpId: string;779 rpName?: string;780 userId: string;781 userName: string;782 userDisplayName?: string;783 challenge: string;784 clientDataJSON: number[];785 origin: string;786 pubKeyCredParams?: FirefoxPubKeyCredParam[];787 authenticatorSelection?: Record<string, unknown>;788 timeout?: number;789 attestation?: string;790}791792export interface FirefoxPasskeyGetPayload {793 rpId: string;794 challenge: string;795 clientDataJSON: number[];796 origin: string;797 allowCredentials: FirefoxAllowCredential[];798 userVerification?: string;799}800801interface FirefoxCreateCredentialResponse {802 type: "create";803 id: string;804 clientDataJSON: number[];805 attestationObject: string;806 authenticatorData: string;807 publicKey: string;808 algorithm: -7 | -8;809}810811interface FirefoxGetCredentialResponse {812 type: "get";813 id: string;814 clientDataJSON: number[];815 authenticatorData: string;816 signature: string;817 userHandle: string;818}819820type FirefoxPasskeyResult<T> =821 | { ok: true; credential: T }822 | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };823824function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {825 return vault.getEntries().filter(826 (entry): entry is VaultEntry & { passkey: PasskeyData } =>827 entry.passkey !== undefined && !entry.deletedAt828 );829}830831export async function handlePasskeyHasCredentials(832 payload: FirefoxPasskeyHasCredentialsPayload833): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {834 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {835 return { ok: false, error: "not_applicable" };836 }837 if (!vault.isUnlocked()) {838 839 840 841 return { ok: true, hasCredentials: false };842 }843 const passkeys = listPasskeyEntries().filter(844 (entry) => entry.passkey.rpId === payload.rpId845 );846 if (passkeys.length === 0) return { ok: true, hasCredentials: false };847848 const allow = payload.allowCredentials ?? [];849 if (allow.length === 0) return { ok: true, hasCredentials: true };850851 const hasMatch = passkeys.some((entry) =>852 allow.some((ac) => ac.id === entry.passkey.credentialId)853 );854 return { ok: true, hasCredentials: hasMatch };855}856857858859860861862863864async function requestFirefoxApproval(args: {865 type: "create" | "get";866 rpId: string;867 rpName?: string;868 userName: string;869 userDisplayName?: string;870}): Promise<boolean> {871 872 873 const requestId = Date.now() + Math.floor(Math.random() * 1000);874 return promptUserApproval({875 requestId,876 type: args.type,877 rpId: args.rpId,878 rpName: args.rpName ?? args.rpId,879 userName: args.userName,880 userDisplayName: args.userDisplayName,881 });882}883884export async function handleFirefoxPasskeyCreate(885 payload: FirefoxPasskeyCreatePayload886): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {887 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {888 return { ok: false, error: "not_applicable" };889 }890 if (!(await isProUser())) {891 broadcastPasskeyProGate();892 return { ok: false, error: "pro_required" };893 }894 if (!vault.isUnlocked()) {895 await openApprovalPopup();896 return { ok: false, error: "vault_locked" };897 }898899 const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);900 if (!algorithm) {901 return { ok: false, error: "internal_error", message: "No supported algorithm offered" };902 }903904 const approved = await requestFirefoxApproval({905 type: "create",906 rpId: payload.rpId,907 rpName: payload.rpName,908 userName: payload.userName,909 userDisplayName: payload.userDisplayName,910 });911 if (!approved) return { ok: false, error: "user_cancelled" };912913 const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();914 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);915916 917 918 const authenticatorData = await buildAuthenticatorData({919 rpId: payload.rpId,920 flags: 0x45,921 signCount: 0,922 credentialId: credentialIdBytes,923 publicKeyCose,924 });925 const attestationObject = createAttestationObject(authenticatorData);926927 const passkey: PasskeyData = {928 rpId: payload.rpId,929 rpName: payload.rpName ?? payload.rpId,930 credentialId,931 publicKeyCose: base64urlEncode(publicKeyCose),932 privateKeyJwk,933 userHandle: payload.userId,934 userName: payload.userName,935 userDisplayName: payload.userDisplayName ?? payload.userName,936 algorithm,937 signCount: 0,938 aaguid: base64urlEncode(new Uint8Array(16)),939 backupEligible: false,940 backupState: false,941 browser: "firefox",942 transports: ["internal"],943 createdAt: Date.now(),944 };945946 const target = findAttachTarget(payload.rpId);947 if (target) {948 await vault.updateEntry(target.id, { passkey });949 } else {950 await vault.addEntry({951 type: "login",952 title: payload.rpName || payload.rpId,953 url: `https://${payload.rpId}`,954 username: payload.userName,955 passkey,956 });957 }958959 return {960 ok: true,961 credential: {962 type: "create",963 id: credentialId,964 965 966 967 clientDataJSON: payload.clientDataJSON,968 attestationObject: base64urlEncode(attestationObject),969 authenticatorData: base64urlEncode(authenticatorData),970 publicKey: base64urlEncode(publicKeySpki),971 algorithm,972 },973 };974}975976export async function handleFirefoxPasskeyGet(977 payload: FirefoxPasskeyGetPayload978): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {979 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {980 return { ok: false, error: "not_applicable" };981 }982 if (!(await isProUser())) {983 broadcastPasskeyProGate();984 return { ok: false, error: "pro_required" };985 }986 if (!vault.isUnlocked()) {987 await openApprovalPopup();988 return { ok: false, error: "vault_locked" };989 }990991 const allow = payload.allowCredentials ?? [];992 const candidates = listPasskeyEntries().filter((entry) => {993 if (entry.passkey.rpId !== payload.rpId) return false;994 if (allow.length === 0) return true;995 return allow.some((ac) => ac.id === entry.passkey.credentialId);996 });997998 if (candidates.length === 0) return { ok: false, error: "no_credentials" };9991000 1001 1002 1003 candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1004 const match = candidates[0]!;1005 const credential = match.passkey;10061007 if (!credential.privateKeyJwk) {1008 1009 1010 1011 return { ok: false, error: "no_credentials" };1012 }10131014 const approved = await requestFirefoxApproval({1015 type: "get",1016 rpId: credential.rpId,1017 rpName: credential.rpName,1018 userName: credential.userName,1019 userDisplayName: credential.userDisplayName,1020 });1021 if (!approved) return { ok: false, error: "user_cancelled" };10221023 const nextSignCount = credential.signCount + 1;1024 1025 1026 const flags =1027 0x05 |1028 (credential.backupEligible ? 0x08 : 0) |1029 (credential.backupState ? 0x10 : 0);1030 const authenticatorData = await buildAuthenticatorData({1031 rpId: credential.rpId,1032 flags,1033 signCount: nextSignCount,1034 });10351036 1037 1038 1039 const clientDataBytes = new Uint8Array(payload.clientDataJSON);1040 const clientDataHash = new Uint8Array(1041 await crypto.subtle.digest("SHA-256", clientDataBytes)1042 );1043 const signature = await signPasskeyAssertion(1044 credential.privateKeyJwk,1045 credential.algorithm,1046 authenticatorData,1047 clientDataHash1048 );10491050 await vault.updateEntry(match.id, {1051 passkey: {1052 ...credential,1053 signCount: nextSignCount,1054 lastUsedAt: Date.now(),1055 },1056 });10571058 return {1059 ok: true,1060 credential: {1061 type: "get",1062 id: credential.credentialId,1063 clientDataJSON: payload.clientDataJSON,1064 authenticatorData: base64urlEncode(authenticatorData),1065 signature: base64urlEncode(signature),1066 userHandle: credential.userHandle,1067 },1068 };1069}10701071export async function setupPasskeyProxy(): Promise<void> {1072 if (!isPasskeySupported()) return;10731074 1075 1076 if (detectSharedBrowser() === "firefox") {1077 await refreshPasskeyProxy();1078 return;1079 }10801081 if (!listenersRegistered) {1082 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1083 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1084 requestId: requestInfo.requestId,1085 isUvpaa: vault.isUnlocked(),1086 });1087 });1088 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1089 void handleCreateRequest(requestInfo).catch(async (error) => {1090 console.error("Peach passkey create failed", error);1091 await rejectCreate(1092 requestInfo.requestId,1093 "UnknownError",1094 error instanceof Error ? error.message : "Passkey creation failed."1095 );1096 });1097 });1098 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1099 void handleGetRequest(requestInfo).catch(async (error) => {1100 console.error("Peach passkey get failed", error);1101 await rejectGet(1102 requestInfo.requestId,1103 "UnknownError",1104 error instanceof Error ? error.message : "Passkey authentication failed."1105 );1106 });1107 });1108 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1109 void resolvePendingPasskeyRequest(requestId, false);1110 });1111 listenersRegistered = true;1112 }11131114 await refreshPasskeyProxy();1115}