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 { isValidRpId } from "../shared/passkey-utils";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> }>();4142434445const cancelledRequests = new Set<number>();46function markCancelled(requestId: number): void {47 cancelledRequests.add(requestId);48 49 50 51 if (cancelledRequests.size > 256) {52 const iter = cancelledRequests.values();53 for (let i = 0; i < 128; i += 1) cancelledRequests.delete(iter.next().value as number);54 }55}5657export function onVaultUnlocked(): void {58 for (const { resolve, timeoutId } of unlockWaiters.values()) {59 clearTimeout(timeoutId);60 resolve(true);61 }62 unlockWaiters.clear();63}6465666768697071export async function denyAllPendingPasskeyRequests(): Promise<void> {72 if (approvals.size === 0 && unlockWaiters.size === 0) return;73 console.log("[Peach passkey] denyAllPendingPasskeyRequests (popup disconnected)", {74 approvals: approvals.size,75 unlockWaiters: unlockWaiters.size,76 });77 for (const [rid, { resolve, timeoutId }] of approvals.entries()) {78 clearTimeout(timeoutId);79 resolve(false);80 approvals.delete(rid);81 await clearPendingRequest(rid).catch(() => {});82 }83 for (const { resolve, timeoutId } of unlockWaiters.values()) {84 clearTimeout(timeoutId);85 resolve(false);86 }87 unlockWaiters.clear();88}8990function isPasskeySupported(): boolean {91 if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;92 93 94 95 96 if (detectSharedBrowser() === "firefox") {97 const ver = firefoxVersion();98 if (Number.isFinite(ver) && ver >= 150) return true;99 }100 return false;101}102103export const detectBrowser = detectSharedBrowser;104105function getBrowserLabel(): string {106 const ua = navigator.userAgent;107 if (/Firefox\/(\d+)/.test(ua)) {108 const version = ua.match(/Firefox\/(\d+)/)?.[1];109 return version ? `Firefox ${version}` : "Firefox";110 }111 const version = ua.match(/Chrome\/(\d+)/)?.[1];112 return version ? `Chrome ${version}` : "Chromium";113}114115function pendingStorageKey(requestId: number): string {116 return `${PENDING_PREFIX}${requestId}`;117}118119function originFromRpId(rpId: string): string {120 return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)121 ? `http://${rpId}`122 : `https://${rpId}`;123}124125126127128129130131132133134135136137138139140async function resolvePageOrigin(rpId: string): Promise<string> {141 try {142 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>143 chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)144 );145 const url = tabs[0]?.url;146 if (url) {147 const parsed = new URL(url);148 const host = parsed.hostname.toLowerCase();149 const rp = rpId.toLowerCase();150 if (host === rp || host.endsWith(`.${rp}`)) {151 return parsed.origin;152 }153 }154 } catch {155 156 }157 return originFromRpId(rpId);158}159160async function setBadge(text: string): Promise<void> {161 if (!chrome.action?.setBadgeText) return;162 await chrome.action.setBadgeText({ text });163 if (text) {164 await chrome.action.setBadgeBackgroundColor({ color: "#E5805C" });165 }166}167168async function clearPendingRequest(requestId: number): Promise<void> {169 if (chrome.storage.session) {170 await chrome.storage.session.remove(pendingStorageKey(requestId));171 }172 await setBadge("");173}174175async function openApprovalPopup(): Promise<void> {176 try {177 await chrome.action.openPopup();178 } catch {179 await setBadge("PK");180 }181}182183184185186187188189190191function isInvalidRequestIdError(err: unknown): boolean {192 return err instanceof Error && /invalid requestid/i.test(err.message);193}194195196197198199200201202203204function isMissingResponseJsonError(err: unknown): boolean {205 return err instanceof Error && /missing getresponsedetails/i.test(err.message);206}207208async function rejectCreate(209 requestId: number,210 name: string,211 message = ""212): Promise<void> {213 try {214 await chrome.webAuthenticationProxy.completeCreateRequest({215 requestId,216 error: { name, message },217 });218 } catch (err) {219 if (isInvalidRequestIdError(err)) {220 console.log("[Peach passkey] completeCreateRequest skipped (already cancelled)", { requestId });221 return;222 }223 throw err;224 }225}226227async function rejectGet(228 requestId: number,229 name: string,230 message = ""231): Promise<void> {232 try {233 await chrome.webAuthenticationProxy.completeGetRequest({234 requestId,235 error: { name, message },236 });237 } catch (err) {238 if (isInvalidRequestIdError(err)) {239 console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId });240 return;241 }242 throw err;243 }244}245246export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {247 if (!chrome.storage.session) return null; 248 const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;249 const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));250 if (!key) return null;251 const pending = stored[key];252 return pending && typeof pending === "object"253 ? (pending as PendingPasskeyRequest)254 : null;255}256257export async function resolvePendingPasskeyRequest(258 requestId: number,259 approved: boolean260): Promise<void> {261 const pending = approvals.get(requestId);262 if (!pending) {263 264 265 266 267 await clearPendingRequest(requestId);268 if (chrome.storage.session) {269 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);270 }271 return;272 }273274 clearTimeout(pending.timeoutId);275 approvals.delete(requestId);276 await clearPendingRequest(requestId);277 pending.resolve(approved);278}279280async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {281 if (chrome.storage.session) {282 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });283 }284 await openApprovalPopup();285286 return new Promise<boolean>((resolve) => {287 const timeoutId = setTimeout(async () => {288 approvals.delete(request.requestId);289 await clearPendingRequest(request.requestId);290 resolve(false);291 }, APPROVAL_TIMEOUT_MS);292293 approvals.set(request.requestId, { resolve, timeoutId });294 });295}296297export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {298 const settings = await vault.ensureSettingsLoaded();299 const supported = isPasskeySupported();300 const enabled = settings.passkeyProviderEnabled === true;301 return {302 supported,303 enabled,304 attached: supported && attached,305 browserLabel: getBrowserLabel(),306 statusLabel: !supported307 ? "Interception unavailable"308 : attached309 ? "Active"310 : conflict311 ? "Another provider is active"312 : enabled313 ? "Inactive"314 : "Disabled",315 conflict,316 };317}318319async function attachPasskeyProxy(): Promise<void> {320 if (!isPasskeySupported()) {321 attached = false;322 conflict = "This browser does not expose chrome.webAuthenticationProxy.";323 return;324 }325326 327 328 329 if (detectSharedBrowser() === "firefox") {330 attached = true;331 conflict = undefined;332 return;333 }334335 if (attached) return;336337 try {338 await chrome.webAuthenticationProxy.attach();339 attached = true;340 conflict = undefined;341 console.log("[Peach passkey] proxy attached successfully");342 } catch (error) {343 attached = false;344 conflict = error instanceof Error ? error.message : "Another provider is active.";345 console.warn("[Peach passkey] proxy attach failed", { conflict });346 }347}348349async function detachPasskeyProxy(): Promise<void> {350 if (!isPasskeySupported()) {351 attached = false;352 return;353 }354355 if (detectSharedBrowser() === "firefox") {356 attached = false;357 conflict = undefined;358 return;359 }360361 try {362 await chrome.webAuthenticationProxy.detach();363 } catch {364 365 }366367 attached = false;368 conflict = undefined;369}370371export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {372 await vault.updateSettings({ passkeyProviderEnabled: enabled });373 if (enabled) {374 await attachPasskeyProxy();375 } else {376 await detachPasskeyProxy();377 }378 return getPasskeyProviderState();379}380381async function refreshPasskeyProxy(): Promise<void> {382 const settings = await vault.ensureSettingsLoaded();383 if (settings.passkeyProviderEnabled === true) {384 await attachPasskeyProxy();385 return;386 }387 await detachPasskeyProxy();388}389390391392393394395396397function findAttachTarget(rpId: string): VaultEntry | null {398 const all = vault.getEntries();399 const passkeyMatch = all.find(400 (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt401 );402 if (passkeyMatch) return passkeyMatch;403 const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);404 if (loginMatches.length === 0) return null;405 406 return loginMatches.reduce((best, e) =>407 (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best408 );409}410411async function handleCreateRequest(412 request: chrome.webAuthenticationProxy.CreateRequest413): Promise<void> {414 console.log("[Peach passkey] create event received", {415 requestId: request.requestId,416 detailsJson: request.requestDetailsJson,417 });418 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;419 const rpId = options.rp.id;420 const rpName = options.rp.name ?? options.rp.id;421 const userName = options.user.name;422 const userDisplayName = options.user.displayName ?? options.user.name;423 console.log("[Peach passkey] create parsed", { rpId, rpName, userName, unlocked: vault.isUnlocked() });424425 if (!rpId) {426 console.warn("[Peach passkey] reject: missing rp.id");427 await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");428 return;429 }430431 432 433 434 435 436 437 const pageOrigin = await resolvePageOrigin(rpId);438439 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {440 console.warn("[Peach passkey] reject: user is not Pro");441 await rejectCreate(442 request.requestId,443 "NotAllowedError",444 "Passkey creation requires Peach Pro."445 );446 447 broadcastPasskeyProGate();448 return;449 }450451 if (!vault.isUnlocked()) {452 console.log("[Peach passkey] vault locked, summoning inline unlock");453 454 455 456 457 458 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>459 chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)460 );461 const activeTab = tabs[0];462 console.log("[Peach passkey] active tab for unlock summon", { id: activeTab?.id, url: activeTab?.url });463 if (activeTab?.id) {464 chrome.tabs.sendMessage(activeTab.id, {465 type: 'SUMMON_AUTOFILL',466 source: 'passkey-unlock',467 }).catch((err) => {468 console.warn("[Peach passkey] SUMMON_AUTOFILL failed, falling back to popup", err);469 void openApprovalPopup();470 });471 } else {472 await openApprovalPopup();473 }474475 const unlocked = await new Promise<boolean>((resolve) => {476 const timeoutId = setTimeout(() => {477 unlockWaiters.delete(request.requestId);478 resolve(false);479 }, APPROVAL_TIMEOUT_MS);480 unlockWaiters.set(request.requestId, { resolve, timeoutId });481 });482483 console.log("[Peach passkey] unlock wait resolved", { unlocked, vaultUnlocked: vault.isUnlocked() });484 if (!unlocked || !vault.isUnlocked()) {485 console.warn("[Peach passkey] reject: unlock timeout");486 await rejectCreate(request.requestId, "NotAllowedError", "Not unlocked in time.");487 return;488 }489 490 }491492 console.log("[Peach passkey] requesting approval via popup");493 const approved = await promptUserApproval({494 requestId: request.requestId,495 type: "create",496 rpId,497 rpName,498 userName,499 userDisplayName,500 });501 console.log("[Peach passkey] approval resolved", { approved });502 if (!approved) {503 console.warn("[Peach passkey] reject: user denied");504 await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");505 return;506 }507508 const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);509 if (!algorithm) {510 console.warn("[Peach passkey] reject: no supported algorithm", options.pubKeyCredParams);511 await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");512 return;513 }514 console.log("[Peach passkey] algorithm selected", algorithm);515516 const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));517 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(518 algorithm519 );520 const authData = await buildAuthenticatorData({521 rpId,522 flags: 0x4d,523 signCount: 0,524 credentialId: credentialIdBytes,525 publicKeyCose,526 });527 const attestationObject = createAttestationObject(authData);528 const clientDataJSON = buildClientDataJSON(529 "webauthn.create",530 options.challenge,531 pageOrigin532 );533534 const passkey: PasskeyData = {535 rpId,536 rpName,537 credentialId: base64urlEncode(credentialIdBytes),538 publicKeyCose: base64urlEncode(publicKeyCose),539 privateKeyJwk,540 userHandle: options.user.id,541 userName,542 userDisplayName,543 algorithm,544 signCount: 0,545 aaguid: base64urlEncode(new Uint8Array(16)),546 backupEligible: false,547 backupState: false,548 browser: "chrome",549 transports: ["internal"],550 createdAt: Date.now(),551 };552553 554 555 const target = findAttachTarget(rpId);556 if (target) {557 await vault.updateEntry(target.id, { passkey });558 } else {559 await vault.addEntry({560 type: "login",561 title: rpName,562 url: `https://${rpId}`,563 username: userName,564 passkey,565 });566 }567 568 569 570 await vault.consolidatePasskeyEntries();571572 try {573 await chrome.webAuthenticationProxy.completeCreateRequest({574 requestId: request.requestId,575 responseJson: JSON.stringify({576 id: passkey.credentialId,577 rawId: passkey.credentialId,578 type: "public-key",579 response: {580 clientDataJSON: base64urlEncode(clientDataJSON),581 authenticatorData: base64urlEncode(authData),582 attestationObject: base64urlEncode(attestationObject),583 transports: ["internal"],584 publicKey: base64urlEncode(publicKeySpki),585 publicKeyAlgorithm: algorithm,586 },587 authenticatorAttachment: "platform",588 clientExtensionResults: {},589 }),590 });591 console.log("[Peach passkey] completeCreateRequest success", { requestId: request.requestId });592 } catch (err) {593 if (isInvalidRequestIdError(err)) {594 console.log("[Peach passkey] completeCreateRequest skipped (already cancelled)", { requestId: request.requestId });595 return;596 }597 throw err;598 }599}600601function broadcastPasskeyProGate(): void {602 chrome.tabs.query({}, (tabs) => {603 for (const tab of tabs) {604 if (tab.id !== undefined) {605 chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});606 }607 }608 });609}610611async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {612 if (chrome.storage.session) {613 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });614 }615616 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {617 chrome.tabs.query({ active: true, currentWindow: true }, resolve);618 });619 const tab = tabs[0];620621 if (tab?.id) {622 chrome.tabs.sendMessage(tab.id, {623 type: 'SHOW_PASSKEY_TOAST',624 payload: {625 site: request.rpId,626 displayName: request.userDisplayName ?? request.userName,627 username: request.userName,628 requestId: String(request.requestId),629 },630 }).catch(() => {});631 } else {632 await openApprovalPopup();633 }634635 return new Promise<boolean>((resolve) => {636 const timeoutId = setTimeout(async () => {637 approvals.delete(request.requestId);638 await clearPendingRequest(request.requestId);639 resolve(false);640 }, APPROVAL_TIMEOUT_MS);641642 approvals.set(request.requestId, { resolve, timeoutId });643 });644}645646async function handleGetRequest(647 request: chrome.webAuthenticationProxy.GetRequest648): Promise<void> {649 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;650 if (!vault.isUnlocked()) {651 652 653 654 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>655 chrome.tabs.query({ active: true, currentWindow: true }, resolve)656 );657 const activeTab = tabs[0];658 if (activeTab?.id) {659 chrome.tabs.sendMessage(activeTab.id, {660 type: 'SUMMON_AUTOFILL',661 source: 'passkey-unlock',662 }).catch(() => { void openApprovalPopup(); });663 } else {664 await openApprovalPopup();665 }666667 668 const unlocked = await new Promise<boolean>((resolve) => {669 const timeoutId = setTimeout(() => {670 unlockWaiters.delete(request.requestId);671 resolve(false);672 }, APPROVAL_TIMEOUT_MS);673 unlockWaiters.set(request.requestId, { resolve, timeoutId });674 });675676 if (!unlocked || !vault.isUnlocked()) {677 await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");678 return;679 }680 681 }682683 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {684 await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");685 broadcastPasskeyProGate();686 return;687 }688689 const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>690 entry.passkey !== undefined && !entry.deletedAt691 );692 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);693694 const match = passkeyBearing.find((entry) => {695 if (allowedCredentialIds.length > 0) {696 return allowedCredentialIds.includes(entry.passkey.credentialId);697 }698 if (!options.rpId) return false;699 return entry.passkey.rpId === options.rpId;700 });701702 if (!match) {703 704 705 706 707 708 709 console.log("[Peach passkey] no matching credential — passing through to platform", { requestId: request.requestId });710 try {711 await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });712 } catch (err) {713 if (isInvalidRequestIdError(err)) return;714 if (isMissingResponseJsonError(err)) return;715 throw err;716 }717 return;718 }719720 const credential = match.passkey;721 722 723 724 if (!credential.privateKeyJwk) {725 console.log("[Peach passkey] no private key (Firefox passkey) — passing through to platform", { requestId: request.requestId });726 try {727 await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });728 } catch (err) {729 if (isInvalidRequestIdError(err)) return;730 if (isMissingResponseJsonError(err)) return;731 throw err;732 }733 return;734 }735736 const approved = await promptViaToast({737 requestId: request.requestId,738 type: "get",739 rpId: credential.rpId,740 rpName: credential.rpName,741 userName: credential.userName,742 userDisplayName: credential.userDisplayName,743 });744 if (!approved) {745 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");746 return;747 }748749 const nextSignCount = credential.signCount + 1;750 const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);751 const authData = await buildAuthenticatorData({752 rpId: credential.rpId,753 flags,754 signCount: nextSignCount,755 });756 const clientDataJSON = buildClientDataJSON(757 "webauthn.get",758 options.challenge,759 await resolvePageOrigin(credential.rpId)760 );761 const clientDataHash = new Uint8Array(762 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))763 );764 const signature = await signPasskeyAssertion(765 credential.privateKeyJwk,766 credential.algorithm,767 authData,768 clientDataHash769 );770771 await vault.updateEntry(match.id, {772 passkey: {773 ...credential,774 signCount: nextSignCount,775 lastUsedAt: Date.now(),776 },777 });778779 const responseJson = JSON.stringify({780 id: credential.credentialId,781 rawId: credential.credentialId,782 type: "public-key",783 response: {784 clientDataJSON: base64urlEncode(clientDataJSON),785 authenticatorData: base64urlEncode(authData),786 signature: base64urlEncode(signature),787 userHandle: credential.userHandle,788 },789 authenticatorAttachment: "platform",790 clientExtensionResults: {},791 });792793 try {794 await chrome.webAuthenticationProxy.completeGetRequest({795 requestId: request.requestId,796 responseJson,797 });798 console.log("[Peach passkey] completeGetRequest success", { requestId: request.requestId });799 } catch (err) {800 if (isInvalidRequestIdError(err)) {801 console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId: request.requestId });802 return;803 }804 throw err;805 }806}807808809810811812813814815816817const FIREFOX_PENDING_KEY = "passkey_pending_firefox";818const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";819const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; 820821export async function initiateFirefoxPasskeyCreate(args: {822 rpId: string;823 rpName: string;824 userName: string;825 userDisplayName?: string;826}, callerOrigin?: string): Promise<{ ok: boolean; error?: string }> {827 828 829 830 831 832 if (!callerOrigin || !isValidRpId(args.rpId, callerOrigin)) {833 return { ok: false, error: "rpid_origin_mismatch" };834 }835 if (!(await isProUser())) {836 broadcastPasskeyProGate();837 return { ok: false, error: "passkey creation requires Peach Pro" };838 }839 if (!vault.isUnlocked()) {840 await openApprovalPopup();841 return { ok: false, error: "vault_locked" };842 }843 if (!chrome.storage.session) {844 return { ok: false, error: "session storage unavailable" };845 }846847 848 849 850 const requestId = Date.now() + Math.floor(Math.random() * 1_000_000);851 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));852 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));853854 const pending: PendingPasskeyRequest = {855 requestId,856 type: "create",857 rpId: args.rpId,858 rpName: args.rpName,859 userName: args.userName,860 userDisplayName: args.userDisplayName,861 browser: "firefox",862 challenge,863 userHandle,864 };865866 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });867 await openApprovalPopup();868 return { ok: true };869}870871export async function completeFirefoxPasskeyCreate(payload: {872 requestId: number;873 rpId: string;874 rpName: string;875 userName: string;876 userDisplayName?: string;877 userHandle: string;878 credentialId: string;879 publicKeyCose: string;880 algorithm: -7 | -8;881 aaguid?: string;882 transports?: string[];883 backupEligible?: boolean;884 backupState?: boolean;885}): Promise<{ ok: boolean; error?: string }> {886 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };887888 889 890 891 892 893 894 const pending = chrome.storage.session895 ? ((await chrome.storage.session.get(FIREFOX_PENDING_KEY))[FIREFOX_PENDING_KEY] as896 | PendingPasskeyRequest897 | undefined)898 : undefined;899 if (900 !pending ||901 pending.requestId !== payload.requestId ||902 pending.rpId !== payload.rpId ||903 pending.userHandle !== payload.userHandle904 ) {905 return { ok: false, error: "no_pending_request" };906 }907908 const passkey: PasskeyData = {909 rpId: payload.rpId,910 rpName: payload.rpName,911 credentialId: payload.credentialId,912 publicKeyCose: payload.publicKeyCose,913 userHandle: payload.userHandle,914 userName: payload.userName,915 userDisplayName: payload.userDisplayName ?? payload.userName,916 algorithm: payload.algorithm,917 signCount: 0,918 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),919 backupEligible: payload.backupEligible ?? false,920 backupState: payload.backupState ?? false,921 browser: "firefox",922 transports: payload.transports ?? ["internal"],923 createdAt: Date.now(),924 };925926 927 928 929 const target = findAttachTarget(payload.rpId);930 if (target) {931 await vault.updateEntry(target.id, { passkey });932 } else {933 await vault.addEntry({934 type: "login",935 title: payload.rpName || payload.rpId,936 url: `https://${payload.rpId}`,937 username: payload.userName,938 passkey,939 });940 }941 await vault.consolidatePasskeyEntries();942943 if (chrome.storage.session) {944 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);945 }946 return { ok: true };947}948949export async function cancelFirefoxPasskeyCreate(): Promise<void> {950 if (chrome.storage.session) {951 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);952 }953}954955function declineKey(domain: string): string {956 return `${FIREFOX_DECLINE_PREFIX}${domain}`;957}958959export async function recordPasskeyOfferDecline(domain: string): Promise<void> {960 if (!domain) return;961 await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });962}963964export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {965 if (!domain) return false;966 const key = declineKey(domain);967 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;968 const at = stored[key];969 if (typeof at !== "number") return false;970 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {971 await chrome.storage.local.remove(key);972 return false;973 }974 return true;975}976977978979980981982983984985986987988989990interface FirefoxAllowCredential {991 id: string;992 type?: string;993}994995interface FirefoxPubKeyCredParam {996 type?: string;997 alg: number;998}9991000export interface FirefoxPasskeyHasCredentialsPayload {1001 rpId: string;1002 allowCredentials: FirefoxAllowCredential[];1003}10041005export interface FirefoxPasskeyCreatePayload {1006 rpId: string;1007 rpName?: string;1008 userId: string;1009 userName: string;1010 userDisplayName?: string;1011 challenge: string;1012 clientDataJSON: number[];1013 origin: string;1014 pubKeyCredParams?: FirefoxPubKeyCredParam[];1015 authenticatorSelection?: Record<string, unknown>;1016 timeout?: number;1017 attestation?: string;1018}10191020export interface FirefoxPasskeyGetPayload {1021 rpId: string;1022 challenge: string;1023 clientDataJSON: number[];1024 origin: string;1025 allowCredentials: FirefoxAllowCredential[];1026 userVerification?: string;1027}10281029interface FirefoxCreateCredentialResponse {1030 type: "create";1031 id: string;1032 clientDataJSON: number[];1033 attestationObject: string;1034 authenticatorData: string;1035 publicKey: string;1036 algorithm: -7 | -8;1037}10381039interface FirefoxGetCredentialResponse {1040 type: "get";1041 id: string;1042 clientDataJSON: number[];1043 authenticatorData: string;1044 signature: string;1045 userHandle: string;1046}10471048type FirefoxPasskeyResult<T> =1049 | { ok: true; credential: T }1050 | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };10511052function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {1053 return vault.getEntries().filter(1054 (entry): entry is VaultEntry & { passkey: PasskeyData } =>1055 entry.passkey !== undefined && !entry.deletedAt1056 );1057}105810591060function clientDataJsonOrigin(bytes: number[] | undefined): string | null {1061 if (!Array.isArray(bytes)) return null;1062 try {1063 const obj = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes))) as { origin?: unknown };1064 return typeof obj.origin === "string" ? obj.origin : null;1065 } catch {1066 return null;1067 }1068}106910701071107210731074107510761077107810791080function assertOriginOwnsRpId(1081 rpId: string,1082 callerOrigin: string | undefined,1083 clientDataJSON?: number[],1084): void {1085 if (!callerOrigin || !isValidRpId(rpId, callerOrigin)) {1086 throw new DOMException(1087 `SecurityError: relying party ID "${rpId}" is not a registrable domain ` +1088 `suffix of the calling origin "${callerOrigin ?? "(unknown)"}".`,1089 "SecurityError",1090 );1091 }1092 const cdjOrigin = clientDataJsonOrigin(clientDataJSON);1093 if (cdjOrigin !== null && cdjOrigin !== callerOrigin) {1094 throw new DOMException(1095 `SecurityError: clientDataJSON origin "${cdjOrigin}" does not match the ` +1096 `attested calling origin "${callerOrigin}".`,1097 "SecurityError",1098 );1099 }1100}11011102export async function handlePasskeyHasCredentials(1103 payload: FirefoxPasskeyHasCredentialsPayload,1104 callerOrigin?: string,1105): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {1106 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1107 return { ok: false, error: "not_applicable" };1108 }1109 1110 1111 1112 if (!callerOrigin || !isValidRpId(payload.rpId, callerOrigin)) {1113 return { ok: true, hasCredentials: false };1114 }1115 if (!vault.isUnlocked()) {1116 1117 1118 1119 return { ok: true, hasCredentials: false };1120 }1121 const passkeys = listPasskeyEntries().filter(1122 (entry) => entry.passkey.rpId === payload.rpId1123 );1124 if (passkeys.length === 0) return { ok: true, hasCredentials: false };11251126 const allow = payload.allowCredentials ?? [];1127 if (allow.length === 0) return { ok: true, hasCredentials: true };11281129 const hasMatch = passkeys.some((entry) =>1130 allow.some((ac) => ac.id === entry.passkey.credentialId)1131 );1132 return { ok: true, hasCredentials: hasMatch };1133}113411351136113711381139114011411142async function requestFirefoxApproval(args: {1143 type: "create" | "get";1144 rpId: string;1145 rpName?: string;1146 userName: string;1147 userDisplayName?: string;1148}): Promise<boolean> {1149 1150 1151 const requestId = Date.now() + Math.floor(Math.random() * 1000);1152 return promptUserApproval({1153 requestId,1154 type: args.type,1155 rpId: args.rpId,1156 rpName: args.rpName ?? args.rpId,1157 userName: args.userName,1158 userDisplayName: args.userDisplayName,1159 });1160}11611162export async function handleFirefoxPasskeyCreate(1163 payload: FirefoxPasskeyCreatePayload,1164 callerOrigin?: string,1165): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {1166 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1167 return { ok: false, error: "not_applicable" };1168 }1169 1170 1171 1172 1173 1174 assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1175 if (!(await isProUser())) {1176 broadcastPasskeyProGate();1177 return { ok: false, error: "pro_required" };1178 }1179 if (!vault.isUnlocked()) {1180 await openApprovalPopup();1181 return { ok: false, error: "vault_locked" };1182 }11831184 const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);1185 if (!algorithm) {1186 return { ok: false, error: "internal_error", message: "No supported algorithm offered" };1187 }11881189 const approved = await requestFirefoxApproval({1190 type: "create",1191 rpId: payload.rpId,1192 rpName: payload.rpName,1193 userName: payload.userName,1194 userDisplayName: payload.userDisplayName,1195 });1196 if (!approved) return { ok: false, error: "user_cancelled" };11971198 const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1199 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);12001201 1202 1203 const authenticatorData = await buildAuthenticatorData({1204 rpId: payload.rpId,1205 flags: 0x45,1206 signCount: 0,1207 credentialId: credentialIdBytes,1208 publicKeyCose,1209 });1210 const attestationObject = createAttestationObject(authenticatorData);12111212 const passkey: PasskeyData = {1213 rpId: payload.rpId,1214 rpName: payload.rpName ?? payload.rpId,1215 credentialId,1216 publicKeyCose: base64urlEncode(publicKeyCose),1217 privateKeyJwk,1218 userHandle: payload.userId,1219 userName: payload.userName,1220 userDisplayName: payload.userDisplayName ?? payload.userName,1221 algorithm,1222 signCount: 0,1223 aaguid: base64urlEncode(new Uint8Array(16)),1224 backupEligible: false,1225 backupState: false,1226 browser: "firefox",1227 transports: ["internal"],1228 createdAt: Date.now(),1229 };12301231 const target = findAttachTarget(payload.rpId);1232 if (target) {1233 await vault.updateEntry(target.id, { passkey });1234 } else {1235 await vault.addEntry({1236 type: "login",1237 title: payload.rpName || payload.rpId,1238 url: `https://${payload.rpId}`,1239 username: payload.userName,1240 passkey,1241 });1242 }1243 await vault.consolidatePasskeyEntries();12441245 return {1246 ok: true,1247 credential: {1248 type: "create",1249 id: credentialId,1250 1251 1252 1253 clientDataJSON: payload.clientDataJSON,1254 attestationObject: base64urlEncode(attestationObject),1255 authenticatorData: base64urlEncode(authenticatorData),1256 publicKey: base64urlEncode(publicKeySpki),1257 algorithm,1258 },1259 };1260}12611262export async function handleFirefoxPasskeyGet(1263 payload: FirefoxPasskeyGetPayload,1264 callerOrigin?: string,1265): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1266 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1267 return { ok: false, error: "not_applicable" };1268 }1269 1270 1271 1272 1273 1274 1275 assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1276 if (!(await isProUser())) {1277 broadcastPasskeyProGate();1278 return { ok: false, error: "pro_required" };1279 }1280 if (!vault.isUnlocked()) {1281 await openApprovalPopup();1282 return { ok: false, error: "vault_locked" };1283 }12841285 const allow = payload.allowCredentials ?? [];1286 const candidates = listPasskeyEntries().filter((entry) => {1287 if (entry.passkey.rpId !== payload.rpId) return false;1288 if (allow.length === 0) return true;1289 return allow.some((ac) => ac.id === entry.passkey.credentialId);1290 });12911292 if (candidates.length === 0) return { ok: false, error: "no_credentials" };12931294 1295 1296 1297 candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1298 const match = candidates[0]!;1299 const credential = match.passkey;13001301 if (!credential.privateKeyJwk) {1302 1303 1304 1305 return { ok: false, error: "no_credentials" };1306 }13071308 const approved = await requestFirefoxApproval({1309 type: "get",1310 rpId: credential.rpId,1311 rpName: credential.rpName,1312 userName: credential.userName,1313 userDisplayName: credential.userDisplayName,1314 });1315 if (!approved) return { ok: false, error: "user_cancelled" };13161317 const nextSignCount = credential.signCount + 1;1318 1319 1320 const flags =1321 0x05 |1322 (credential.backupEligible ? 0x08 : 0) |1323 (credential.backupState ? 0x10 : 0);1324 const authenticatorData = await buildAuthenticatorData({1325 rpId: credential.rpId,1326 flags,1327 signCount: nextSignCount,1328 });13291330 1331 1332 1333 const clientDataBytes = new Uint8Array(payload.clientDataJSON);1334 const clientDataHash = new Uint8Array(1335 await crypto.subtle.digest("SHA-256", clientDataBytes)1336 );1337 const signature = await signPasskeyAssertion(1338 credential.privateKeyJwk,1339 credential.algorithm,1340 authenticatorData,1341 clientDataHash1342 );13431344 await vault.updateEntry(match.id, {1345 passkey: {1346 ...credential,1347 signCount: nextSignCount,1348 lastUsedAt: Date.now(),1349 },1350 });13511352 return {1353 ok: true,1354 credential: {1355 type: "get",1356 id: credential.credentialId,1357 clientDataJSON: payload.clientDataJSON,1358 authenticatorData: base64urlEncode(authenticatorData),1359 signature: base64urlEncode(signature),1360 userHandle: credential.userHandle,1361 },1362 };1363}13641365export async function setupPasskeyProxy(): Promise<void> {1366 if (!isPasskeySupported()) {1367 console.log("[Peach passkey] setupPasskeyProxy: not supported on this browser");1368 return;1369 }13701371 1372 1373 if (detectSharedBrowser() === "firefox") {1374 await refreshPasskeyProxy();1375 return;1376 }13771378 if (!listenersRegistered) {1379 console.log("[Peach passkey] registering webAuthenticationProxy listeners");1380 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1381 1382 1383 1384 1385 1386 1387 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1388 requestId: requestInfo.requestId,1389 isUvpaa: true,1390 });1391 });1392 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1393 console.log("[Peach passkey] onCreateRequest fired", { requestId: requestInfo.requestId });1394 void handleCreateRequest(requestInfo).catch(async (error) => {1395 1396 1397 1398 if (isInvalidRequestIdError(error)) {1399 console.log("[Peach passkey] create handler resolved after cancel", { requestId: requestInfo.requestId });1400 return;1401 }1402 console.error("Peach passkey create failed", error);1403 await rejectCreate(1404 requestInfo.requestId,1405 "UnknownError",1406 error instanceof Error ? error.message : "Passkey creation failed."1407 );1408 });1409 });1410 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1411 console.log("[Peach passkey] onGetRequest fired", { requestId: requestInfo.requestId });1412 void handleGetRequest(requestInfo).catch(async (error) => {1413 if (isInvalidRequestIdError(error)) {1414 console.log("[Peach passkey] get handler resolved after cancel", { requestId: requestInfo.requestId });1415 return;1416 }1417 console.error("Peach passkey get failed", error);1418 await rejectGet(1419 requestInfo.requestId,1420 "UnknownError",1421 error instanceof Error ? error.message : "Passkey authentication failed."1422 );1423 });1424 });1425 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1426 console.log("[Peach passkey] onRequestCanceled", { requestId });1427 markCancelled(requestId);1428 1429 const waiter = unlockWaiters.get(requestId);1430 if (waiter) {1431 clearTimeout(waiter.timeoutId);1432 waiter.resolve(false);1433 unlockWaiters.delete(requestId);1434 }1435 void resolvePendingPasskeyRequest(requestId, false);1436 });1437 listenersRegistered = true;1438 }14391440 await refreshPasskeyProxy();1441}