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}103104105106107108109110111112113114115116117118119async function resolvePageOrigin(rpId: string): Promise<string> {120 try {121 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>122 chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)123 );124 const url = tabs[0]?.url;125 if (url) {126 const parsed = new URL(url);127 const host = parsed.hostname.toLowerCase();128 const rp = rpId.toLowerCase();129 if (host === rp || host.endsWith(`.${rp}`)) {130 return parsed.origin;131 }132 }133 } catch {134 135 }136 return originFromRpId(rpId);137}138139async function setBadge(text: string): Promise<void> {140 if (!chrome.action?.setBadgeText) return;141 await chrome.action.setBadgeText({ text });142 if (text) {143 await chrome.action.setBadgeBackgroundColor({ color: "#E5805C" });144 }145}146147async function clearPendingRequest(requestId: number): Promise<void> {148 if (chrome.storage.session) {149 await chrome.storage.session.remove(pendingStorageKey(requestId));150 }151 await setBadge("");152}153154async function openApprovalPopup(): Promise<void> {155 try {156 await chrome.action.openPopup();157 } catch {158 await setBadge("PK");159 }160}161162async function rejectCreate(163 requestId: number,164 name: string,165 message = ""166): Promise<void> {167 await chrome.webAuthenticationProxy.completeCreateRequest({168 requestId,169 error: { name, message },170 });171}172173async function rejectGet(174 requestId: number,175 name: string,176 message = ""177): Promise<void> {178 await chrome.webAuthenticationProxy.completeGetRequest({179 requestId,180 error: { name, message },181 });182}183184export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {185 if (!chrome.storage.session) return null; 186 const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;187 const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));188 if (!key) return null;189 const pending = stored[key];190 return pending && typeof pending === "object"191 ? (pending as PendingPasskeyRequest)192 : null;193}194195export async function resolvePendingPasskeyRequest(196 requestId: number,197 approved: boolean198): Promise<void> {199 const pending = approvals.get(requestId);200 if (!pending) {201 202 203 204 205 await clearPendingRequest(requestId);206 if (chrome.storage.session) {207 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);208 }209 return;210 }211212 clearTimeout(pending.timeoutId);213 approvals.delete(requestId);214 await clearPendingRequest(requestId);215 pending.resolve(approved);216}217218async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {219 if (chrome.storage.session) {220 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });221 }222 await openApprovalPopup();223224 return new Promise<boolean>((resolve) => {225 const timeoutId = setTimeout(async () => {226 approvals.delete(request.requestId);227 await clearPendingRequest(request.requestId);228 resolve(false);229 }, APPROVAL_TIMEOUT_MS);230231 approvals.set(request.requestId, { resolve, timeoutId });232 });233}234235export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {236 const settings = await vault.ensureSettingsLoaded();237 const supported = isPasskeySupported();238 const enabled = settings.passkeyProviderEnabled === true;239 return {240 supported,241 enabled,242 attached: supported && attached,243 browserLabel: getBrowserLabel(),244 statusLabel: !supported245 ? "Interception unavailable"246 : attached247 ? "Active"248 : conflict249 ? "Another provider is active"250 : enabled251 ? "Inactive"252 : "Disabled",253 conflict,254 };255}256257async function attachPasskeyProxy(): Promise<void> {258 if (!isPasskeySupported()) {259 attached = false;260 conflict = "This browser does not expose chrome.webAuthenticationProxy.";261 return;262 }263264 265 266 267 if (detectSharedBrowser() === "firefox") {268 attached = true;269 conflict = undefined;270 return;271 }272273 if (attached) return;274275 try {276 await chrome.webAuthenticationProxy.attach();277 attached = true;278 conflict = undefined;279 } catch (error) {280 attached = false;281 conflict = error instanceof Error ? error.message : "Another provider is active.";282 }283}284285async function detachPasskeyProxy(): Promise<void> {286 if (!isPasskeySupported()) {287 attached = false;288 return;289 }290291 if (detectSharedBrowser() === "firefox") {292 attached = false;293 conflict = undefined;294 return;295 }296297 try {298 await chrome.webAuthenticationProxy.detach();299 } catch {300 301 }302303 attached = false;304 conflict = undefined;305}306307export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {308 await vault.updateSettings({ passkeyProviderEnabled: enabled });309 if (enabled) {310 await attachPasskeyProxy();311 } else {312 await detachPasskeyProxy();313 }314 return getPasskeyProviderState();315}316317async function refreshPasskeyProxy(): Promise<void> {318 const settings = await vault.ensureSettingsLoaded();319 if (settings.passkeyProviderEnabled === true) {320 await attachPasskeyProxy();321 return;322 }323 await detachPasskeyProxy();324}325326327328329330331332333function findAttachTarget(rpId: string): VaultEntry | null {334 const all = vault.getEntries();335 const passkeyMatch = all.find(336 (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt337 );338 if (passkeyMatch) return passkeyMatch;339 const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);340 if (loginMatches.length === 0) return null;341 342 return loginMatches.reduce((best, e) =>343 (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best344 );345}346347async function handleCreateRequest(348 request: chrome.webAuthenticationProxy.CreateRequest349): Promise<void> {350 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;351 const rpId = options.rp.id;352 const rpName = options.rp.name ?? options.rp.id;353 const userName = options.user.name;354 const userDisplayName = options.user.displayName ?? options.user.name;355356 if (!rpId) {357 await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");358 return;359 }360361 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {362 await rejectCreate(363 request.requestId,364 "NotAllowedError",365 "Passkey creation requires Peach Pro."366 );367 368 broadcastPasskeyProGate();369 return;370 }371372 if (!vault.isUnlocked()) {373 374 375 376 377 378 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>379 chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)380 );381 const activeTab = tabs[0];382 if (activeTab?.id) {383 chrome.tabs.sendMessage(activeTab.id, {384 type: 'SUMMON_AUTOFILL',385 source: 'passkey-unlock',386 }).catch(() => { void openApprovalPopup(); });387 } else {388 await openApprovalPopup();389 }390391 const unlocked = await new Promise<boolean>((resolve) => {392 const timeoutId = setTimeout(() => {393 unlockWaiters.delete(request.requestId);394 resolve(false);395 }, APPROVAL_TIMEOUT_MS);396 unlockWaiters.set(request.requestId, { resolve, timeoutId });397 });398399 if (!unlocked || !vault.isUnlocked()) {400 await rejectCreate(request.requestId, "NotAllowedError", "Not unlocked in time.");401 return;402 }403 404 }405406 const approved = await promptUserApproval({407 requestId: request.requestId,408 type: "create",409 rpId,410 rpName,411 userName,412 userDisplayName,413 });414 if (!approved) {415 await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");416 return;417 }418419 const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);420 if (!algorithm) {421 await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");422 return;423 }424425 const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));426 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(427 algorithm428 );429 const authData = await buildAuthenticatorData({430 rpId,431 flags: 0x4d,432 signCount: 0,433 credentialId: credentialIdBytes,434 publicKeyCose,435 });436 const attestationObject = createAttestationObject(authData);437 const clientDataJSON = buildClientDataJSON(438 "webauthn.create",439 options.challenge,440 await resolvePageOrigin(rpId)441 );442443 const passkey: PasskeyData = {444 rpId,445 rpName,446 credentialId: base64urlEncode(credentialIdBytes),447 publicKeyCose: base64urlEncode(publicKeyCose),448 privateKeyJwk,449 userHandle: options.user.id,450 userName,451 userDisplayName,452 algorithm,453 signCount: 0,454 aaguid: base64urlEncode(new Uint8Array(16)),455 backupEligible: false,456 backupState: false,457 browser: "chrome",458 transports: ["internal"],459 createdAt: Date.now(),460 };461462 463 464 const target = findAttachTarget(rpId);465 if (target) {466 await vault.updateEntry(target.id, { passkey });467 } else {468 await vault.addEntry({469 type: "login",470 title: rpName,471 url: `https://${rpId}`,472 username: userName,473 passkey,474 });475 }476477 await chrome.webAuthenticationProxy.completeCreateRequest({478 requestId: request.requestId,479 responseJson: JSON.stringify({480 id: passkey.credentialId,481 rawId: passkey.credentialId,482 type: "public-key",483 response: {484 clientDataJSON: base64urlEncode(clientDataJSON),485 authenticatorData: base64urlEncode(authData),486 attestationObject: base64urlEncode(attestationObject),487 transports: ["internal"],488 publicKey: base64urlEncode(publicKeySpki),489 publicKeyAlgorithm: algorithm,490 },491 authenticatorAttachment: "platform",492 clientExtensionResults: {},493 }),494 });495}496497function broadcastPasskeyProGate(): void {498 chrome.tabs.query({}, (tabs) => {499 for (const tab of tabs) {500 if (tab.id !== undefined) {501 chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});502 }503 }504 });505}506507async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {508 if (chrome.storage.session) {509 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });510 }511512 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {513 chrome.tabs.query({ active: true, currentWindow: true }, resolve);514 });515 const tab = tabs[0];516517 if (tab?.id) {518 chrome.tabs.sendMessage(tab.id, {519 type: 'SHOW_PASSKEY_TOAST',520 payload: {521 site: request.rpId,522 displayName: request.userDisplayName ?? request.userName,523 username: request.userName,524 requestId: String(request.requestId),525 },526 }).catch(() => {});527 } else {528 await openApprovalPopup();529 }530531 return new Promise<boolean>((resolve) => {532 const timeoutId = setTimeout(async () => {533 approvals.delete(request.requestId);534 await clearPendingRequest(request.requestId);535 resolve(false);536 }, APPROVAL_TIMEOUT_MS);537538 approvals.set(request.requestId, { resolve, timeoutId });539 });540}541542async function handleGetRequest(543 request: chrome.webAuthenticationProxy.GetRequest544): Promise<void> {545 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;546 if (!vault.isUnlocked()) {547 548 549 550 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>551 chrome.tabs.query({ active: true, currentWindow: true }, resolve)552 );553 const activeTab = tabs[0];554 if (activeTab?.id) {555 chrome.tabs.sendMessage(activeTab.id, {556 type: 'SUMMON_AUTOFILL',557 source: 'passkey-unlock',558 }).catch(() => { void openApprovalPopup(); });559 } else {560 await openApprovalPopup();561 }562563 564 const unlocked = await new Promise<boolean>((resolve) => {565 const timeoutId = setTimeout(() => {566 unlockWaiters.delete(request.requestId);567 resolve(false);568 }, APPROVAL_TIMEOUT_MS);569 unlockWaiters.set(request.requestId, { resolve, timeoutId });570 });571572 if (!unlocked || !vault.isUnlocked()) {573 await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");574 return;575 }576 577 }578579 if (!(await isProUser())) {580 await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");581 broadcastPasskeyProGate();582 return;583 }584585 const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>586 entry.passkey !== undefined && !entry.deletedAt587 );588 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);589590 const match = passkeyBearing.find((entry) => {591 if (allowedCredentialIds.length > 0) {592 return allowedCredentialIds.includes(entry.passkey.credentialId);593 }594 if (!options.rpId) return false;595 return entry.passkey.rpId === options.rpId;596 });597598 if (!match) {599 await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");600 return;601 }602603 const credential = match.passkey;604 605 606 607 608 if (!credential.privateKeyJwk) {609 await rejectGet(610 request.requestId,611 "NotAllowedError",612 "This passkey was created on Firefox; sign in via the platform authenticator."613 );614 return;615 }616617 const approved = await promptViaToast({618 requestId: request.requestId,619 type: "get",620 rpId: credential.rpId,621 rpName: credential.rpName,622 userName: credential.userName,623 userDisplayName: credential.userDisplayName,624 });625 if (!approved) {626 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");627 return;628 }629630 const nextSignCount = credential.signCount + 1;631 const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);632 const authData = await buildAuthenticatorData({633 rpId: credential.rpId,634 flags,635 signCount: nextSignCount,636 });637 const clientDataJSON = buildClientDataJSON(638 "webauthn.get",639 options.challenge,640 await resolvePageOrigin(credential.rpId)641 );642 const clientDataHash = new Uint8Array(643 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))644 );645 const signature = await signPasskeyAssertion(646 credential.privateKeyJwk,647 credential.algorithm,648 authData,649 clientDataHash650 );651652 await vault.updateEntry(match.id, {653 passkey: {654 ...credential,655 signCount: nextSignCount,656 lastUsedAt: Date.now(),657 },658 });659660 const responseJson = JSON.stringify({661 id: credential.credentialId,662 rawId: credential.credentialId,663 type: "public-key",664 response: {665 clientDataJSON: base64urlEncode(clientDataJSON),666 authenticatorData: base64urlEncode(authData),667 signature: base64urlEncode(signature),668 userHandle: credential.userHandle,669 },670 authenticatorAttachment: "platform",671 clientExtensionResults: {},672 });673674 await chrome.webAuthenticationProxy.completeGetRequest({675 requestId: request.requestId,676 responseJson,677 });678}679680681682683684685686687688689const FIREFOX_PENDING_KEY = "passkey_pending_firefox";690const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";691const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; 692693export async function initiateFirefoxPasskeyCreate(args: {694 rpId: string;695 rpName: string;696 userName: string;697 userDisplayName?: string;698}): Promise<{ ok: boolean; error?: string }> {699 if (!(await isProUser())) {700 broadcastPasskeyProGate();701 return { ok: false, error: "passkey creation requires Peach Pro" };702 }703 if (!vault.isUnlocked()) {704 await openApprovalPopup();705 return { ok: false, error: "vault_locked" };706 }707 if (!chrome.storage.session) {708 return { ok: false, error: "session storage unavailable" };709 }710711 712 713 714 const requestId = Date.now() + Math.floor(Math.random() * 1_000_000);715 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));716 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));717718 const pending: PendingPasskeyRequest = {719 requestId,720 type: "create",721 rpId: args.rpId,722 rpName: args.rpName,723 userName: args.userName,724 userDisplayName: args.userDisplayName,725 browser: "firefox",726 challenge,727 userHandle,728 };729730 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });731 await openApprovalPopup();732 return { ok: true };733}734735export async function completeFirefoxPasskeyCreate(payload: {736 requestId: number;737 rpId: string;738 rpName: string;739 userName: string;740 userDisplayName?: string;741 userHandle: string;742 credentialId: string;743 publicKeyCose: string;744 algorithm: -7 | -8;745 aaguid?: string;746 transports?: string[];747 backupEligible?: boolean;748 backupState?: boolean;749}): Promise<{ ok: boolean; error?: string }> {750 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };751752 const passkey: PasskeyData = {753 rpId: payload.rpId,754 rpName: payload.rpName,755 credentialId: payload.credentialId,756 publicKeyCose: payload.publicKeyCose,757 userHandle: payload.userHandle,758 userName: payload.userName,759 userDisplayName: payload.userDisplayName ?? payload.userName,760 algorithm: payload.algorithm,761 signCount: 0,762 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),763 backupEligible: payload.backupEligible ?? false,764 backupState: payload.backupState ?? false,765 browser: "firefox",766 transports: payload.transports ?? ["internal"],767 createdAt: Date.now(),768 };769770 771 772 773 const target = findAttachTarget(payload.rpId);774 if (target) {775 await vault.updateEntry(target.id, { passkey });776 } else {777 await vault.addEntry({778 type: "login",779 title: payload.rpName || payload.rpId,780 url: `https://${payload.rpId}`,781 username: payload.userName,782 passkey,783 });784 }785786 if (chrome.storage.session) {787 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);788 }789 return { ok: true };790}791792export async function cancelFirefoxPasskeyCreate(): Promise<void> {793 if (chrome.storage.session) {794 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);795 }796}797798function declineKey(domain: string): string {799 return `${FIREFOX_DECLINE_PREFIX}${domain}`;800}801802export async function recordPasskeyOfferDecline(domain: string): Promise<void> {803 if (!domain) return;804 await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });805}806807export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {808 if (!domain) return false;809 const key = declineKey(domain);810 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;811 const at = stored[key];812 if (typeof at !== "number") return false;813 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {814 await chrome.storage.local.remove(key);815 return false;816 }817 return true;818}819820821822823824825826827828829830831832833interface FirefoxAllowCredential {834 id: string;835 type?: string;836}837838interface FirefoxPubKeyCredParam {839 type?: string;840 alg: number;841}842843export interface FirefoxPasskeyHasCredentialsPayload {844 rpId: string;845 allowCredentials: FirefoxAllowCredential[];846}847848export interface FirefoxPasskeyCreatePayload {849 rpId: string;850 rpName?: string;851 userId: string;852 userName: string;853 userDisplayName?: string;854 challenge: string;855 clientDataJSON: number[];856 origin: string;857 pubKeyCredParams?: FirefoxPubKeyCredParam[];858 authenticatorSelection?: Record<string, unknown>;859 timeout?: number;860 attestation?: string;861}862863export interface FirefoxPasskeyGetPayload {864 rpId: string;865 challenge: string;866 clientDataJSON: number[];867 origin: string;868 allowCredentials: FirefoxAllowCredential[];869 userVerification?: string;870}871872interface FirefoxCreateCredentialResponse {873 type: "create";874 id: string;875 clientDataJSON: number[];876 attestationObject: string;877 authenticatorData: string;878 publicKey: string;879 algorithm: -7 | -8;880}881882interface FirefoxGetCredentialResponse {883 type: "get";884 id: string;885 clientDataJSON: number[];886 authenticatorData: string;887 signature: string;888 userHandle: string;889}890891type FirefoxPasskeyResult<T> =892 | { ok: true; credential: T }893 | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };894895function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {896 return vault.getEntries().filter(897 (entry): entry is VaultEntry & { passkey: PasskeyData } =>898 entry.passkey !== undefined && !entry.deletedAt899 );900}901902export async function handlePasskeyHasCredentials(903 payload: FirefoxPasskeyHasCredentialsPayload904): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {905 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {906 return { ok: false, error: "not_applicable" };907 }908 if (!vault.isUnlocked()) {909 910 911 912 return { ok: true, hasCredentials: false };913 }914 const passkeys = listPasskeyEntries().filter(915 (entry) => entry.passkey.rpId === payload.rpId916 );917 if (passkeys.length === 0) return { ok: true, hasCredentials: false };918919 const allow = payload.allowCredentials ?? [];920 if (allow.length === 0) return { ok: true, hasCredentials: true };921922 const hasMatch = passkeys.some((entry) =>923 allow.some((ac) => ac.id === entry.passkey.credentialId)924 );925 return { ok: true, hasCredentials: hasMatch };926}927928929930931932933934935async function requestFirefoxApproval(args: {936 type: "create" | "get";937 rpId: string;938 rpName?: string;939 userName: string;940 userDisplayName?: string;941}): Promise<boolean> {942 943 944 const requestId = Date.now() + Math.floor(Math.random() * 1000);945 return promptUserApproval({946 requestId,947 type: args.type,948 rpId: args.rpId,949 rpName: args.rpName ?? args.rpId,950 userName: args.userName,951 userDisplayName: args.userDisplayName,952 });953}954955export async function handleFirefoxPasskeyCreate(956 payload: FirefoxPasskeyCreatePayload957): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {958 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {959 return { ok: false, error: "not_applicable" };960 }961 if (!(await isProUser())) {962 broadcastPasskeyProGate();963 return { ok: false, error: "pro_required" };964 }965 if (!vault.isUnlocked()) {966 await openApprovalPopup();967 return { ok: false, error: "vault_locked" };968 }969970 const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);971 if (!algorithm) {972 return { ok: false, error: "internal_error", message: "No supported algorithm offered" };973 }974975 const approved = await requestFirefoxApproval({976 type: "create",977 rpId: payload.rpId,978 rpName: payload.rpName,979 userName: payload.userName,980 userDisplayName: payload.userDisplayName,981 });982 if (!approved) return { ok: false, error: "user_cancelled" };983984 const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();985 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);986987 988 989 const authenticatorData = await buildAuthenticatorData({990 rpId: payload.rpId,991 flags: 0x45,992 signCount: 0,993 credentialId: credentialIdBytes,994 publicKeyCose,995 });996 const attestationObject = createAttestationObject(authenticatorData);997998 const passkey: PasskeyData = {999 rpId: payload.rpId,1000 rpName: payload.rpName ?? payload.rpId,1001 credentialId,1002 publicKeyCose: base64urlEncode(publicKeyCose),1003 privateKeyJwk,1004 userHandle: payload.userId,1005 userName: payload.userName,1006 userDisplayName: payload.userDisplayName ?? payload.userName,1007 algorithm,1008 signCount: 0,1009 aaguid: base64urlEncode(new Uint8Array(16)),1010 backupEligible: false,1011 backupState: false,1012 browser: "firefox",1013 transports: ["internal"],1014 createdAt: Date.now(),1015 };10161017 const target = findAttachTarget(payload.rpId);1018 if (target) {1019 await vault.updateEntry(target.id, { passkey });1020 } else {1021 await vault.addEntry({1022 type: "login",1023 title: payload.rpName || payload.rpId,1024 url: `https://${payload.rpId}`,1025 username: payload.userName,1026 passkey,1027 });1028 }10291030 return {1031 ok: true,1032 credential: {1033 type: "create",1034 id: credentialId,1035 1036 1037 1038 clientDataJSON: payload.clientDataJSON,1039 attestationObject: base64urlEncode(attestationObject),1040 authenticatorData: base64urlEncode(authenticatorData),1041 publicKey: base64urlEncode(publicKeySpki),1042 algorithm,1043 },1044 };1045}10461047export async function handleFirefoxPasskeyGet(1048 payload: FirefoxPasskeyGetPayload1049): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1050 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1051 return { ok: false, error: "not_applicable" };1052 }1053 if (!(await isProUser())) {1054 broadcastPasskeyProGate();1055 return { ok: false, error: "pro_required" };1056 }1057 if (!vault.isUnlocked()) {1058 await openApprovalPopup();1059 return { ok: false, error: "vault_locked" };1060 }10611062 const allow = payload.allowCredentials ?? [];1063 const candidates = listPasskeyEntries().filter((entry) => {1064 if (entry.passkey.rpId !== payload.rpId) return false;1065 if (allow.length === 0) return true;1066 return allow.some((ac) => ac.id === entry.passkey.credentialId);1067 });10681069 if (candidates.length === 0) return { ok: false, error: "no_credentials" };10701071 1072 1073 1074 candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1075 const match = candidates[0]!;1076 const credential = match.passkey;10771078 if (!credential.privateKeyJwk) {1079 1080 1081 1082 return { ok: false, error: "no_credentials" };1083 }10841085 const approved = await requestFirefoxApproval({1086 type: "get",1087 rpId: credential.rpId,1088 rpName: credential.rpName,1089 userName: credential.userName,1090 userDisplayName: credential.userDisplayName,1091 });1092 if (!approved) return { ok: false, error: "user_cancelled" };10931094 const nextSignCount = credential.signCount + 1;1095 1096 1097 const flags =1098 0x05 |1099 (credential.backupEligible ? 0x08 : 0) |1100 (credential.backupState ? 0x10 : 0);1101 const authenticatorData = await buildAuthenticatorData({1102 rpId: credential.rpId,1103 flags,1104 signCount: nextSignCount,1105 });11061107 1108 1109 1110 const clientDataBytes = new Uint8Array(payload.clientDataJSON);1111 const clientDataHash = new Uint8Array(1112 await crypto.subtle.digest("SHA-256", clientDataBytes)1113 );1114 const signature = await signPasskeyAssertion(1115 credential.privateKeyJwk,1116 credential.algorithm,1117 authenticatorData,1118 clientDataHash1119 );11201121 await vault.updateEntry(match.id, {1122 passkey: {1123 ...credential,1124 signCount: nextSignCount,1125 lastUsedAt: Date.now(),1126 },1127 });11281129 return {1130 ok: true,1131 credential: {1132 type: "get",1133 id: credential.credentialId,1134 clientDataJSON: payload.clientDataJSON,1135 authenticatorData: base64urlEncode(authenticatorData),1136 signature: base64urlEncode(signature),1137 userHandle: credential.userHandle,1138 },1139 };1140}11411142export async function setupPasskeyProxy(): Promise<void> {1143 if (!isPasskeySupported()) return;11441145 1146 1147 if (detectSharedBrowser() === "firefox") {1148 await refreshPasskeyProxy();1149 return;1150 }11511152 if (!listenersRegistered) {1153 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1154 1155 1156 1157 1158 1159 1160 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1161 requestId: requestInfo.requestId,1162 isUvpaa: true,1163 });1164 });1165 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1166 void handleCreateRequest(requestInfo).catch(async (error) => {1167 console.error("Peach passkey create failed", error);1168 await rejectCreate(1169 requestInfo.requestId,1170 "UnknownError",1171 error instanceof Error ? error.message : "Passkey creation failed."1172 );1173 });1174 });1175 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1176 void handleGetRequest(requestInfo).catch(async (error) => {1177 console.error("Peach passkey get failed", error);1178 await rejectGet(1179 requestInfo.requestId,1180 "UnknownError",1181 error instanceof Error ? error.message : "Passkey authentication failed."1182 );1183 });1184 });1185 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1186 void resolvePendingPasskeyRequest(requestId, false);1187 });1188 listenersRegistered = true;1189 }11901191 await refreshPasskeyProxy();1192}