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>();37383940const unlockWaiters = new Map<number, { resolve: (ok: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }>();4142export function onVaultUnlocked(): void {43 for (const { resolve, timeoutId } of unlockWaiters.values()) {44 clearTimeout(timeoutId);45 resolve(true);46 }47 unlockWaiters.clear();48}4950515253545556export async function denyAllPendingPasskeyRequests(): Promise<void> {57 for (const [rid, { resolve, timeoutId }] of approvals.entries()) {58 clearTimeout(timeoutId);59 resolve(false);60 approvals.delete(rid);61 await clearPendingRequest(rid).catch(() => {});62 }63 for (const { resolve, timeoutId } of unlockWaiters.values()) {64 clearTimeout(timeoutId);65 resolve(false);66 }67 unlockWaiters.clear();68}6970function isPasskeySupported(): boolean {71 if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;72 73 74 75 76 if (detectSharedBrowser() === "firefox") {77 const ver = firefoxVersion();78 if (Number.isFinite(ver) && ver >= 150) return true;79 }80 return false;81}8283export const detectBrowser = detectSharedBrowser;8485function getBrowserLabel(): string {86 const ua = navigator.userAgent;87 if (/Firefox\/(\d+)/.test(ua)) {88 const version = ua.match(/Firefox\/(\d+)/)?.[1];89 return version ? `Firefox ${version}` : "Firefox";90 }91 const version = ua.match(/Chrome\/(\d+)/)?.[1];92 return version ? `Chrome ${version}` : "Chromium";93}9495function pendingStorageKey(requestId: number): string {96 return `${PENDING_PREFIX}${requestId}`;97}9899function originFromRpId(rpId: string): string {100 return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)101 ? `http://${rpId}`102 : `https://${rpId}`;103}104105async function setBadge(text: string): Promise<void> {106 if (!chrome.action?.setBadgeText) return;107 await chrome.action.setBadgeText({ text });108 if (text) {109 await chrome.action.setBadgeBackgroundColor({ color: "#D96830" });110 }111}112113async function clearPendingRequest(requestId: number): Promise<void> {114 if (chrome.storage.session) {115 await chrome.storage.session.remove(pendingStorageKey(requestId));116 }117 await setBadge("");118}119120async function openApprovalPopup(): Promise<void> {121 try {122 await chrome.action.openPopup();123 } catch {124 await setBadge("PK");125 }126}127128async function rejectCreate(129 requestId: number,130 name: string,131 message = ""132): Promise<void> {133 await chrome.webAuthenticationProxy.completeCreateRequest({134 requestId,135 error: { name, message },136 });137}138139async function rejectGet(140 requestId: number,141 name: string,142 message = ""143): Promise<void> {144 await chrome.webAuthenticationProxy.completeGetRequest({145 requestId,146 error: { name, message },147 });148}149150export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {151 if (!chrome.storage.session) return null; 152 const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;153 const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));154 if (!key) return null;155 const pending = stored[key];156 return pending && typeof pending === "object"157 ? (pending as PendingPasskeyRequest)158 : null;159}160161export async function resolvePendingPasskeyRequest(162 requestId: number,163 approved: boolean164): Promise<void> {165 const pending = approvals.get(requestId);166 if (!pending) {167 168 169 170 171 await clearPendingRequest(requestId);172 if (chrome.storage.session) {173 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);174 }175 return;176 }177178 clearTimeout(pending.timeoutId);179 approvals.delete(requestId);180 await clearPendingRequest(requestId);181 pending.resolve(approved);182}183184async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {185 if (chrome.storage.session) {186 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });187 }188 await openApprovalPopup();189190 return new Promise<boolean>((resolve) => {191 const timeoutId = setTimeout(async () => {192 approvals.delete(request.requestId);193 await clearPendingRequest(request.requestId);194 resolve(false);195 }, APPROVAL_TIMEOUT_MS);196197 approvals.set(request.requestId, { resolve, timeoutId });198 });199}200201export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {202 const settings = await vault.ensureSettingsLoaded();203 const supported = isPasskeySupported();204 const enabled = settings.passkeyProviderEnabled === true;205 return {206 supported,207 enabled,208 attached: supported && attached,209 browserLabel: getBrowserLabel(),210 statusLabel: !supported211 ? "Interception unavailable"212 : attached213 ? "Active"214 : conflict215 ? "Another provider is active"216 : enabled217 ? "Inactive"218 : "Disabled",219 conflict,220 };221}222223async function attachPasskeyProxy(): Promise<void> {224 if (!isPasskeySupported()) {225 attached = false;226 conflict = "This browser does not expose chrome.webAuthenticationProxy.";227 return;228 }229230 231 232 233 if (detectSharedBrowser() === "firefox") {234 attached = true;235 conflict = undefined;236 return;237 }238239 if (attached) return;240241 try {242 await chrome.webAuthenticationProxy.attach();243 attached = true;244 conflict = undefined;245 } catch (error) {246 attached = false;247 conflict = error instanceof Error ? error.message : "Another provider is active.";248 }249}250251async function detachPasskeyProxy(): Promise<void> {252 if (!isPasskeySupported()) {253 attached = false;254 return;255 }256257 if (detectSharedBrowser() === "firefox") {258 attached = false;259 conflict = undefined;260 return;261 }262263 try {264 await chrome.webAuthenticationProxy.detach();265 } catch {266 267 }268269 attached = false;270 conflict = undefined;271}272273export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {274 await vault.updateSettings({ passkeyProviderEnabled: enabled });275 if (enabled) {276 await attachPasskeyProxy();277 } else {278 await detachPasskeyProxy();279 }280 return getPasskeyProviderState();281}282283async function refreshPasskeyProxy(): Promise<void> {284 const settings = await vault.ensureSettingsLoaded();285 if (settings.passkeyProviderEnabled === true) {286 await attachPasskeyProxy();287 return;288 }289 await detachPasskeyProxy();290}291292293294295296297298299function findAttachTarget(rpId: string): VaultEntry | null {300 const all = vault.getEntries();301 const passkeyMatch = all.find(302 (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt303 );304 if (passkeyMatch) return passkeyMatch;305 const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);306 if (loginMatches.length === 0) return null;307 308 return loginMatches.reduce((best, e) =>309 (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best310 );311}312313async function handleCreateRequest(314 request: chrome.webAuthenticationProxy.CreateRequest315): Promise<void> {316 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;317 const rpId = options.rp.id;318 const rpName = options.rp.name ?? options.rp.id;319 const userName = options.user.name;320 const userDisplayName = options.user.displayName ?? options.user.name;321322 if (!rpId) {323 await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");324 return;325 }326327 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {328 await rejectCreate(329 request.requestId,330 "NotAllowedError",331 "Passkey creation requires Peach Pro."332 );333 334 broadcastPasskeyProGate();335 return;336 }337338 if (!vault.isUnlocked()) {339 await openApprovalPopup();340 await rejectCreate(request.requestId, "NotAllowedError", "Unlock Peach first.");341 return;342 }343344 const approved = await promptUserApproval({345 requestId: request.requestId,346 type: "create",347 rpId,348 rpName,349 userName,350 userDisplayName,351 });352 if (!approved) {353 await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");354 return;355 }356357 const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);358 if (!algorithm) {359 await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");360 return;361 }362363 const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));364 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(365 algorithm366 );367 const authData = await buildAuthenticatorData({368 rpId,369 flags: 0x4d,370 signCount: 0,371 credentialId: credentialIdBytes,372 publicKeyCose,373 });374 const attestationObject = createAttestationObject(authData);375 const clientDataJSON = buildClientDataJSON("webauthn.create", options.challenge, originFromRpId(rpId));376377 const passkey: PasskeyData = {378 rpId,379 rpName,380 credentialId: base64urlEncode(credentialIdBytes),381 publicKeyCose: base64urlEncode(publicKeyCose),382 privateKeyJwk,383 userHandle: options.user.id,384 userName,385 userDisplayName,386 algorithm,387 signCount: 0,388 aaguid: base64urlEncode(new Uint8Array(16)),389 backupEligible: false,390 backupState: false,391 browser: "chrome",392 transports: ["internal"],393 createdAt: Date.now(),394 };395396 397 398 const target = findAttachTarget(rpId);399 if (target) {400 await vault.updateEntry(target.id, { passkey });401 } else {402 await vault.addEntry({403 type: "login",404 title: rpName,405 url: `https://${rpId}`,406 username: userName,407 passkey,408 });409 }410411 await chrome.webAuthenticationProxy.completeCreateRequest({412 requestId: request.requestId,413 responseJson: JSON.stringify({414 id: passkey.credentialId,415 rawId: passkey.credentialId,416 type: "public-key",417 response: {418 clientDataJSON: base64urlEncode(clientDataJSON),419 authenticatorData: base64urlEncode(authData),420 attestationObject: base64urlEncode(attestationObject),421 transports: ["internal"],422 publicKey: base64urlEncode(publicKeySpki),423 publicKeyAlgorithm: algorithm,424 },425 authenticatorAttachment: "platform",426 clientExtensionResults: {},427 }),428 });429}430431function broadcastPasskeyProGate(): void {432 chrome.tabs.query({}, (tabs) => {433 for (const tab of tabs) {434 if (tab.id !== undefined) {435 chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});436 }437 }438 });439}440441async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {442 if (chrome.storage.session) {443 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });444 }445446 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {447 chrome.tabs.query({ active: true, currentWindow: true }, resolve);448 });449 const tab = tabs[0];450451 if (tab?.id) {452 chrome.tabs.sendMessage(tab.id, {453 type: 'SHOW_PASSKEY_TOAST',454 payload: {455 site: request.rpId,456 displayName: request.userDisplayName ?? request.userName,457 username: request.userName,458 requestId: String(request.requestId),459 },460 }).catch(() => {});461 } else {462 await openApprovalPopup();463 }464465 return new Promise<boolean>((resolve) => {466 const timeoutId = setTimeout(async () => {467 approvals.delete(request.requestId);468 await clearPendingRequest(request.requestId);469 resolve(false);470 }, APPROVAL_TIMEOUT_MS);471472 approvals.set(request.requestId, { resolve, timeoutId });473 });474}475476async function handleGetRequest(477 request: chrome.webAuthenticationProxy.GetRequest478): Promise<void> {479 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;480 if (!vault.isUnlocked()) {481 482 483 484 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>485 chrome.tabs.query({ active: true, currentWindow: true }, resolve)486 );487 const activeTab = tabs[0];488 if (activeTab?.id) {489 chrome.tabs.sendMessage(activeTab.id, {490 type: 'SUMMON_AUTOFILL',491 source: 'passkey-unlock',492 }).catch(() => { void openApprovalPopup(); });493 } else {494 await openApprovalPopup();495 }496497 498 const unlocked = await new Promise<boolean>((resolve) => {499 const timeoutId = setTimeout(() => {500 unlockWaiters.delete(request.requestId);501 resolve(false);502 }, APPROVAL_TIMEOUT_MS);503 unlockWaiters.set(request.requestId, { resolve, timeoutId });504 });505506 if (!unlocked || !vault.isUnlocked()) {507 await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");508 return;509 }510 511 }512513 if (!(await isProUser())) {514 await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");515 broadcastPasskeyProGate();516 return;517 }518519 const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>520 entry.passkey !== undefined && !entry.deletedAt521 );522 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);523524 const match = passkeyBearing.find((entry) => {525 if (allowedCredentialIds.length > 0) {526 return allowedCredentialIds.includes(entry.passkey.credentialId);527 }528 if (!options.rpId) return false;529 return entry.passkey.rpId === options.rpId;530 });531532 if (!match) {533 await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");534 return;535 }536537 const credential = match.passkey;538 539 540 541 542 if (!credential.privateKeyJwk) {543 await rejectGet(544 request.requestId,545 "NotAllowedError",546 "This passkey was created on Firefox; sign in via the platform authenticator."547 );548 return;549 }550551 const approved = await promptViaToast({552 requestId: request.requestId,553 type: "get",554 rpId: credential.rpId,555 rpName: credential.rpName,556 userName: credential.userName,557 userDisplayName: credential.userDisplayName,558 });559 if (!approved) {560 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");561 return;562 }563564 const nextSignCount = credential.signCount + 1;565 const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);566 const authData = await buildAuthenticatorData({567 rpId: credential.rpId,568 flags,569 signCount: nextSignCount,570 });571 const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(credential.rpId));572 const clientDataHash = new Uint8Array(573 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))574 );575 const signature = await signPasskeyAssertion(576 credential.privateKeyJwk,577 credential.algorithm,578 authData,579 clientDataHash580 );581582 await vault.updateEntry(match.id, {583 passkey: {584 ...credential,585 signCount: nextSignCount,586 lastUsedAt: Date.now(),587 },588 });589590 const responseJson = JSON.stringify({591 id: credential.credentialId,592 rawId: credential.credentialId,593 type: "public-key",594 response: {595 clientDataJSON: base64urlEncode(clientDataJSON),596 authenticatorData: base64urlEncode(authData),597 signature: base64urlEncode(signature),598 userHandle: credential.userHandle,599 },600 authenticatorAttachment: "platform",601 clientExtensionResults: {},602 });603604 await chrome.webAuthenticationProxy.completeGetRequest({605 requestId: request.requestId,606 responseJson,607 });608}609610611612613614615616617618619const FIREFOX_PENDING_KEY = "passkey_pending_firefox";620const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";621const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; 622623export async function initiateFirefoxPasskeyCreate(args: {624 rpId: string;625 rpName: string;626 userName: string;627 userDisplayName?: string;628}): Promise<{ ok: boolean; error?: string }> {629 if (!(await isProUser())) {630 broadcastPasskeyProGate();631 return { ok: false, error: "passkey creation requires Peach Pro" };632 }633 if (!vault.isUnlocked()) {634 await openApprovalPopup();635 return { ok: false, error: "vault locked" };636 }637 if (!chrome.storage.session) {638 return { ok: false, error: "session storage unavailable" };639 }640641 const requestId = Date.now();642 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));643 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));644645 const pending: PendingPasskeyRequest = {646 requestId,647 type: "create",648 rpId: args.rpId,649 rpName: args.rpName,650 userName: args.userName,651 userDisplayName: args.userDisplayName,652 browser: "firefox",653 challenge,654 userHandle,655 };656657 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });658 await openApprovalPopup();659 return { ok: true };660}661662export async function completeFirefoxPasskeyCreate(payload: {663 requestId: number;664 rpId: string;665 rpName: string;666 userName: string;667 userDisplayName?: string;668 userHandle: string;669 credentialId: string;670 publicKeyCose: string;671 algorithm: -7 | -8;672 aaguid?: string;673 transports?: string[];674 backupEligible?: boolean;675 backupState?: boolean;676}): Promise<{ ok: boolean; error?: string }> {677 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };678679 const passkey: PasskeyData = {680 rpId: payload.rpId,681 rpName: payload.rpName,682 credentialId: payload.credentialId,683 publicKeyCose: payload.publicKeyCose,684 userHandle: payload.userHandle,685 userName: payload.userName,686 userDisplayName: payload.userDisplayName ?? payload.userName,687 algorithm: payload.algorithm,688 signCount: 0,689 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),690 backupEligible: payload.backupEligible ?? false,691 backupState: payload.backupState ?? false,692 browser: "firefox",693 transports: payload.transports ?? ["internal"],694 createdAt: Date.now(),695 };696697 698 699 700 const target = findAttachTarget(payload.rpId);701 if (target) {702 await vault.updateEntry(target.id, { passkey });703 } else {704 await vault.addEntry({705 type: "login",706 title: payload.rpName || payload.rpId,707 url: `https://${payload.rpId}`,708 username: payload.userName,709 passkey,710 });711 }712713 if (chrome.storage.session) {714 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);715 }716 return { ok: true };717}718719export async function cancelFirefoxPasskeyCreate(): Promise<void> {720 if (chrome.storage.session) {721 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);722 }723}724725function declineKey(domain: string): string {726 return `${FIREFOX_DECLINE_PREFIX}${domain}`;727}728729export async function recordPasskeyOfferDecline(domain: string): Promise<void> {730 if (!domain) return;731 await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });732}733734export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {735 if (!domain) return false;736 const key = declineKey(domain);737 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;738 const at = stored[key];739 if (typeof at !== "number") return false;740 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {741 await chrome.storage.local.remove(key);742 return false;743 }744 return true;745}746747748749750751752753754755756757758759760interface FirefoxAllowCredential {761 id: string;762 type?: string;763}764765interface FirefoxPubKeyCredParam {766 type?: string;767 alg: number;768}769770export interface FirefoxPasskeyHasCredentialsPayload {771 rpId: string;772 allowCredentials: FirefoxAllowCredential[];773}774775export interface FirefoxPasskeyCreatePayload {776 rpId: string;777 rpName?: string;778 userId: string;779 userName: string;780 userDisplayName?: string;781 challenge: string;782 clientDataJSON: number[];783 origin: string;784 pubKeyCredParams?: FirefoxPubKeyCredParam[];785 authenticatorSelection?: Record<string, unknown>;786 timeout?: number;787 attestation?: string;788}789790export interface FirefoxPasskeyGetPayload {791 rpId: string;792 challenge: string;793 clientDataJSON: number[];794 origin: string;795 allowCredentials: FirefoxAllowCredential[];796 userVerification?: string;797}798799interface FirefoxCreateCredentialResponse {800 type: "create";801 id: string;802 clientDataJSON: number[];803 attestationObject: string;804 authenticatorData: string;805 publicKey: string;806 algorithm: -7 | -8;807}808809interface FirefoxGetCredentialResponse {810 type: "get";811 id: string;812 clientDataJSON: number[];813 authenticatorData: string;814 signature: string;815 userHandle: string;816}817818type FirefoxPasskeyResult<T> =819 | { ok: true; credential: T }820 | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };821822function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {823 return vault.getEntries().filter(824 (entry): entry is VaultEntry & { passkey: PasskeyData } =>825 entry.passkey !== undefined && !entry.deletedAt826 );827}828829export async function handlePasskeyHasCredentials(830 payload: FirefoxPasskeyHasCredentialsPayload831): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {832 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {833 return { ok: false, error: "not_applicable" };834 }835 if (!vault.isUnlocked()) {836 837 838 839 return { ok: true, hasCredentials: false };840 }841 const passkeys = listPasskeyEntries().filter(842 (entry) => entry.passkey.rpId === payload.rpId843 );844 if (passkeys.length === 0) return { ok: true, hasCredentials: false };845846 const allow = payload.allowCredentials ?? [];847 if (allow.length === 0) return { ok: true, hasCredentials: true };848849 const hasMatch = passkeys.some((entry) =>850 allow.some((ac) => ac.id === entry.passkey.credentialId)851 );852 return { ok: true, hasCredentials: hasMatch };853}854855856857858859860861862async function requestFirefoxApproval(args: {863 type: "create" | "get";864 rpId: string;865 rpName?: string;866 userName: string;867 userDisplayName?: string;868}): Promise<boolean> {869 870 871 const requestId = Date.now() + Math.floor(Math.random() * 1000);872 return promptUserApproval({873 requestId,874 type: args.type,875 rpId: args.rpId,876 rpName: args.rpName ?? args.rpId,877 userName: args.userName,878 userDisplayName: args.userDisplayName,879 });880}881882export async function handleFirefoxPasskeyCreate(883 payload: FirefoxPasskeyCreatePayload884): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {885 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {886 return { ok: false, error: "not_applicable" };887 }888 if (!(await isProUser())) {889 broadcastPasskeyProGate();890 return { ok: false, error: "pro_required" };891 }892 if (!vault.isUnlocked()) {893 await openApprovalPopup();894 return { ok: false, error: "vault_locked" };895 }896897 const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);898 if (!algorithm) {899 return { ok: false, error: "internal_error", message: "No supported algorithm offered" };900 }901902 const approved = await requestFirefoxApproval({903 type: "create",904 rpId: payload.rpId,905 rpName: payload.rpName,906 userName: payload.userName,907 userDisplayName: payload.userDisplayName,908 });909 if (!approved) return { ok: false, error: "user_cancelled" };910911 const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();912 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);913914 915 916 const authenticatorData = await buildAuthenticatorData({917 rpId: payload.rpId,918 flags: 0x45,919 signCount: 0,920 credentialId: credentialIdBytes,921 publicKeyCose,922 });923 const attestationObject = createAttestationObject(authenticatorData);924925 const passkey: PasskeyData = {926 rpId: payload.rpId,927 rpName: payload.rpName ?? payload.rpId,928 credentialId,929 publicKeyCose: base64urlEncode(publicKeyCose),930 privateKeyJwk,931 userHandle: payload.userId,932 userName: payload.userName,933 userDisplayName: payload.userDisplayName ?? payload.userName,934 algorithm,935 signCount: 0,936 aaguid: base64urlEncode(new Uint8Array(16)),937 backupEligible: false,938 backupState: false,939 browser: "firefox",940 transports: ["internal"],941 createdAt: Date.now(),942 };943944 const target = findAttachTarget(payload.rpId);945 if (target) {946 await vault.updateEntry(target.id, { passkey });947 } else {948 await vault.addEntry({949 type: "login",950 title: payload.rpName || payload.rpId,951 url: `https://${payload.rpId}`,952 username: payload.userName,953 passkey,954 });955 }956957 return {958 ok: true,959 credential: {960 type: "create",961 id: credentialId,962 963 964 965 clientDataJSON: payload.clientDataJSON,966 attestationObject: base64urlEncode(attestationObject),967 authenticatorData: base64urlEncode(authenticatorData),968 publicKey: base64urlEncode(publicKeySpki),969 algorithm,970 },971 };972}973974export async function handleFirefoxPasskeyGet(975 payload: FirefoxPasskeyGetPayload976): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {977 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {978 return { ok: false, error: "not_applicable" };979 }980 if (!(await isProUser())) {981 broadcastPasskeyProGate();982 return { ok: false, error: "pro_required" };983 }984 if (!vault.isUnlocked()) {985 await openApprovalPopup();986 return { ok: false, error: "vault_locked" };987 }988989 const allow = payload.allowCredentials ?? [];990 const candidates = listPasskeyEntries().filter((entry) => {991 if (entry.passkey.rpId !== payload.rpId) return false;992 if (allow.length === 0) return true;993 return allow.some((ac) => ac.id === entry.passkey.credentialId);994 });995996 if (candidates.length === 0) return { ok: false, error: "no_credentials" };997998 999 1000 1001 candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1002 const match = candidates[0]!;1003 const credential = match.passkey;10041005 if (!credential.privateKeyJwk) {1006 1007 1008 1009 return { ok: false, error: "no_credentials" };1010 }10111012 const approved = await requestFirefoxApproval({1013 type: "get",1014 rpId: credential.rpId,1015 rpName: credential.rpName,1016 userName: credential.userName,1017 userDisplayName: credential.userDisplayName,1018 });1019 if (!approved) return { ok: false, error: "user_cancelled" };10201021 const nextSignCount = credential.signCount + 1;1022 1023 1024 const flags =1025 0x05 |1026 (credential.backupEligible ? 0x08 : 0) |1027 (credential.backupState ? 0x10 : 0);1028 const authenticatorData = await buildAuthenticatorData({1029 rpId: credential.rpId,1030 flags,1031 signCount: nextSignCount,1032 });10331034 1035 1036 1037 const clientDataBytes = new Uint8Array(payload.clientDataJSON);1038 const clientDataHash = new Uint8Array(1039 await crypto.subtle.digest("SHA-256", clientDataBytes)1040 );1041 const signature = await signPasskeyAssertion(1042 credential.privateKeyJwk,1043 credential.algorithm,1044 authenticatorData,1045 clientDataHash1046 );10471048 await vault.updateEntry(match.id, {1049 passkey: {1050 ...credential,1051 signCount: nextSignCount,1052 lastUsedAt: Date.now(),1053 },1054 });10551056 return {1057 ok: true,1058 credential: {1059 type: "get",1060 id: credential.credentialId,1061 clientDataJSON: payload.clientDataJSON,1062 authenticatorData: base64urlEncode(authenticatorData),1063 signature: base64urlEncode(signature),1064 userHandle: credential.userHandle,1065 },1066 };1067}10681069export async function setupPasskeyProxy(): Promise<void> {1070 if (!isPasskeySupported()) return;10711072 1073 1074 if (detectSharedBrowser() === "firefox") {1075 await refreshPasskeyProxy();1076 return;1077 }10781079 if (!listenersRegistered) {1080 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1081 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1082 requestId: requestInfo.requestId,1083 isUvpaa: vault.isUnlocked(),1084 });1085 });1086 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1087 void handleCreateRequest(requestInfo).catch(async (error) => {1088 console.error("Peach passkey create failed", error);1089 await rejectCreate(1090 requestInfo.requestId,1091 "UnknownError",1092 error instanceof Error ? error.message : "Passkey creation failed."1093 );1094 });1095 });1096 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1097 void handleGetRequest(requestInfo).catch(async (error) => {1098 console.error("Peach passkey get failed", error);1099 await rejectGet(1100 requestInfo.requestId,1101 "UnknownError",1102 error instanceof Error ? error.message : "Passkey authentication failed."1103 );1104 });1105 });1106 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1107 void resolvePendingPasskeyRequest(requestId, false);1108 });1109 listenersRegistered = true;1110 }11111112 await refreshPasskeyProxy();1113}