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 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {432 console.warn("[Peach passkey] reject: user is not Pro");433 await rejectCreate(434 request.requestId,435 "NotAllowedError",436 "Passkey creation requires Peach Pro."437 );438 439 broadcastPasskeyProGate();440 return;441 }442443 if (!vault.isUnlocked()) {444 console.log("[Peach passkey] vault locked, summoning inline unlock");445 446 447 448 449 450 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>451 chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)452 );453 const activeTab = tabs[0];454 console.log("[Peach passkey] active tab for unlock summon", { id: activeTab?.id, url: activeTab?.url });455 if (activeTab?.id) {456 chrome.tabs.sendMessage(activeTab.id, {457 type: 'SUMMON_AUTOFILL',458 source: 'passkey-unlock',459 }).catch((err) => {460 console.warn("[Peach passkey] SUMMON_AUTOFILL failed, falling back to popup", err);461 void openApprovalPopup();462 });463 } else {464 await openApprovalPopup();465 }466467 const unlocked = await new Promise<boolean>((resolve) => {468 const timeoutId = setTimeout(() => {469 unlockWaiters.delete(request.requestId);470 resolve(false);471 }, APPROVAL_TIMEOUT_MS);472 unlockWaiters.set(request.requestId, { resolve, timeoutId });473 });474475 console.log("[Peach passkey] unlock wait resolved", { unlocked, vaultUnlocked: vault.isUnlocked() });476 if (!unlocked || !vault.isUnlocked()) {477 console.warn("[Peach passkey] reject: unlock timeout");478 await rejectCreate(request.requestId, "NotAllowedError", "Not unlocked in time.");479 return;480 }481 482 }483484 console.log("[Peach passkey] requesting approval via popup");485 const approved = await promptUserApproval({486 requestId: request.requestId,487 type: "create",488 rpId,489 rpName,490 userName,491 userDisplayName,492 });493 console.log("[Peach passkey] approval resolved", { approved });494 if (!approved) {495 console.warn("[Peach passkey] reject: user denied");496 await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");497 return;498 }499500 const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);501 if (!algorithm) {502 console.warn("[Peach passkey] reject: no supported algorithm", options.pubKeyCredParams);503 await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");504 return;505 }506 console.log("[Peach passkey] algorithm selected", algorithm);507508 const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));509 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(510 algorithm511 );512 const authData = await buildAuthenticatorData({513 rpId,514 flags: 0x4d,515 signCount: 0,516 credentialId: credentialIdBytes,517 publicKeyCose,518 });519 const attestationObject = createAttestationObject(authData);520 const clientDataJSON = buildClientDataJSON(521 "webauthn.create",522 options.challenge,523 await resolvePageOrigin(rpId)524 );525526 const passkey: PasskeyData = {527 rpId,528 rpName,529 credentialId: base64urlEncode(credentialIdBytes),530 publicKeyCose: base64urlEncode(publicKeyCose),531 privateKeyJwk,532 userHandle: options.user.id,533 userName,534 userDisplayName,535 algorithm,536 signCount: 0,537 aaguid: base64urlEncode(new Uint8Array(16)),538 backupEligible: false,539 backupState: false,540 browser: "chrome",541 transports: ["internal"],542 createdAt: Date.now(),543 };544545 546 547 const target = findAttachTarget(rpId);548 if (target) {549 await vault.updateEntry(target.id, { passkey });550 } else {551 await vault.addEntry({552 type: "login",553 title: rpName,554 url: `https://${rpId}`,555 username: userName,556 passkey,557 });558 }559560 try {561 await chrome.webAuthenticationProxy.completeCreateRequest({562 requestId: request.requestId,563 responseJson: JSON.stringify({564 id: passkey.credentialId,565 rawId: passkey.credentialId,566 type: "public-key",567 response: {568 clientDataJSON: base64urlEncode(clientDataJSON),569 authenticatorData: base64urlEncode(authData),570 attestationObject: base64urlEncode(attestationObject),571 transports: ["internal"],572 publicKey: base64urlEncode(publicKeySpki),573 publicKeyAlgorithm: algorithm,574 },575 authenticatorAttachment: "platform",576 clientExtensionResults: {},577 }),578 });579 console.log("[Peach passkey] completeCreateRequest success", { requestId: request.requestId });580 } catch (err) {581 if (isInvalidRequestIdError(err)) {582 console.log("[Peach passkey] completeCreateRequest skipped (already cancelled)", { requestId: request.requestId });583 return;584 }585 throw err;586 }587}588589function broadcastPasskeyProGate(): void {590 chrome.tabs.query({}, (tabs) => {591 for (const tab of tabs) {592 if (tab.id !== undefined) {593 chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});594 }595 }596 });597}598599async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {600 if (chrome.storage.session) {601 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });602 }603604 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {605 chrome.tabs.query({ active: true, currentWindow: true }, resolve);606 });607 const tab = tabs[0];608609 if (tab?.id) {610 chrome.tabs.sendMessage(tab.id, {611 type: 'SHOW_PASSKEY_TOAST',612 payload: {613 site: request.rpId,614 displayName: request.userDisplayName ?? request.userName,615 username: request.userName,616 requestId: String(request.requestId),617 },618 }).catch(() => {});619 } else {620 await openApprovalPopup();621 }622623 return new Promise<boolean>((resolve) => {624 const timeoutId = setTimeout(async () => {625 approvals.delete(request.requestId);626 await clearPendingRequest(request.requestId);627 resolve(false);628 }, APPROVAL_TIMEOUT_MS);629630 approvals.set(request.requestId, { resolve, timeoutId });631 });632}633634async function handleGetRequest(635 request: chrome.webAuthenticationProxy.GetRequest636): Promise<void> {637 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;638 if (!vault.isUnlocked()) {639 640 641 642 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>643 chrome.tabs.query({ active: true, currentWindow: true }, resolve)644 );645 const activeTab = tabs[0];646 if (activeTab?.id) {647 chrome.tabs.sendMessage(activeTab.id, {648 type: 'SUMMON_AUTOFILL',649 source: 'passkey-unlock',650 }).catch(() => { void openApprovalPopup(); });651 } else {652 await openApprovalPopup();653 }654655 656 const unlocked = await new Promise<boolean>((resolve) => {657 const timeoutId = setTimeout(() => {658 unlockWaiters.delete(request.requestId);659 resolve(false);660 }, APPROVAL_TIMEOUT_MS);661 unlockWaiters.set(request.requestId, { resolve, timeoutId });662 });663664 if (!unlocked || !vault.isUnlocked()) {665 await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");666 return;667 }668 669 }670671 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {672 await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");673 broadcastPasskeyProGate();674 return;675 }676677 const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>678 entry.passkey !== undefined && !entry.deletedAt679 );680 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);681682 const match = passkeyBearing.find((entry) => {683 if (allowedCredentialIds.length > 0) {684 return allowedCredentialIds.includes(entry.passkey.credentialId);685 }686 if (!options.rpId) return false;687 return entry.passkey.rpId === options.rpId;688 });689690 if (!match) {691 692 693 694 695 696 697 console.log("[Peach passkey] no matching credential — passing through to platform", { requestId: request.requestId });698 try {699 await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });700 } catch (err) {701 if (isInvalidRequestIdError(err)) return;702 if (isMissingResponseJsonError(err)) return;703 throw err;704 }705 return;706 }707708 const credential = match.passkey;709 710 711 712 if (!credential.privateKeyJwk) {713 console.log("[Peach passkey] no private key (Firefox passkey) — passing through to platform", { requestId: request.requestId });714 try {715 await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });716 } catch (err) {717 if (isInvalidRequestIdError(err)) return;718 if (isMissingResponseJsonError(err)) return;719 throw err;720 }721 return;722 }723724 const approved = await promptViaToast({725 requestId: request.requestId,726 type: "get",727 rpId: credential.rpId,728 rpName: credential.rpName,729 userName: credential.userName,730 userDisplayName: credential.userDisplayName,731 });732 if (!approved) {733 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");734 return;735 }736737 const nextSignCount = credential.signCount + 1;738 const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);739 const authData = await buildAuthenticatorData({740 rpId: credential.rpId,741 flags,742 signCount: nextSignCount,743 });744 const clientDataJSON = buildClientDataJSON(745 "webauthn.get",746 options.challenge,747 await resolvePageOrigin(credential.rpId)748 );749 const clientDataHash = new Uint8Array(750 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))751 );752 const signature = await signPasskeyAssertion(753 credential.privateKeyJwk,754 credential.algorithm,755 authData,756 clientDataHash757 );758759 await vault.updateEntry(match.id, {760 passkey: {761 ...credential,762 signCount: nextSignCount,763 lastUsedAt: Date.now(),764 },765 });766767 const responseJson = JSON.stringify({768 id: credential.credentialId,769 rawId: credential.credentialId,770 type: "public-key",771 response: {772 clientDataJSON: base64urlEncode(clientDataJSON),773 authenticatorData: base64urlEncode(authData),774 signature: base64urlEncode(signature),775 userHandle: credential.userHandle,776 },777 authenticatorAttachment: "platform",778 clientExtensionResults: {},779 });780781 try {782 await chrome.webAuthenticationProxy.completeGetRequest({783 requestId: request.requestId,784 responseJson,785 });786 console.log("[Peach passkey] completeGetRequest success", { requestId: request.requestId });787 } catch (err) {788 if (isInvalidRequestIdError(err)) {789 console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId: request.requestId });790 return;791 }792 throw err;793 }794}795796797798799800801802803804805const FIREFOX_PENDING_KEY = "passkey_pending_firefox";806const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";807const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; 808809export async function initiateFirefoxPasskeyCreate(args: {810 rpId: string;811 rpName: string;812 userName: string;813 userDisplayName?: string;814}): Promise<{ ok: boolean; error?: string }> {815 if (!(await isProUser())) {816 broadcastPasskeyProGate();817 return { ok: false, error: "passkey creation requires Peach Pro" };818 }819 if (!vault.isUnlocked()) {820 await openApprovalPopup();821 return { ok: false, error: "vault_locked" };822 }823 if (!chrome.storage.session) {824 return { ok: false, error: "session storage unavailable" };825 }826827 828 829 830 const requestId = Date.now() + Math.floor(Math.random() * 1_000_000);831 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));832 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));833834 const pending: PendingPasskeyRequest = {835 requestId,836 type: "create",837 rpId: args.rpId,838 rpName: args.rpName,839 userName: args.userName,840 userDisplayName: args.userDisplayName,841 browser: "firefox",842 challenge,843 userHandle,844 };845846 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });847 await openApprovalPopup();848 return { ok: true };849}850851export async function completeFirefoxPasskeyCreate(payload: {852 requestId: number;853 rpId: string;854 rpName: string;855 userName: string;856 userDisplayName?: string;857 userHandle: string;858 credentialId: string;859 publicKeyCose: string;860 algorithm: -7 | -8;861 aaguid?: string;862 transports?: string[];863 backupEligible?: boolean;864 backupState?: boolean;865}): Promise<{ ok: boolean; error?: string }> {866 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };867868 const passkey: PasskeyData = {869 rpId: payload.rpId,870 rpName: payload.rpName,871 credentialId: payload.credentialId,872 publicKeyCose: payload.publicKeyCose,873 userHandle: payload.userHandle,874 userName: payload.userName,875 userDisplayName: payload.userDisplayName ?? payload.userName,876 algorithm: payload.algorithm,877 signCount: 0,878 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),879 backupEligible: payload.backupEligible ?? false,880 backupState: payload.backupState ?? false,881 browser: "firefox",882 transports: payload.transports ?? ["internal"],883 createdAt: Date.now(),884 };885886 887 888 889 const target = findAttachTarget(payload.rpId);890 if (target) {891 await vault.updateEntry(target.id, { passkey });892 } else {893 await vault.addEntry({894 type: "login",895 title: payload.rpName || payload.rpId,896 url: `https://${payload.rpId}`,897 username: payload.userName,898 passkey,899 });900 }901902 if (chrome.storage.session) {903 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);904 }905 return { ok: true };906}907908export async function cancelFirefoxPasskeyCreate(): Promise<void> {909 if (chrome.storage.session) {910 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);911 }912}913914function declineKey(domain: string): string {915 return `${FIREFOX_DECLINE_PREFIX}${domain}`;916}917918export async function recordPasskeyOfferDecline(domain: string): Promise<void> {919 if (!domain) return;920 await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });921}922923export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {924 if (!domain) return false;925 const key = declineKey(domain);926 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;927 const at = stored[key];928 if (typeof at !== "number") return false;929 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {930 await chrome.storage.local.remove(key);931 return false;932 }933 return true;934}935936937938939940941942943944945946947948949interface FirefoxAllowCredential {950 id: string;951 type?: string;952}953954interface FirefoxPubKeyCredParam {955 type?: string;956 alg: number;957}958959export interface FirefoxPasskeyHasCredentialsPayload {960 rpId: string;961 allowCredentials: FirefoxAllowCredential[];962}963964export interface FirefoxPasskeyCreatePayload {965 rpId: string;966 rpName?: string;967 userId: string;968 userName: string;969 userDisplayName?: string;970 challenge: string;971 clientDataJSON: number[];972 origin: string;973 pubKeyCredParams?: FirefoxPubKeyCredParam[];974 authenticatorSelection?: Record<string, unknown>;975 timeout?: number;976 attestation?: string;977}978979export interface FirefoxPasskeyGetPayload {980 rpId: string;981 challenge: string;982 clientDataJSON: number[];983 origin: string;984 allowCredentials: FirefoxAllowCredential[];985 userVerification?: string;986}987988interface FirefoxCreateCredentialResponse {989 type: "create";990 id: string;991 clientDataJSON: number[];992 attestationObject: string;993 authenticatorData: string;994 publicKey: string;995 algorithm: -7 | -8;996}997998interface FirefoxGetCredentialResponse {999 type: "get";1000 id: string;1001 clientDataJSON: number[];1002 authenticatorData: string;1003 signature: string;1004 userHandle: string;1005}10061007type FirefoxPasskeyResult<T> =1008 | { ok: true; credential: T }1009 | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };10101011function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {1012 return vault.getEntries().filter(1013 (entry): entry is VaultEntry & { passkey: PasskeyData } =>1014 entry.passkey !== undefined && !entry.deletedAt1015 );1016}101710181019function clientDataJsonOrigin(bytes: number[] | undefined): string | null {1020 if (!Array.isArray(bytes)) return null;1021 try {1022 const obj = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes))) as { origin?: unknown };1023 return typeof obj.origin === "string" ? obj.origin : null;1024 } catch {1025 return null;1026 }1027}102810291030103110321033103410351036103710381039function assertOriginOwnsRpId(1040 rpId: string,1041 callerOrigin: string | undefined,1042 clientDataJSON?: number[],1043): void {1044 if (!callerOrigin || !isValidRpId(rpId, callerOrigin)) {1045 throw new DOMException(1046 `SecurityError: relying party ID "${rpId}" is not a registrable domain ` +1047 `suffix of the calling origin "${callerOrigin ?? "(unknown)"}".`,1048 "SecurityError",1049 );1050 }1051 const cdjOrigin = clientDataJsonOrigin(clientDataJSON);1052 if (cdjOrigin !== null && cdjOrigin !== callerOrigin) {1053 throw new DOMException(1054 `SecurityError: clientDataJSON origin "${cdjOrigin}" does not match the ` +1055 `attested calling origin "${callerOrigin}".`,1056 "SecurityError",1057 );1058 }1059}10601061export async function handlePasskeyHasCredentials(1062 payload: FirefoxPasskeyHasCredentialsPayload,1063 callerOrigin?: string,1064): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {1065 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1066 return { ok: false, error: "not_applicable" };1067 }1068 1069 1070 1071 if (!callerOrigin || !isValidRpId(payload.rpId, callerOrigin)) {1072 return { ok: true, hasCredentials: false };1073 }1074 if (!vault.isUnlocked()) {1075 1076 1077 1078 return { ok: true, hasCredentials: false };1079 }1080 const passkeys = listPasskeyEntries().filter(1081 (entry) => entry.passkey.rpId === payload.rpId1082 );1083 if (passkeys.length === 0) return { ok: true, hasCredentials: false };10841085 const allow = payload.allowCredentials ?? [];1086 if (allow.length === 0) return { ok: true, hasCredentials: true };10871088 const hasMatch = passkeys.some((entry) =>1089 allow.some((ac) => ac.id === entry.passkey.credentialId)1090 );1091 return { ok: true, hasCredentials: hasMatch };1092}109310941095109610971098109911001101async function requestFirefoxApproval(args: {1102 type: "create" | "get";1103 rpId: string;1104 rpName?: string;1105 userName: string;1106 userDisplayName?: string;1107}): Promise<boolean> {1108 1109 1110 const requestId = Date.now() + Math.floor(Math.random() * 1000);1111 return promptUserApproval({1112 requestId,1113 type: args.type,1114 rpId: args.rpId,1115 rpName: args.rpName ?? args.rpId,1116 userName: args.userName,1117 userDisplayName: args.userDisplayName,1118 });1119}11201121export async function handleFirefoxPasskeyCreate(1122 payload: FirefoxPasskeyCreatePayload,1123 callerOrigin?: string,1124): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {1125 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1126 return { ok: false, error: "not_applicable" };1127 }1128 1129 1130 1131 1132 1133 assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1134 if (!(await isProUser())) {1135 broadcastPasskeyProGate();1136 return { ok: false, error: "pro_required" };1137 }1138 if (!vault.isUnlocked()) {1139 await openApprovalPopup();1140 return { ok: false, error: "vault_locked" };1141 }11421143 const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);1144 if (!algorithm) {1145 return { ok: false, error: "internal_error", message: "No supported algorithm offered" };1146 }11471148 const approved = await requestFirefoxApproval({1149 type: "create",1150 rpId: payload.rpId,1151 rpName: payload.rpName,1152 userName: payload.userName,1153 userDisplayName: payload.userDisplayName,1154 });1155 if (!approved) return { ok: false, error: "user_cancelled" };11561157 const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1158 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);11591160 1161 1162 const authenticatorData = await buildAuthenticatorData({1163 rpId: payload.rpId,1164 flags: 0x45,1165 signCount: 0,1166 credentialId: credentialIdBytes,1167 publicKeyCose,1168 });1169 const attestationObject = createAttestationObject(authenticatorData);11701171 const passkey: PasskeyData = {1172 rpId: payload.rpId,1173 rpName: payload.rpName ?? payload.rpId,1174 credentialId,1175 publicKeyCose: base64urlEncode(publicKeyCose),1176 privateKeyJwk,1177 userHandle: payload.userId,1178 userName: payload.userName,1179 userDisplayName: payload.userDisplayName ?? payload.userName,1180 algorithm,1181 signCount: 0,1182 aaguid: base64urlEncode(new Uint8Array(16)),1183 backupEligible: false,1184 backupState: false,1185 browser: "firefox",1186 transports: ["internal"],1187 createdAt: Date.now(),1188 };11891190 const target = findAttachTarget(payload.rpId);1191 if (target) {1192 await vault.updateEntry(target.id, { passkey });1193 } else {1194 await vault.addEntry({1195 type: "login",1196 title: payload.rpName || payload.rpId,1197 url: `https://${payload.rpId}`,1198 username: payload.userName,1199 passkey,1200 });1201 }12021203 return {1204 ok: true,1205 credential: {1206 type: "create",1207 id: credentialId,1208 1209 1210 1211 clientDataJSON: payload.clientDataJSON,1212 attestationObject: base64urlEncode(attestationObject),1213 authenticatorData: base64urlEncode(authenticatorData),1214 publicKey: base64urlEncode(publicKeySpki),1215 algorithm,1216 },1217 };1218}12191220export async function handleFirefoxPasskeyGet(1221 payload: FirefoxPasskeyGetPayload,1222 callerOrigin?: string,1223): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1224 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1225 return { ok: false, error: "not_applicable" };1226 }1227 1228 1229 1230 1231 1232 1233 assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1234 if (!(await isProUser())) {1235 broadcastPasskeyProGate();1236 return { ok: false, error: "pro_required" };1237 }1238 if (!vault.isUnlocked()) {1239 await openApprovalPopup();1240 return { ok: false, error: "vault_locked" };1241 }12421243 const allow = payload.allowCredentials ?? [];1244 const candidates = listPasskeyEntries().filter((entry) => {1245 if (entry.passkey.rpId !== payload.rpId) return false;1246 if (allow.length === 0) return true;1247 return allow.some((ac) => ac.id === entry.passkey.credentialId);1248 });12491250 if (candidates.length === 0) return { ok: false, error: "no_credentials" };12511252 1253 1254 1255 candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1256 const match = candidates[0]!;1257 const credential = match.passkey;12581259 if (!credential.privateKeyJwk) {1260 1261 1262 1263 return { ok: false, error: "no_credentials" };1264 }12651266 const approved = await requestFirefoxApproval({1267 type: "get",1268 rpId: credential.rpId,1269 rpName: credential.rpName,1270 userName: credential.userName,1271 userDisplayName: credential.userDisplayName,1272 });1273 if (!approved) return { ok: false, error: "user_cancelled" };12741275 const nextSignCount = credential.signCount + 1;1276 1277 1278 const flags =1279 0x05 |1280 (credential.backupEligible ? 0x08 : 0) |1281 (credential.backupState ? 0x10 : 0);1282 const authenticatorData = await buildAuthenticatorData({1283 rpId: credential.rpId,1284 flags,1285 signCount: nextSignCount,1286 });12871288 1289 1290 1291 const clientDataBytes = new Uint8Array(payload.clientDataJSON);1292 const clientDataHash = new Uint8Array(1293 await crypto.subtle.digest("SHA-256", clientDataBytes)1294 );1295 const signature = await signPasskeyAssertion(1296 credential.privateKeyJwk,1297 credential.algorithm,1298 authenticatorData,1299 clientDataHash1300 );13011302 await vault.updateEntry(match.id, {1303 passkey: {1304 ...credential,1305 signCount: nextSignCount,1306 lastUsedAt: Date.now(),1307 },1308 });13091310 return {1311 ok: true,1312 credential: {1313 type: "get",1314 id: credential.credentialId,1315 clientDataJSON: payload.clientDataJSON,1316 authenticatorData: base64urlEncode(authenticatorData),1317 signature: base64urlEncode(signature),1318 userHandle: credential.userHandle,1319 },1320 };1321}13221323export async function setupPasskeyProxy(): Promise<void> {1324 if (!isPasskeySupported()) {1325 console.log("[Peach passkey] setupPasskeyProxy: not supported on this browser");1326 return;1327 }13281329 1330 1331 if (detectSharedBrowser() === "firefox") {1332 await refreshPasskeyProxy();1333 return;1334 }13351336 if (!listenersRegistered) {1337 console.log("[Peach passkey] registering webAuthenticationProxy listeners");1338 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1339 1340 1341 1342 1343 1344 1345 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1346 requestId: requestInfo.requestId,1347 isUvpaa: true,1348 });1349 });1350 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1351 console.log("[Peach passkey] onCreateRequest fired", { requestId: requestInfo.requestId });1352 void handleCreateRequest(requestInfo).catch(async (error) => {1353 1354 1355 1356 if (isInvalidRequestIdError(error)) {1357 console.log("[Peach passkey] create handler resolved after cancel", { requestId: requestInfo.requestId });1358 return;1359 }1360 console.error("Peach passkey create failed", error);1361 await rejectCreate(1362 requestInfo.requestId,1363 "UnknownError",1364 error instanceof Error ? error.message : "Passkey creation failed."1365 );1366 });1367 });1368 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1369 console.log("[Peach passkey] onGetRequest fired", { requestId: requestInfo.requestId });1370 void handleGetRequest(requestInfo).catch(async (error) => {1371 if (isInvalidRequestIdError(error)) {1372 console.log("[Peach passkey] get handler resolved after cancel", { requestId: requestInfo.requestId });1373 return;1374 }1375 console.error("Peach passkey get failed", error);1376 await rejectGet(1377 requestInfo.requestId,1378 "UnknownError",1379 error instanceof Error ? error.message : "Passkey authentication failed."1380 );1381 });1382 });1383 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1384 console.log("[Peach passkey] onRequestCanceled", { requestId });1385 markCancelled(requestId);1386 1387 const waiter = unlockWaiters.get(requestId);1388 if (waiter) {1389 clearTimeout(waiter.timeoutId);1390 waiter.resolve(false);1391 unlockWaiters.delete(requestId);1392 }1393 void resolvePendingPasskeyRequest(requestId, false);1394 });1395 listenersRegistered = true;1396 }13971398 await refreshPasskeyProxy();1399}