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 await openApprovalPopup();374 await rejectCreate(request.requestId, "NotAllowedError", "Unlock Peach first.");375 return;376 }377378 const approved = await promptUserApproval({379 requestId: request.requestId,380 type: "create",381 rpId,382 rpName,383 userName,384 userDisplayName,385 });386 if (!approved) {387 await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");388 return;389 }390391 const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);392 if (!algorithm) {393 await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");394 return;395 }396397 const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));398 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(399 algorithm400 );401 const authData = await buildAuthenticatorData({402 rpId,403 flags: 0x4d,404 signCount: 0,405 credentialId: credentialIdBytes,406 publicKeyCose,407 });408 const attestationObject = createAttestationObject(authData);409 const clientDataJSON = buildClientDataJSON(410 "webauthn.create",411 options.challenge,412 await resolvePageOrigin(rpId)413 );414415 const passkey: PasskeyData = {416 rpId,417 rpName,418 credentialId: base64urlEncode(credentialIdBytes),419 publicKeyCose: base64urlEncode(publicKeyCose),420 privateKeyJwk,421 userHandle: options.user.id,422 userName,423 userDisplayName,424 algorithm,425 signCount: 0,426 aaguid: base64urlEncode(new Uint8Array(16)),427 backupEligible: false,428 backupState: false,429 browser: "chrome",430 transports: ["internal"],431 createdAt: Date.now(),432 };433434 435 436 const target = findAttachTarget(rpId);437 if (target) {438 await vault.updateEntry(target.id, { passkey });439 } else {440 await vault.addEntry({441 type: "login",442 title: rpName,443 url: `https://${rpId}`,444 username: userName,445 passkey,446 });447 }448449 await chrome.webAuthenticationProxy.completeCreateRequest({450 requestId: request.requestId,451 responseJson: JSON.stringify({452 id: passkey.credentialId,453 rawId: passkey.credentialId,454 type: "public-key",455 response: {456 clientDataJSON: base64urlEncode(clientDataJSON),457 authenticatorData: base64urlEncode(authData),458 attestationObject: base64urlEncode(attestationObject),459 transports: ["internal"],460 publicKey: base64urlEncode(publicKeySpki),461 publicKeyAlgorithm: algorithm,462 },463 authenticatorAttachment: "platform",464 clientExtensionResults: {},465 }),466 });467}468469function broadcastPasskeyProGate(): void {470 chrome.tabs.query({}, (tabs) => {471 for (const tab of tabs) {472 if (tab.id !== undefined) {473 chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});474 }475 }476 });477}478479async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {480 if (chrome.storage.session) {481 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });482 }483484 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {485 chrome.tabs.query({ active: true, currentWindow: true }, resolve);486 });487 const tab = tabs[0];488489 if (tab?.id) {490 chrome.tabs.sendMessage(tab.id, {491 type: 'SHOW_PASSKEY_TOAST',492 payload: {493 site: request.rpId,494 displayName: request.userDisplayName ?? request.userName,495 username: request.userName,496 requestId: String(request.requestId),497 },498 }).catch(() => {});499 } else {500 await openApprovalPopup();501 }502503 return new Promise<boolean>((resolve) => {504 const timeoutId = setTimeout(async () => {505 approvals.delete(request.requestId);506 await clearPendingRequest(request.requestId);507 resolve(false);508 }, APPROVAL_TIMEOUT_MS);509510 approvals.set(request.requestId, { resolve, timeoutId });511 });512}513514async function handleGetRequest(515 request: chrome.webAuthenticationProxy.GetRequest516): Promise<void> {517 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;518 if (!vault.isUnlocked()) {519 520 521 522 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>523 chrome.tabs.query({ active: true, currentWindow: true }, resolve)524 );525 const activeTab = tabs[0];526 if (activeTab?.id) {527 chrome.tabs.sendMessage(activeTab.id, {528 type: 'SUMMON_AUTOFILL',529 source: 'passkey-unlock',530 }).catch(() => { void openApprovalPopup(); });531 } else {532 await openApprovalPopup();533 }534535 536 const unlocked = await new Promise<boolean>((resolve) => {537 const timeoutId = setTimeout(() => {538 unlockWaiters.delete(request.requestId);539 resolve(false);540 }, APPROVAL_TIMEOUT_MS);541 unlockWaiters.set(request.requestId, { resolve, timeoutId });542 });543544 if (!unlocked || !vault.isUnlocked()) {545 await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");546 return;547 }548 549 }550551 if (!(await isProUser())) {552 await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");553 broadcastPasskeyProGate();554 return;555 }556557 const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>558 entry.passkey !== undefined && !entry.deletedAt559 );560 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);561562 const match = passkeyBearing.find((entry) => {563 if (allowedCredentialIds.length > 0) {564 return allowedCredentialIds.includes(entry.passkey.credentialId);565 }566 if (!options.rpId) return false;567 return entry.passkey.rpId === options.rpId;568 });569570 if (!match) {571 await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");572 return;573 }574575 const credential = match.passkey;576 577 578 579 580 if (!credential.privateKeyJwk) {581 await rejectGet(582 request.requestId,583 "NotAllowedError",584 "This passkey was created on Firefox; sign in via the platform authenticator."585 );586 return;587 }588589 const approved = await promptViaToast({590 requestId: request.requestId,591 type: "get",592 rpId: credential.rpId,593 rpName: credential.rpName,594 userName: credential.userName,595 userDisplayName: credential.userDisplayName,596 });597 if (!approved) {598 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");599 return;600 }601602 const nextSignCount = credential.signCount + 1;603 const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);604 const authData = await buildAuthenticatorData({605 rpId: credential.rpId,606 flags,607 signCount: nextSignCount,608 });609 const clientDataJSON = buildClientDataJSON(610 "webauthn.get",611 options.challenge,612 await resolvePageOrigin(credential.rpId)613 );614 const clientDataHash = new Uint8Array(615 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))616 );617 const signature = await signPasskeyAssertion(618 credential.privateKeyJwk,619 credential.algorithm,620 authData,621 clientDataHash622 );623624 await vault.updateEntry(match.id, {625 passkey: {626 ...credential,627 signCount: nextSignCount,628 lastUsedAt: Date.now(),629 },630 });631632 const responseJson = JSON.stringify({633 id: credential.credentialId,634 rawId: credential.credentialId,635 type: "public-key",636 response: {637 clientDataJSON: base64urlEncode(clientDataJSON),638 authenticatorData: base64urlEncode(authData),639 signature: base64urlEncode(signature),640 userHandle: credential.userHandle,641 },642 authenticatorAttachment: "platform",643 clientExtensionResults: {},644 });645646 await chrome.webAuthenticationProxy.completeGetRequest({647 requestId: request.requestId,648 responseJson,649 });650}651652653654655656657658659660661const FIREFOX_PENDING_KEY = "passkey_pending_firefox";662const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";663const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; 664665export async function initiateFirefoxPasskeyCreate(args: {666 rpId: string;667 rpName: string;668 userName: string;669 userDisplayName?: string;670}): Promise<{ ok: boolean; error?: string }> {671 if (!(await isProUser())) {672 broadcastPasskeyProGate();673 return { ok: false, error: "passkey creation requires Peach Pro" };674 }675 if (!vault.isUnlocked()) {676 await openApprovalPopup();677 return { ok: false, error: "vault_locked" };678 }679 if (!chrome.storage.session) {680 return { ok: false, error: "session storage unavailable" };681 }682683 684 685 686 const requestId = Date.now() + Math.floor(Math.random() * 1_000_000);687 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));688 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));689690 const pending: PendingPasskeyRequest = {691 requestId,692 type: "create",693 rpId: args.rpId,694 rpName: args.rpName,695 userName: args.userName,696 userDisplayName: args.userDisplayName,697 browser: "firefox",698 challenge,699 userHandle,700 };701702 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });703 await openApprovalPopup();704 return { ok: true };705}706707export async function completeFirefoxPasskeyCreate(payload: {708 requestId: number;709 rpId: string;710 rpName: string;711 userName: string;712 userDisplayName?: string;713 userHandle: string;714 credentialId: string;715 publicKeyCose: string;716 algorithm: -7 | -8;717 aaguid?: string;718 transports?: string[];719 backupEligible?: boolean;720 backupState?: boolean;721}): Promise<{ ok: boolean; error?: string }> {722 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };723724 const passkey: PasskeyData = {725 rpId: payload.rpId,726 rpName: payload.rpName,727 credentialId: payload.credentialId,728 publicKeyCose: payload.publicKeyCose,729 userHandle: payload.userHandle,730 userName: payload.userName,731 userDisplayName: payload.userDisplayName ?? payload.userName,732 algorithm: payload.algorithm,733 signCount: 0,734 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),735 backupEligible: payload.backupEligible ?? false,736 backupState: payload.backupState ?? false,737 browser: "firefox",738 transports: payload.transports ?? ["internal"],739 createdAt: Date.now(),740 };741742 743 744 745 const target = findAttachTarget(payload.rpId);746 if (target) {747 await vault.updateEntry(target.id, { passkey });748 } else {749 await vault.addEntry({750 type: "login",751 title: payload.rpName || payload.rpId,752 url: `https://${payload.rpId}`,753 username: payload.userName,754 passkey,755 });756 }757758 if (chrome.storage.session) {759 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);760 }761 return { ok: true };762}763764export async function cancelFirefoxPasskeyCreate(): Promise<void> {765 if (chrome.storage.session) {766 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);767 }768}769770function declineKey(domain: string): string {771 return `${FIREFOX_DECLINE_PREFIX}${domain}`;772}773774export async function recordPasskeyOfferDecline(domain: string): Promise<void> {775 if (!domain) return;776 await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });777}778779export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {780 if (!domain) return false;781 const key = declineKey(domain);782 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;783 const at = stored[key];784 if (typeof at !== "number") return false;785 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {786 await chrome.storage.local.remove(key);787 return false;788 }789 return true;790}791792793794795796797798799800801802803804805interface FirefoxAllowCredential {806 id: string;807 type?: string;808}809810interface FirefoxPubKeyCredParam {811 type?: string;812 alg: number;813}814815export interface FirefoxPasskeyHasCredentialsPayload {816 rpId: string;817 allowCredentials: FirefoxAllowCredential[];818}819820export interface FirefoxPasskeyCreatePayload {821 rpId: string;822 rpName?: string;823 userId: string;824 userName: string;825 userDisplayName?: string;826 challenge: string;827 clientDataJSON: number[];828 origin: string;829 pubKeyCredParams?: FirefoxPubKeyCredParam[];830 authenticatorSelection?: Record<string, unknown>;831 timeout?: number;832 attestation?: string;833}834835export interface FirefoxPasskeyGetPayload {836 rpId: string;837 challenge: string;838 clientDataJSON: number[];839 origin: string;840 allowCredentials: FirefoxAllowCredential[];841 userVerification?: string;842}843844interface FirefoxCreateCredentialResponse {845 type: "create";846 id: string;847 clientDataJSON: number[];848 attestationObject: string;849 authenticatorData: string;850 publicKey: string;851 algorithm: -7 | -8;852}853854interface FirefoxGetCredentialResponse {855 type: "get";856 id: string;857 clientDataJSON: number[];858 authenticatorData: string;859 signature: string;860 userHandle: string;861}862863type FirefoxPasskeyResult<T> =864 | { ok: true; credential: T }865 | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };866867function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {868 return vault.getEntries().filter(869 (entry): entry is VaultEntry & { passkey: PasskeyData } =>870 entry.passkey !== undefined && !entry.deletedAt871 );872}873874export async function handlePasskeyHasCredentials(875 payload: FirefoxPasskeyHasCredentialsPayload876): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {877 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {878 return { ok: false, error: "not_applicable" };879 }880 if (!vault.isUnlocked()) {881 882 883 884 return { ok: true, hasCredentials: false };885 }886 const passkeys = listPasskeyEntries().filter(887 (entry) => entry.passkey.rpId === payload.rpId888 );889 if (passkeys.length === 0) return { ok: true, hasCredentials: false };890891 const allow = payload.allowCredentials ?? [];892 if (allow.length === 0) return { ok: true, hasCredentials: true };893894 const hasMatch = passkeys.some((entry) =>895 allow.some((ac) => ac.id === entry.passkey.credentialId)896 );897 return { ok: true, hasCredentials: hasMatch };898}899900901902903904905906907async function requestFirefoxApproval(args: {908 type: "create" | "get";909 rpId: string;910 rpName?: string;911 userName: string;912 userDisplayName?: string;913}): Promise<boolean> {914 915 916 const requestId = Date.now() + Math.floor(Math.random() * 1000);917 return promptUserApproval({918 requestId,919 type: args.type,920 rpId: args.rpId,921 rpName: args.rpName ?? args.rpId,922 userName: args.userName,923 userDisplayName: args.userDisplayName,924 });925}926927export async function handleFirefoxPasskeyCreate(928 payload: FirefoxPasskeyCreatePayload929): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {930 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {931 return { ok: false, error: "not_applicable" };932 }933 if (!(await isProUser())) {934 broadcastPasskeyProGate();935 return { ok: false, error: "pro_required" };936 }937 if (!vault.isUnlocked()) {938 await openApprovalPopup();939 return { ok: false, error: "vault_locked" };940 }941942 const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);943 if (!algorithm) {944 return { ok: false, error: "internal_error", message: "No supported algorithm offered" };945 }946947 const approved = await requestFirefoxApproval({948 type: "create",949 rpId: payload.rpId,950 rpName: payload.rpName,951 userName: payload.userName,952 userDisplayName: payload.userDisplayName,953 });954 if (!approved) return { ok: false, error: "user_cancelled" };955956 const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();957 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);958959 960 961 const authenticatorData = await buildAuthenticatorData({962 rpId: payload.rpId,963 flags: 0x45,964 signCount: 0,965 credentialId: credentialIdBytes,966 publicKeyCose,967 });968 const attestationObject = createAttestationObject(authenticatorData);969970 const passkey: PasskeyData = {971 rpId: payload.rpId,972 rpName: payload.rpName ?? payload.rpId,973 credentialId,974 publicKeyCose: base64urlEncode(publicKeyCose),975 privateKeyJwk,976 userHandle: payload.userId,977 userName: payload.userName,978 userDisplayName: payload.userDisplayName ?? payload.userName,979 algorithm,980 signCount: 0,981 aaguid: base64urlEncode(new Uint8Array(16)),982 backupEligible: false,983 backupState: false,984 browser: "firefox",985 transports: ["internal"],986 createdAt: Date.now(),987 };988989 const target = findAttachTarget(payload.rpId);990 if (target) {991 await vault.updateEntry(target.id, { passkey });992 } else {993 await vault.addEntry({994 type: "login",995 title: payload.rpName || payload.rpId,996 url: `https://${payload.rpId}`,997 username: payload.userName,998 passkey,999 });1000 }10011002 return {1003 ok: true,1004 credential: {1005 type: "create",1006 id: credentialId,1007 1008 1009 1010 clientDataJSON: payload.clientDataJSON,1011 attestationObject: base64urlEncode(attestationObject),1012 authenticatorData: base64urlEncode(authenticatorData),1013 publicKey: base64urlEncode(publicKeySpki),1014 algorithm,1015 },1016 };1017}10181019export async function handleFirefoxPasskeyGet(1020 payload: FirefoxPasskeyGetPayload1021): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1022 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1023 return { ok: false, error: "not_applicable" };1024 }1025 if (!(await isProUser())) {1026 broadcastPasskeyProGate();1027 return { ok: false, error: "pro_required" };1028 }1029 if (!vault.isUnlocked()) {1030 await openApprovalPopup();1031 return { ok: false, error: "vault_locked" };1032 }10331034 const allow = payload.allowCredentials ?? [];1035 const candidates = listPasskeyEntries().filter((entry) => {1036 if (entry.passkey.rpId !== payload.rpId) return false;1037 if (allow.length === 0) return true;1038 return allow.some((ac) => ac.id === entry.passkey.credentialId);1039 });10401041 if (candidates.length === 0) return { ok: false, error: "no_credentials" };10421043 1044 1045 1046 candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1047 const match = candidates[0]!;1048 const credential = match.passkey;10491050 if (!credential.privateKeyJwk) {1051 1052 1053 1054 return { ok: false, error: "no_credentials" };1055 }10561057 const approved = await requestFirefoxApproval({1058 type: "get",1059 rpId: credential.rpId,1060 rpName: credential.rpName,1061 userName: credential.userName,1062 userDisplayName: credential.userDisplayName,1063 });1064 if (!approved) return { ok: false, error: "user_cancelled" };10651066 const nextSignCount = credential.signCount + 1;1067 1068 1069 const flags =1070 0x05 |1071 (credential.backupEligible ? 0x08 : 0) |1072 (credential.backupState ? 0x10 : 0);1073 const authenticatorData = await buildAuthenticatorData({1074 rpId: credential.rpId,1075 flags,1076 signCount: nextSignCount,1077 });10781079 1080 1081 1082 const clientDataBytes = new Uint8Array(payload.clientDataJSON);1083 const clientDataHash = new Uint8Array(1084 await crypto.subtle.digest("SHA-256", clientDataBytes)1085 );1086 const signature = await signPasskeyAssertion(1087 credential.privateKeyJwk,1088 credential.algorithm,1089 authenticatorData,1090 clientDataHash1091 );10921093 await vault.updateEntry(match.id, {1094 passkey: {1095 ...credential,1096 signCount: nextSignCount,1097 lastUsedAt: Date.now(),1098 },1099 });11001101 return {1102 ok: true,1103 credential: {1104 type: "get",1105 id: credential.credentialId,1106 clientDataJSON: payload.clientDataJSON,1107 authenticatorData: base64urlEncode(authenticatorData),1108 signature: base64urlEncode(signature),1109 userHandle: credential.userHandle,1110 },1111 };1112}11131114export async function setupPasskeyProxy(): Promise<void> {1115 if (!isPasskeySupported()) return;11161117 1118 1119 if (detectSharedBrowser() === "firefox") {1120 await refreshPasskeyProxy();1121 return;1122 }11231124 if (!listenersRegistered) {1125 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1126 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1127 requestId: requestInfo.requestId,1128 isUvpaa: vault.isUnlocked(),1129 });1130 });1131 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1132 void handleCreateRequest(requestInfo).catch(async (error) => {1133 console.error("Peach passkey create failed", error);1134 await rejectCreate(1135 requestInfo.requestId,1136 "UnknownError",1137 error instanceof Error ? error.message : "Passkey creation failed."1138 );1139 });1140 });1141 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1142 void handleGetRequest(requestInfo).catch(async (error) => {1143 console.error("Peach passkey get failed", error);1144 await rejectGet(1145 requestInfo.requestId,1146 "UnknownError",1147 error instanceof Error ? error.message : "Passkey authentication failed."1148 );1149 });1150 });1151 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1152 void resolvePendingPasskeyRequest(requestId, false);1153 });1154 listenersRegistered = true;1155 }11561157 await refreshPasskeyProxy();1158}