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}4950function isPasskeySupported(): boolean {51 if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;52 53 54 55 56 if (detectSharedBrowser() === "firefox") {57 const ver = firefoxVersion();58 if (Number.isFinite(ver) && ver >= 150) return true;59 }60 return false;61}6263export const detectBrowser = detectSharedBrowser;6465function getBrowserLabel(): string {66 const ua = navigator.userAgent;67 if (/Firefox\/(\d+)/.test(ua)) {68 const version = ua.match(/Firefox\/(\d+)/)?.[1];69 return version ? `Firefox ${version}` : "Firefox";70 }71 const version = ua.match(/Chrome\/(\d+)/)?.[1];72 return version ? `Chrome ${version}` : "Chromium";73}7475function pendingStorageKey(requestId: number): string {76 return `${PENDING_PREFIX}${requestId}`;77}7879function originFromRpId(rpId: string): string {80 return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)81 ? `http://${rpId}`82 : `https://${rpId}`;83}8485async function setBadge(text: string): Promise<void> {86 if (!chrome.action?.setBadgeText) return;87 await chrome.action.setBadgeText({ text });88 if (text) {89 await chrome.action.setBadgeBackgroundColor({ color: "#D96830" });90 }91}9293async function clearPendingRequest(requestId: number): Promise<void> {94 if (chrome.storage.session) {95 await chrome.storage.session.remove(pendingStorageKey(requestId));96 }97 await setBadge("");98}99100async function openApprovalPopup(): Promise<void> {101 try {102 await chrome.action.openPopup();103 } catch {104 await setBadge("PK");105 }106}107108async function rejectCreate(109 requestId: number,110 name: string,111 message = ""112): Promise<void> {113 await chrome.webAuthenticationProxy.completeCreateRequest({114 requestId,115 error: { name, message },116 });117}118119async function rejectGet(120 requestId: number,121 name: string,122 message = ""123): Promise<void> {124 await chrome.webAuthenticationProxy.completeGetRequest({125 requestId,126 error: { name, message },127 });128}129130export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {131 if (!chrome.storage.session) return null; 132 const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;133 const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));134 if (!key) return null;135 const pending = stored[key];136 return pending && typeof pending === "object"137 ? (pending as PendingPasskeyRequest)138 : null;139}140141export async function resolvePendingPasskeyRequest(142 requestId: number,143 approved: boolean144): Promise<void> {145 const pending = approvals.get(requestId);146 if (!pending) {147 148 149 150 151 await clearPendingRequest(requestId);152 if (chrome.storage.session) {153 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);154 }155 return;156 }157158 clearTimeout(pending.timeoutId);159 approvals.delete(requestId);160 await clearPendingRequest(requestId);161 pending.resolve(approved);162}163164async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {165 if (chrome.storage.session) {166 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });167 }168 await openApprovalPopup();169170 return new Promise<boolean>((resolve) => {171 const timeoutId = setTimeout(async () => {172 approvals.delete(request.requestId);173 await clearPendingRequest(request.requestId);174 resolve(false);175 }, APPROVAL_TIMEOUT_MS);176177 approvals.set(request.requestId, { resolve, timeoutId });178 });179}180181export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {182 const settings = await vault.ensureSettingsLoaded();183 const supported = isPasskeySupported();184 const enabled = settings.passkeyProviderEnabled === true;185 return {186 supported,187 enabled,188 attached: supported && attached,189 browserLabel: getBrowserLabel(),190 statusLabel: !supported191 ? "Interception unavailable"192 : attached193 ? "Active"194 : conflict195 ? "Another provider is active"196 : enabled197 ? "Inactive"198 : "Disabled",199 conflict,200 };201}202203async function attachPasskeyProxy(): Promise<void> {204 if (!isPasskeySupported()) {205 attached = false;206 conflict = "This browser does not expose chrome.webAuthenticationProxy.";207 return;208 }209210 211 212 213 if (detectSharedBrowser() === "firefox") {214 attached = true;215 conflict = undefined;216 return;217 }218219 if (attached) return;220221 try {222 await chrome.webAuthenticationProxy.attach();223 attached = true;224 conflict = undefined;225 } catch (error) {226 attached = false;227 conflict = error instanceof Error ? error.message : "Another provider is active.";228 }229}230231async function detachPasskeyProxy(): Promise<void> {232 if (!isPasskeySupported()) {233 attached = false;234 return;235 }236237 if (detectSharedBrowser() === "firefox") {238 attached = false;239 conflict = undefined;240 return;241 }242243 try {244 await chrome.webAuthenticationProxy.detach();245 } catch {246 247 }248249 attached = false;250 conflict = undefined;251}252253export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {254 await vault.updateSettings({ passkeyProviderEnabled: enabled });255 if (enabled) {256 await attachPasskeyProxy();257 } else {258 await detachPasskeyProxy();259 }260 return getPasskeyProviderState();261}262263async function refreshPasskeyProxy(): Promise<void> {264 const settings = await vault.ensureSettingsLoaded();265 if (settings.passkeyProviderEnabled === true) {266 await attachPasskeyProxy();267 return;268 }269 await detachPasskeyProxy();270}271272273274275276277278279function findAttachTarget(rpId: string): VaultEntry | null {280 const all = vault.getEntries();281 const passkeyMatch = all.find(282 (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt283 );284 if (passkeyMatch) return passkeyMatch;285 const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);286 if (loginMatches.length === 0) return null;287 288 return loginMatches.reduce((best, e) =>289 (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best290 );291}292293async function handleCreateRequest(294 request: chrome.webAuthenticationProxy.CreateRequest295): Promise<void> {296 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;297 const rpId = options.rp.id;298 const rpName = options.rp.name ?? options.rp.id;299 const userName = options.user.name;300 const userDisplayName = options.user.displayName ?? options.user.name;301302 if (!rpId) {303 await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");304 return;305 }306307 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {308 await rejectCreate(309 request.requestId,310 "NotAllowedError",311 "Passkey creation requires Peach Pro."312 );313 314 broadcastPasskeyProGate();315 return;316 }317318 if (!vault.isUnlocked()) {319 await openApprovalPopup();320 await rejectCreate(request.requestId, "NotAllowedError", "Unlock Peach first.");321 return;322 }323324 const approved = await promptUserApproval({325 requestId: request.requestId,326 type: "create",327 rpId,328 rpName,329 userName,330 userDisplayName,331 });332 if (!approved) {333 await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");334 return;335 }336337 const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);338 if (!algorithm) {339 await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");340 return;341 }342343 const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));344 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(345 algorithm346 );347 const authData = await buildAuthenticatorData({348 rpId,349 flags: 0x4d,350 signCount: 0,351 credentialId: credentialIdBytes,352 publicKeyCose,353 });354 const attestationObject = createAttestationObject(authData);355 const clientDataJSON = buildClientDataJSON("webauthn.create", options.challenge, originFromRpId(rpId));356357 const passkey: PasskeyData = {358 rpId,359 rpName,360 credentialId: base64urlEncode(credentialIdBytes),361 publicKeyCose: base64urlEncode(publicKeyCose),362 privateKeyJwk,363 userHandle: options.user.id,364 userName,365 userDisplayName,366 algorithm,367 signCount: 0,368 aaguid: base64urlEncode(new Uint8Array(16)),369 backupEligible: false,370 backupState: false,371 browser: "chrome",372 transports: ["internal"],373 createdAt: Date.now(),374 };375376 377 378 const target = findAttachTarget(rpId);379 if (target) {380 await vault.updateEntry(target.id, { passkey });381 } else {382 await vault.addEntry({383 type: "login",384 title: rpName,385 url: `https://${rpId}`,386 username: userName,387 passkey,388 });389 }390391 await chrome.webAuthenticationProxy.completeCreateRequest({392 requestId: request.requestId,393 responseJson: JSON.stringify({394 id: passkey.credentialId,395 rawId: passkey.credentialId,396 type: "public-key",397 response: {398 clientDataJSON: base64urlEncode(clientDataJSON),399 authenticatorData: base64urlEncode(authData),400 attestationObject: base64urlEncode(attestationObject),401 transports: ["internal"],402 publicKey: base64urlEncode(publicKeySpki),403 publicKeyAlgorithm: algorithm,404 },405 authenticatorAttachment: "platform",406 clientExtensionResults: {},407 }),408 });409}410411function broadcastPasskeyProGate(): void {412 chrome.tabs.query({}, (tabs) => {413 for (const tab of tabs) {414 if (tab.id !== undefined) {415 chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});416 }417 }418 });419}420421async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {422 if (chrome.storage.session) {423 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });424 }425426 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {427 chrome.tabs.query({ active: true, currentWindow: true }, resolve);428 });429 const tab = tabs[0];430431 if (tab?.id) {432 chrome.tabs.sendMessage(tab.id, {433 type: 'SHOW_PASSKEY_TOAST',434 payload: {435 site: request.rpId,436 displayName: request.userDisplayName ?? request.userName,437 username: request.userName,438 requestId: String(request.requestId),439 },440 }).catch(() => {});441 } else {442 await openApprovalPopup();443 }444445 return new Promise<boolean>((resolve) => {446 const timeoutId = setTimeout(async () => {447 approvals.delete(request.requestId);448 await clearPendingRequest(request.requestId);449 resolve(false);450 }, APPROVAL_TIMEOUT_MS);451452 approvals.set(request.requestId, { resolve, timeoutId });453 });454}455456async function handleGetRequest(457 request: chrome.webAuthenticationProxy.GetRequest458): Promise<void> {459 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;460 if (!vault.isUnlocked()) {461 462 463 464 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>465 chrome.tabs.query({ active: true, currentWindow: true }, resolve)466 );467 const activeTab = tabs[0];468 if (activeTab?.id) {469 chrome.tabs.sendMessage(activeTab.id, {470 type: 'SUMMON_AUTOFILL',471 source: 'passkey-unlock',472 }).catch(() => { void openApprovalPopup(); });473 } else {474 await openApprovalPopup();475 }476477 478 const unlocked = await new Promise<boolean>((resolve) => {479 const timeoutId = setTimeout(() => {480 unlockWaiters.delete(request.requestId);481 resolve(false);482 }, APPROVAL_TIMEOUT_MS);483 unlockWaiters.set(request.requestId, { resolve, timeoutId });484 });485486 if (!unlocked || !vault.isUnlocked()) {487 await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");488 return;489 }490 491 }492493 if (!(await isProUser())) {494 await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");495 broadcastPasskeyProGate();496 return;497 }498499 const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>500 entry.passkey !== undefined && !entry.deletedAt501 );502 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);503504 const match = passkeyBearing.find((entry) => {505 if (allowedCredentialIds.length > 0) {506 return allowedCredentialIds.includes(entry.passkey.credentialId);507 }508 if (!options.rpId) return false;509 return entry.passkey.rpId === options.rpId;510 });511512 if (!match) {513 await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");514 return;515 }516517 const credential = match.passkey;518 519 520 521 522 if (!credential.privateKeyJwk) {523 await rejectGet(524 request.requestId,525 "NotAllowedError",526 "This passkey was created on Firefox; sign in via the platform authenticator."527 );528 return;529 }530531 const approved = await promptViaToast({532 requestId: request.requestId,533 type: "get",534 rpId: credential.rpId,535 rpName: credential.rpName,536 userName: credential.userName,537 userDisplayName: credential.userDisplayName,538 });539 if (!approved) {540 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");541 return;542 }543544 const nextSignCount = credential.signCount + 1;545 const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);546 const authData = await buildAuthenticatorData({547 rpId: credential.rpId,548 flags,549 signCount: nextSignCount,550 });551 const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(credential.rpId));552 const clientDataHash = new Uint8Array(553 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))554 );555 const signature = await signPasskeyAssertion(556 credential.privateKeyJwk,557 credential.algorithm,558 authData,559 clientDataHash560 );561562 await vault.updateEntry(match.id, {563 passkey: {564 ...credential,565 signCount: nextSignCount,566 lastUsedAt: Date.now(),567 },568 });569570 const responseJson = JSON.stringify({571 id: credential.credentialId,572 rawId: credential.credentialId,573 type: "public-key",574 response: {575 clientDataJSON: base64urlEncode(clientDataJSON),576 authenticatorData: base64urlEncode(authData),577 signature: base64urlEncode(signature),578 userHandle: credential.userHandle,579 },580 authenticatorAttachment: "platform",581 clientExtensionResults: {},582 });583584 await chrome.webAuthenticationProxy.completeGetRequest({585 requestId: request.requestId,586 responseJson,587 });588}589590591592593594595596597598599const FIREFOX_PENDING_KEY = "passkey_pending_firefox";600const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";601const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; 602603export async function initiateFirefoxPasskeyCreate(args: {604 rpId: string;605 rpName: string;606 userName: string;607 userDisplayName?: string;608}): Promise<{ ok: boolean; error?: string }> {609 if (!(await isProUser())) {610 broadcastPasskeyProGate();611 return { ok: false, error: "passkey creation requires Peach Pro" };612 }613 if (!vault.isUnlocked()) {614 await openApprovalPopup();615 return { ok: false, error: "vault locked" };616 }617 if (!chrome.storage.session) {618 return { ok: false, error: "session storage unavailable" };619 }620621 const requestId = Date.now();622 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));623 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));624625 const pending: PendingPasskeyRequest = {626 requestId,627 type: "create",628 rpId: args.rpId,629 rpName: args.rpName,630 userName: args.userName,631 userDisplayName: args.userDisplayName,632 browser: "firefox",633 challenge,634 userHandle,635 };636637 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });638 await openApprovalPopup();639 return { ok: true };640}641642export async function completeFirefoxPasskeyCreate(payload: {643 requestId: number;644 rpId: string;645 rpName: string;646 userName: string;647 userDisplayName?: string;648 userHandle: string;649 credentialId: string;650 publicKeyCose: string;651 algorithm: -7 | -8;652 aaguid?: string;653 transports?: string[];654 backupEligible?: boolean;655 backupState?: boolean;656}): Promise<{ ok: boolean; error?: string }> {657 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };658659 const passkey: PasskeyData = {660 rpId: payload.rpId,661 rpName: payload.rpName,662 credentialId: payload.credentialId,663 publicKeyCose: payload.publicKeyCose,664 userHandle: payload.userHandle,665 userName: payload.userName,666 userDisplayName: payload.userDisplayName ?? payload.userName,667 algorithm: payload.algorithm,668 signCount: 0,669 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),670 backupEligible: payload.backupEligible ?? false,671 backupState: payload.backupState ?? false,672 browser: "firefox",673 transports: payload.transports ?? ["internal"],674 createdAt: Date.now(),675 };676677 678 679 680 const target = findAttachTarget(payload.rpId);681 if (target) {682 await vault.updateEntry(target.id, { passkey });683 } else {684 await vault.addEntry({685 type: "login",686 title: payload.rpName || payload.rpId,687 url: `https://${payload.rpId}`,688 username: payload.userName,689 passkey,690 });691 }692693 if (chrome.storage.session) {694 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);695 }696 return { ok: true };697}698699export async function cancelFirefoxPasskeyCreate(): Promise<void> {700 if (chrome.storage.session) {701 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);702 }703}704705function declineKey(domain: string): string {706 return `${FIREFOX_DECLINE_PREFIX}${domain}`;707}708709export async function recordPasskeyOfferDecline(domain: string): Promise<void> {710 if (!domain) return;711 await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });712}713714export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {715 if (!domain) return false;716 const key = declineKey(domain);717 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;718 const at = stored[key];719 if (typeof at !== "number") return false;720 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {721 await chrome.storage.local.remove(key);722 return false;723 }724 return true;725}726727728729730731732733734735736737738739740interface FirefoxAllowCredential {741 id: string;742 type?: string;743}744745interface FirefoxPubKeyCredParam {746 type?: string;747 alg: number;748}749750export interface FirefoxPasskeyHasCredentialsPayload {751 rpId: string;752 allowCredentials: FirefoxAllowCredential[];753}754755export interface FirefoxPasskeyCreatePayload {756 rpId: string;757 rpName?: string;758 userId: string;759 userName: string;760 userDisplayName?: string;761 challenge: string;762 clientDataJSON: number[];763 origin: string;764 pubKeyCredParams?: FirefoxPubKeyCredParam[];765 authenticatorSelection?: Record<string, unknown>;766 timeout?: number;767 attestation?: string;768}769770export interface FirefoxPasskeyGetPayload {771 rpId: string;772 challenge: string;773 clientDataJSON: number[];774 origin: string;775 allowCredentials: FirefoxAllowCredential[];776 userVerification?: string;777}778779interface FirefoxCreateCredentialResponse {780 type: "create";781 id: string;782 clientDataJSON: number[];783 attestationObject: string;784 authenticatorData: string;785 publicKey: string;786 algorithm: -7 | -8;787}788789interface FirefoxGetCredentialResponse {790 type: "get";791 id: string;792 clientDataJSON: number[];793 authenticatorData: string;794 signature: string;795 userHandle: string;796}797798type FirefoxPasskeyResult<T> =799 | { ok: true; credential: T }800 | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };801802function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {803 return vault.getEntries().filter(804 (entry): entry is VaultEntry & { passkey: PasskeyData } =>805 entry.passkey !== undefined && !entry.deletedAt806 );807}808809export async function handlePasskeyHasCredentials(810 payload: FirefoxPasskeyHasCredentialsPayload811): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {812 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {813 return { ok: false, error: "not_applicable" };814 }815 if (!vault.isUnlocked()) {816 817 818 819 return { ok: true, hasCredentials: false };820 }821 const passkeys = listPasskeyEntries().filter(822 (entry) => entry.passkey.rpId === payload.rpId823 );824 if (passkeys.length === 0) return { ok: true, hasCredentials: false };825826 const allow = payload.allowCredentials ?? [];827 if (allow.length === 0) return { ok: true, hasCredentials: true };828829 const hasMatch = passkeys.some((entry) =>830 allow.some((ac) => ac.id === entry.passkey.credentialId)831 );832 return { ok: true, hasCredentials: hasMatch };833}834835836837838839840841842async function requestFirefoxApproval(args: {843 type: "create" | "get";844 rpId: string;845 rpName?: string;846 userName: string;847 userDisplayName?: string;848}): Promise<boolean> {849 850 851 const requestId = Date.now() + Math.floor(Math.random() * 1000);852 return promptUserApproval({853 requestId,854 type: args.type,855 rpId: args.rpId,856 rpName: args.rpName ?? args.rpId,857 userName: args.userName,858 userDisplayName: args.userDisplayName,859 });860}861862export async function handleFirefoxPasskeyCreate(863 payload: FirefoxPasskeyCreatePayload864): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {865 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {866 return { ok: false, error: "not_applicable" };867 }868 if (!(await isProUser())) {869 broadcastPasskeyProGate();870 return { ok: false, error: "pro_required" };871 }872 if (!vault.isUnlocked()) {873 await openApprovalPopup();874 return { ok: false, error: "vault_locked" };875 }876877 const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);878 if (!algorithm) {879 return { ok: false, error: "internal_error", message: "No supported algorithm offered" };880 }881882 const approved = await requestFirefoxApproval({883 type: "create",884 rpId: payload.rpId,885 rpName: payload.rpName,886 userName: payload.userName,887 userDisplayName: payload.userDisplayName,888 });889 if (!approved) return { ok: false, error: "user_cancelled" };890891 const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();892 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);893894 895 896 const authenticatorData = await buildAuthenticatorData({897 rpId: payload.rpId,898 flags: 0x45,899 signCount: 0,900 credentialId: credentialIdBytes,901 publicKeyCose,902 });903 const attestationObject = createAttestationObject(authenticatorData);904905 const passkey: PasskeyData = {906 rpId: payload.rpId,907 rpName: payload.rpName ?? payload.rpId,908 credentialId,909 publicKeyCose: base64urlEncode(publicKeyCose),910 privateKeyJwk,911 userHandle: payload.userId,912 userName: payload.userName,913 userDisplayName: payload.userDisplayName ?? payload.userName,914 algorithm,915 signCount: 0,916 aaguid: base64urlEncode(new Uint8Array(16)),917 backupEligible: false,918 backupState: false,919 browser: "firefox",920 transports: ["internal"],921 createdAt: Date.now(),922 };923924 const target = findAttachTarget(payload.rpId);925 if (target) {926 await vault.updateEntry(target.id, { passkey });927 } else {928 await vault.addEntry({929 type: "login",930 title: payload.rpName || payload.rpId,931 url: `https://${payload.rpId}`,932 username: payload.userName,933 passkey,934 });935 }936937 return {938 ok: true,939 credential: {940 type: "create",941 id: credentialId,942 943 944 945 clientDataJSON: payload.clientDataJSON,946 attestationObject: base64urlEncode(attestationObject),947 authenticatorData: base64urlEncode(authenticatorData),948 publicKey: base64urlEncode(publicKeySpki),949 algorithm,950 },951 };952}953954export async function handleFirefoxPasskeyGet(955 payload: FirefoxPasskeyGetPayload956): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {957 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {958 return { ok: false, error: "not_applicable" };959 }960 if (!(await isProUser())) {961 broadcastPasskeyProGate();962 return { ok: false, error: "pro_required" };963 }964 if (!vault.isUnlocked()) {965 await openApprovalPopup();966 return { ok: false, error: "vault_locked" };967 }968969 const allow = payload.allowCredentials ?? [];970 const candidates = listPasskeyEntries().filter((entry) => {971 if (entry.passkey.rpId !== payload.rpId) return false;972 if (allow.length === 0) return true;973 return allow.some((ac) => ac.id === entry.passkey.credentialId);974 });975976 if (candidates.length === 0) return { ok: false, error: "no_credentials" };977978 979 980 981 candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));982 const match = candidates[0]!;983 const credential = match.passkey;984985 if (!credential.privateKeyJwk) {986 987 988 989 return { ok: false, error: "no_credentials" };990 }991992 const approved = await requestFirefoxApproval({993 type: "get",994 rpId: credential.rpId,995 rpName: credential.rpName,996 userName: credential.userName,997 userDisplayName: credential.userDisplayName,998 });999 if (!approved) return { ok: false, error: "user_cancelled" };10001001 const nextSignCount = credential.signCount + 1;1002 1003 1004 const flags =1005 0x05 |1006 (credential.backupEligible ? 0x08 : 0) |1007 (credential.backupState ? 0x10 : 0);1008 const authenticatorData = await buildAuthenticatorData({1009 rpId: credential.rpId,1010 flags,1011 signCount: nextSignCount,1012 });10131014 1015 1016 1017 const clientDataBytes = new Uint8Array(payload.clientDataJSON);1018 const clientDataHash = new Uint8Array(1019 await crypto.subtle.digest("SHA-256", clientDataBytes)1020 );1021 const signature = await signPasskeyAssertion(1022 credential.privateKeyJwk,1023 credential.algorithm,1024 authenticatorData,1025 clientDataHash1026 );10271028 await vault.updateEntry(match.id, {1029 passkey: {1030 ...credential,1031 signCount: nextSignCount,1032 lastUsedAt: Date.now(),1033 },1034 });10351036 return {1037 ok: true,1038 credential: {1039 type: "get",1040 id: credential.credentialId,1041 clientDataJSON: payload.clientDataJSON,1042 authenticatorData: base64urlEncode(authenticatorData),1043 signature: base64urlEncode(signature),1044 userHandle: credential.userHandle,1045 },1046 };1047}10481049export async function setupPasskeyProxy(): Promise<void> {1050 if (!isPasskeySupported()) return;10511052 1053 1054 if (detectSharedBrowser() === "firefox") {1055 await refreshPasskeyProxy();1056 return;1057 }10581059 if (!listenersRegistered) {1060 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1061 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1062 requestId: requestInfo.requestId,1063 isUvpaa: vault.isUnlocked(),1064 });1065 });1066 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1067 void handleCreateRequest(requestInfo).catch(async (error) => {1068 console.error("Peach passkey create failed", error);1069 await rejectCreate(1070 requestInfo.requestId,1071 "UnknownError",1072 error instanceof Error ? error.message : "Passkey creation failed."1073 );1074 });1075 });1076 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1077 void handleGetRequest(requestInfo).catch(async (error) => {1078 console.error("Peach passkey get failed", error);1079 await rejectGet(1080 requestInfo.requestId,1081 "UnknownError",1082 error instanceof Error ? error.message : "Passkey authentication failed."1083 );1084 });1085 });1086 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1087 void resolvePendingPasskeyRequest(requestId, false);1088 });1089 listenersRegistered = true;1090 }10911092 await refreshPasskeyProxy();1093}