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 642 643 644 const requestId = Date.now() + Math.floor(Math.random() * 1_000_000);645 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));646 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));647648 const pending: PendingPasskeyRequest = {649 requestId,650 type: "create",651 rpId: args.rpId,652 rpName: args.rpName,653 userName: args.userName,654 userDisplayName: args.userDisplayName,655 browser: "firefox",656 challenge,657 userHandle,658 };659660 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });661 await openApprovalPopup();662 return { ok: true };663}664665export async function completeFirefoxPasskeyCreate(payload: {666 requestId: number;667 rpId: string;668 rpName: string;669 userName: string;670 userDisplayName?: string;671 userHandle: string;672 credentialId: string;673 publicKeyCose: string;674 algorithm: -7 | -8;675 aaguid?: string;676 transports?: string[];677 backupEligible?: boolean;678 backupState?: boolean;679}): Promise<{ ok: boolean; error?: string }> {680 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };681682 const passkey: PasskeyData = {683 rpId: payload.rpId,684 rpName: payload.rpName,685 credentialId: payload.credentialId,686 publicKeyCose: payload.publicKeyCose,687 userHandle: payload.userHandle,688 userName: payload.userName,689 userDisplayName: payload.userDisplayName ?? payload.userName,690 algorithm: payload.algorithm,691 signCount: 0,692 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),693 backupEligible: payload.backupEligible ?? false,694 backupState: payload.backupState ?? false,695 browser: "firefox",696 transports: payload.transports ?? ["internal"],697 createdAt: Date.now(),698 };699700 701 702 703 const target = findAttachTarget(payload.rpId);704 if (target) {705 await vault.updateEntry(target.id, { passkey });706 } else {707 await vault.addEntry({708 type: "login",709 title: payload.rpName || payload.rpId,710 url: `https://${payload.rpId}`,711 username: payload.userName,712 passkey,713 });714 }715716 if (chrome.storage.session) {717 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);718 }719 return { ok: true };720}721722export async function cancelFirefoxPasskeyCreate(): Promise<void> {723 if (chrome.storage.session) {724 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);725 }726}727728function declineKey(domain: string): string {729 return `${FIREFOX_DECLINE_PREFIX}${domain}`;730}731732export async function recordPasskeyOfferDecline(domain: string): Promise<void> {733 if (!domain) return;734 await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });735}736737export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {738 if (!domain) return false;739 const key = declineKey(domain);740 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;741 const at = stored[key];742 if (typeof at !== "number") return false;743 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {744 await chrome.storage.local.remove(key);745 return false;746 }747 return true;748}749750751752753754755756757758759760761762763interface FirefoxAllowCredential {764 id: string;765 type?: string;766}767768interface FirefoxPubKeyCredParam {769 type?: string;770 alg: number;771}772773export interface FirefoxPasskeyHasCredentialsPayload {774 rpId: string;775 allowCredentials: FirefoxAllowCredential[];776}777778export interface FirefoxPasskeyCreatePayload {779 rpId: string;780 rpName?: string;781 userId: string;782 userName: string;783 userDisplayName?: string;784 challenge: string;785 clientDataJSON: number[];786 origin: string;787 pubKeyCredParams?: FirefoxPubKeyCredParam[];788 authenticatorSelection?: Record<string, unknown>;789 timeout?: number;790 attestation?: string;791}792793export interface FirefoxPasskeyGetPayload {794 rpId: string;795 challenge: string;796 clientDataJSON: number[];797 origin: string;798 allowCredentials: FirefoxAllowCredential[];799 userVerification?: string;800}801802interface FirefoxCreateCredentialResponse {803 type: "create";804 id: string;805 clientDataJSON: number[];806 attestationObject: string;807 authenticatorData: string;808 publicKey: string;809 algorithm: -7 | -8;810}811812interface FirefoxGetCredentialResponse {813 type: "get";814 id: string;815 clientDataJSON: number[];816 authenticatorData: string;817 signature: string;818 userHandle: string;819}820821type FirefoxPasskeyResult<T> =822 | { ok: true; credential: T }823 | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };824825function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {826 return vault.getEntries().filter(827 (entry): entry is VaultEntry & { passkey: PasskeyData } =>828 entry.passkey !== undefined && !entry.deletedAt829 );830}831832export async function handlePasskeyHasCredentials(833 payload: FirefoxPasskeyHasCredentialsPayload834): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {835 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {836 return { ok: false, error: "not_applicable" };837 }838 if (!vault.isUnlocked()) {839 840 841 842 return { ok: true, hasCredentials: false };843 }844 const passkeys = listPasskeyEntries().filter(845 (entry) => entry.passkey.rpId === payload.rpId846 );847 if (passkeys.length === 0) return { ok: true, hasCredentials: false };848849 const allow = payload.allowCredentials ?? [];850 if (allow.length === 0) return { ok: true, hasCredentials: true };851852 const hasMatch = passkeys.some((entry) =>853 allow.some((ac) => ac.id === entry.passkey.credentialId)854 );855 return { ok: true, hasCredentials: hasMatch };856}857858859860861862863864865async function requestFirefoxApproval(args: {866 type: "create" | "get";867 rpId: string;868 rpName?: string;869 userName: string;870 userDisplayName?: string;871}): Promise<boolean> {872 873 874 const requestId = Date.now() + Math.floor(Math.random() * 1000);875 return promptUserApproval({876 requestId,877 type: args.type,878 rpId: args.rpId,879 rpName: args.rpName ?? args.rpId,880 userName: args.userName,881 userDisplayName: args.userDisplayName,882 });883}884885export async function handleFirefoxPasskeyCreate(886 payload: FirefoxPasskeyCreatePayload887): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {888 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {889 return { ok: false, error: "not_applicable" };890 }891 if (!(await isProUser())) {892 broadcastPasskeyProGate();893 return { ok: false, error: "pro_required" };894 }895 if (!vault.isUnlocked()) {896 await openApprovalPopup();897 return { ok: false, error: "vault_locked" };898 }899900 const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);901 if (!algorithm) {902 return { ok: false, error: "internal_error", message: "No supported algorithm offered" };903 }904905 const approved = await requestFirefoxApproval({906 type: "create",907 rpId: payload.rpId,908 rpName: payload.rpName,909 userName: payload.userName,910 userDisplayName: payload.userDisplayName,911 });912 if (!approved) return { ok: false, error: "user_cancelled" };913914 const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();915 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);916917 918 919 const authenticatorData = await buildAuthenticatorData({920 rpId: payload.rpId,921 flags: 0x45,922 signCount: 0,923 credentialId: credentialIdBytes,924 publicKeyCose,925 });926 const attestationObject = createAttestationObject(authenticatorData);927928 const passkey: PasskeyData = {929 rpId: payload.rpId,930 rpName: payload.rpName ?? payload.rpId,931 credentialId,932 publicKeyCose: base64urlEncode(publicKeyCose),933 privateKeyJwk,934 userHandle: payload.userId,935 userName: payload.userName,936 userDisplayName: payload.userDisplayName ?? payload.userName,937 algorithm,938 signCount: 0,939 aaguid: base64urlEncode(new Uint8Array(16)),940 backupEligible: false,941 backupState: false,942 browser: "firefox",943 transports: ["internal"],944 createdAt: Date.now(),945 };946947 const target = findAttachTarget(payload.rpId);948 if (target) {949 await vault.updateEntry(target.id, { passkey });950 } else {951 await vault.addEntry({952 type: "login",953 title: payload.rpName || payload.rpId,954 url: `https://${payload.rpId}`,955 username: payload.userName,956 passkey,957 });958 }959960 return {961 ok: true,962 credential: {963 type: "create",964 id: credentialId,965 966 967 968 clientDataJSON: payload.clientDataJSON,969 attestationObject: base64urlEncode(attestationObject),970 authenticatorData: base64urlEncode(authenticatorData),971 publicKey: base64urlEncode(publicKeySpki),972 algorithm,973 },974 };975}976977export async function handleFirefoxPasskeyGet(978 payload: FirefoxPasskeyGetPayload979): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {980 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {981 return { ok: false, error: "not_applicable" };982 }983 if (!(await isProUser())) {984 broadcastPasskeyProGate();985 return { ok: false, error: "pro_required" };986 }987 if (!vault.isUnlocked()) {988 await openApprovalPopup();989 return { ok: false, error: "vault_locked" };990 }991992 const allow = payload.allowCredentials ?? [];993 const candidates = listPasskeyEntries().filter((entry) => {994 if (entry.passkey.rpId !== payload.rpId) return false;995 if (allow.length === 0) return true;996 return allow.some((ac) => ac.id === entry.passkey.credentialId);997 });998999 if (candidates.length === 0) return { ok: false, error: "no_credentials" };10001001 1002 1003 1004 candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1005 const match = candidates[0]!;1006 const credential = match.passkey;10071008 if (!credential.privateKeyJwk) {1009 1010 1011 1012 return { ok: false, error: "no_credentials" };1013 }10141015 const approved = await requestFirefoxApproval({1016 type: "get",1017 rpId: credential.rpId,1018 rpName: credential.rpName,1019 userName: credential.userName,1020 userDisplayName: credential.userDisplayName,1021 });1022 if (!approved) return { ok: false, error: "user_cancelled" };10231024 const nextSignCount = credential.signCount + 1;1025 1026 1027 const flags =1028 0x05 |1029 (credential.backupEligible ? 0x08 : 0) |1030 (credential.backupState ? 0x10 : 0);1031 const authenticatorData = await buildAuthenticatorData({1032 rpId: credential.rpId,1033 flags,1034 signCount: nextSignCount,1035 });10361037 1038 1039 1040 const clientDataBytes = new Uint8Array(payload.clientDataJSON);1041 const clientDataHash = new Uint8Array(1042 await crypto.subtle.digest("SHA-256", clientDataBytes)1043 );1044 const signature = await signPasskeyAssertion(1045 credential.privateKeyJwk,1046 credential.algorithm,1047 authenticatorData,1048 clientDataHash1049 );10501051 await vault.updateEntry(match.id, {1052 passkey: {1053 ...credential,1054 signCount: nextSignCount,1055 lastUsedAt: Date.now(),1056 },1057 });10581059 return {1060 ok: true,1061 credential: {1062 type: "get",1063 id: credential.credentialId,1064 clientDataJSON: payload.clientDataJSON,1065 authenticatorData: base64urlEncode(authenticatorData),1066 signature: base64urlEncode(signature),1067 userHandle: credential.userHandle,1068 },1069 };1070}10711072export async function setupPasskeyProxy(): Promise<void> {1073 if (!isPasskeySupported()) return;10741075 1076 1077 if (detectSharedBrowser() === "firefox") {1078 await refreshPasskeyProxy();1079 return;1080 }10811082 if (!listenersRegistered) {1083 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1084 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1085 requestId: requestInfo.requestId,1086 isUvpaa: vault.isUnlocked(),1087 });1088 });1089 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1090 void handleCreateRequest(requestInfo).catch(async (error) => {1091 console.error("Peach passkey create failed", error);1092 await rejectCreate(1093 requestInfo.requestId,1094 "UnknownError",1095 error instanceof Error ? error.message : "Passkey creation failed."1096 );1097 });1098 });1099 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1100 void handleGetRequest(requestInfo).catch(async (error) => {1101 console.error("Peach passkey get failed", error);1102 await rejectGet(1103 requestInfo.requestId,1104 "UnknownError",1105 error instanceof Error ? error.message : "Passkey authentication failed."1106 );1107 });1108 });1109 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1110 void resolvePendingPasskeyRequest(requestId, false);1111 });1112 listenersRegistered = true;1113 }11141115 await refreshPasskeyProxy();1116}