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}): Promise<{ ok: boolean; error?: string }> {827 if (!(await isProUser())) {828 broadcastPasskeyProGate();829 return { ok: false, error: "passkey creation requires Peach Pro" };830 }831 if (!vault.isUnlocked()) {832 await openApprovalPopup();833 return { ok: false, error: "vault_locked" };834 }835 if (!chrome.storage.session) {836 return { ok: false, error: "session storage unavailable" };837 }838839 840 841 842 const requestId = Date.now() + Math.floor(Math.random() * 1_000_000);843 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));844 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));845846 const pending: PendingPasskeyRequest = {847 requestId,848 type: "create",849 rpId: args.rpId,850 rpName: args.rpName,851 userName: args.userName,852 userDisplayName: args.userDisplayName,853 browser: "firefox",854 challenge,855 userHandle,856 };857858 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });859 await openApprovalPopup();860 return { ok: true };861}862863export async function completeFirefoxPasskeyCreate(payload: {864 requestId: number;865 rpId: string;866 rpName: string;867 userName: string;868 userDisplayName?: string;869 userHandle: string;870 credentialId: string;871 publicKeyCose: string;872 algorithm: -7 | -8;873 aaguid?: string;874 transports?: string[];875 backupEligible?: boolean;876 backupState?: boolean;877}): Promise<{ ok: boolean; error?: string }> {878 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };879880 const passkey: PasskeyData = {881 rpId: payload.rpId,882 rpName: payload.rpName,883 credentialId: payload.credentialId,884 publicKeyCose: payload.publicKeyCose,885 userHandle: payload.userHandle,886 userName: payload.userName,887 userDisplayName: payload.userDisplayName ?? payload.userName,888 algorithm: payload.algorithm,889 signCount: 0,890 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),891 backupEligible: payload.backupEligible ?? false,892 backupState: payload.backupState ?? false,893 browser: "firefox",894 transports: payload.transports ?? ["internal"],895 createdAt: Date.now(),896 };897898 899 900 901 const target = findAttachTarget(payload.rpId);902 if (target) {903 await vault.updateEntry(target.id, { passkey });904 } else {905 await vault.addEntry({906 type: "login",907 title: payload.rpName || payload.rpId,908 url: `https://${payload.rpId}`,909 username: payload.userName,910 passkey,911 });912 }913 await vault.consolidatePasskeyEntries();914915 if (chrome.storage.session) {916 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);917 }918 return { ok: true };919}920921export async function cancelFirefoxPasskeyCreate(): Promise<void> {922 if (chrome.storage.session) {923 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);924 }925}926927function declineKey(domain: string): string {928 return `${FIREFOX_DECLINE_PREFIX}${domain}`;929}930931export async function recordPasskeyOfferDecline(domain: string): Promise<void> {932 if (!domain) return;933 await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });934}935936export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {937 if (!domain) return false;938 const key = declineKey(domain);939 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;940 const at = stored[key];941 if (typeof at !== "number") return false;942 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {943 await chrome.storage.local.remove(key);944 return false;945 }946 return true;947}948949950951952953954955956957958959960961962interface FirefoxAllowCredential {963 id: string;964 type?: string;965}966967interface FirefoxPubKeyCredParam {968 type?: string;969 alg: number;970}971972export interface FirefoxPasskeyHasCredentialsPayload {973 rpId: string;974 allowCredentials: FirefoxAllowCredential[];975}976977export interface FirefoxPasskeyCreatePayload {978 rpId: string;979 rpName?: string;980 userId: string;981 userName: string;982 userDisplayName?: string;983 challenge: string;984 clientDataJSON: number[];985 origin: string;986 pubKeyCredParams?: FirefoxPubKeyCredParam[];987 authenticatorSelection?: Record<string, unknown>;988 timeout?: number;989 attestation?: string;990}991992export interface FirefoxPasskeyGetPayload {993 rpId: string;994 challenge: string;995 clientDataJSON: number[];996 origin: string;997 allowCredentials: FirefoxAllowCredential[];998 userVerification?: string;999}10001001interface FirefoxCreateCredentialResponse {1002 type: "create";1003 id: string;1004 clientDataJSON: number[];1005 attestationObject: string;1006 authenticatorData: string;1007 publicKey: string;1008 algorithm: -7 | -8;1009}10101011interface FirefoxGetCredentialResponse {1012 type: "get";1013 id: string;1014 clientDataJSON: number[];1015 authenticatorData: string;1016 signature: string;1017 userHandle: string;1018}10191020type FirefoxPasskeyResult<T> =1021 | { ok: true; credential: T }1022 | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };10231024function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {1025 return vault.getEntries().filter(1026 (entry): entry is VaultEntry & { passkey: PasskeyData } =>1027 entry.passkey !== undefined && !entry.deletedAt1028 );1029}103010311032function clientDataJsonOrigin(bytes: number[] | undefined): string | null {1033 if (!Array.isArray(bytes)) return null;1034 try {1035 const obj = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes))) as { origin?: unknown };1036 return typeof obj.origin === "string" ? obj.origin : null;1037 } catch {1038 return null;1039 }1040}104110421043104410451046104710481049105010511052function assertOriginOwnsRpId(1053 rpId: string,1054 callerOrigin: string | undefined,1055 clientDataJSON?: number[],1056): void {1057 if (!callerOrigin || !isValidRpId(rpId, callerOrigin)) {1058 throw new DOMException(1059 `SecurityError: relying party ID "${rpId}" is not a registrable domain ` +1060 `suffix of the calling origin "${callerOrigin ?? "(unknown)"}".`,1061 "SecurityError",1062 );1063 }1064 const cdjOrigin = clientDataJsonOrigin(clientDataJSON);1065 if (cdjOrigin !== null && cdjOrigin !== callerOrigin) {1066 throw new DOMException(1067 `SecurityError: clientDataJSON origin "${cdjOrigin}" does not match the ` +1068 `attested calling origin "${callerOrigin}".`,1069 "SecurityError",1070 );1071 }1072}10731074export async function handlePasskeyHasCredentials(1075 payload: FirefoxPasskeyHasCredentialsPayload,1076 callerOrigin?: string,1077): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {1078 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1079 return { ok: false, error: "not_applicable" };1080 }1081 1082 1083 1084 if (!callerOrigin || !isValidRpId(payload.rpId, callerOrigin)) {1085 return { ok: true, hasCredentials: false };1086 }1087 if (!vault.isUnlocked()) {1088 1089 1090 1091 return { ok: true, hasCredentials: false };1092 }1093 const passkeys = listPasskeyEntries().filter(1094 (entry) => entry.passkey.rpId === payload.rpId1095 );1096 if (passkeys.length === 0) return { ok: true, hasCredentials: false };10971098 const allow = payload.allowCredentials ?? [];1099 if (allow.length === 0) return { ok: true, hasCredentials: true };11001101 const hasMatch = passkeys.some((entry) =>1102 allow.some((ac) => ac.id === entry.passkey.credentialId)1103 );1104 return { ok: true, hasCredentials: hasMatch };1105}110611071108110911101111111211131114async function requestFirefoxApproval(args: {1115 type: "create" | "get";1116 rpId: string;1117 rpName?: string;1118 userName: string;1119 userDisplayName?: string;1120}): Promise<boolean> {1121 1122 1123 const requestId = Date.now() + Math.floor(Math.random() * 1000);1124 return promptUserApproval({1125 requestId,1126 type: args.type,1127 rpId: args.rpId,1128 rpName: args.rpName ?? args.rpId,1129 userName: args.userName,1130 userDisplayName: args.userDisplayName,1131 });1132}11331134export async function handleFirefoxPasskeyCreate(1135 payload: FirefoxPasskeyCreatePayload,1136 callerOrigin?: string,1137): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {1138 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1139 return { ok: false, error: "not_applicable" };1140 }1141 1142 1143 1144 1145 1146 assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1147 if (!(await isProUser())) {1148 broadcastPasskeyProGate();1149 return { ok: false, error: "pro_required" };1150 }1151 if (!vault.isUnlocked()) {1152 await openApprovalPopup();1153 return { ok: false, error: "vault_locked" };1154 }11551156 const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);1157 if (!algorithm) {1158 return { ok: false, error: "internal_error", message: "No supported algorithm offered" };1159 }11601161 const approved = await requestFirefoxApproval({1162 type: "create",1163 rpId: payload.rpId,1164 rpName: payload.rpName,1165 userName: payload.userName,1166 userDisplayName: payload.userDisplayName,1167 });1168 if (!approved) return { ok: false, error: "user_cancelled" };11691170 const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1171 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);11721173 1174 1175 const authenticatorData = await buildAuthenticatorData({1176 rpId: payload.rpId,1177 flags: 0x45,1178 signCount: 0,1179 credentialId: credentialIdBytes,1180 publicKeyCose,1181 });1182 const attestationObject = createAttestationObject(authenticatorData);11831184 const passkey: PasskeyData = {1185 rpId: payload.rpId,1186 rpName: payload.rpName ?? payload.rpId,1187 credentialId,1188 publicKeyCose: base64urlEncode(publicKeyCose),1189 privateKeyJwk,1190 userHandle: payload.userId,1191 userName: payload.userName,1192 userDisplayName: payload.userDisplayName ?? payload.userName,1193 algorithm,1194 signCount: 0,1195 aaguid: base64urlEncode(new Uint8Array(16)),1196 backupEligible: false,1197 backupState: false,1198 browser: "firefox",1199 transports: ["internal"],1200 createdAt: Date.now(),1201 };12021203 const target = findAttachTarget(payload.rpId);1204 if (target) {1205 await vault.updateEntry(target.id, { passkey });1206 } else {1207 await vault.addEntry({1208 type: "login",1209 title: payload.rpName || payload.rpId,1210 url: `https://${payload.rpId}`,1211 username: payload.userName,1212 passkey,1213 });1214 }1215 await vault.consolidatePasskeyEntries();12161217 return {1218 ok: true,1219 credential: {1220 type: "create",1221 id: credentialId,1222 1223 1224 1225 clientDataJSON: payload.clientDataJSON,1226 attestationObject: base64urlEncode(attestationObject),1227 authenticatorData: base64urlEncode(authenticatorData),1228 publicKey: base64urlEncode(publicKeySpki),1229 algorithm,1230 },1231 };1232}12331234export async function handleFirefoxPasskeyGet(1235 payload: FirefoxPasskeyGetPayload,1236 callerOrigin?: string,1237): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1238 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1239 return { ok: false, error: "not_applicable" };1240 }1241 1242 1243 1244 1245 1246 1247 assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1248 if (!(await isProUser())) {1249 broadcastPasskeyProGate();1250 return { ok: false, error: "pro_required" };1251 }1252 if (!vault.isUnlocked()) {1253 await openApprovalPopup();1254 return { ok: false, error: "vault_locked" };1255 }12561257 const allow = payload.allowCredentials ?? [];1258 const candidates = listPasskeyEntries().filter((entry) => {1259 if (entry.passkey.rpId !== payload.rpId) return false;1260 if (allow.length === 0) return true;1261 return allow.some((ac) => ac.id === entry.passkey.credentialId);1262 });12631264 if (candidates.length === 0) return { ok: false, error: "no_credentials" };12651266 1267 1268 1269 candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1270 const match = candidates[0]!;1271 const credential = match.passkey;12721273 if (!credential.privateKeyJwk) {1274 1275 1276 1277 return { ok: false, error: "no_credentials" };1278 }12791280 const approved = await requestFirefoxApproval({1281 type: "get",1282 rpId: credential.rpId,1283 rpName: credential.rpName,1284 userName: credential.userName,1285 userDisplayName: credential.userDisplayName,1286 });1287 if (!approved) return { ok: false, error: "user_cancelled" };12881289 const nextSignCount = credential.signCount + 1;1290 1291 1292 const flags =1293 0x05 |1294 (credential.backupEligible ? 0x08 : 0) |1295 (credential.backupState ? 0x10 : 0);1296 const authenticatorData = await buildAuthenticatorData({1297 rpId: credential.rpId,1298 flags,1299 signCount: nextSignCount,1300 });13011302 1303 1304 1305 const clientDataBytes = new Uint8Array(payload.clientDataJSON);1306 const clientDataHash = new Uint8Array(1307 await crypto.subtle.digest("SHA-256", clientDataBytes)1308 );1309 const signature = await signPasskeyAssertion(1310 credential.privateKeyJwk,1311 credential.algorithm,1312 authenticatorData,1313 clientDataHash1314 );13151316 await vault.updateEntry(match.id, {1317 passkey: {1318 ...credential,1319 signCount: nextSignCount,1320 lastUsedAt: Date.now(),1321 },1322 });13231324 return {1325 ok: true,1326 credential: {1327 type: "get",1328 id: credential.credentialId,1329 clientDataJSON: payload.clientDataJSON,1330 authenticatorData: base64urlEncode(authenticatorData),1331 signature: base64urlEncode(signature),1332 userHandle: credential.userHandle,1333 },1334 };1335}13361337export async function setupPasskeyProxy(): Promise<void> {1338 if (!isPasskeySupported()) {1339 console.log("[Peach passkey] setupPasskeyProxy: not supported on this browser");1340 return;1341 }13421343 1344 1345 if (detectSharedBrowser() === "firefox") {1346 await refreshPasskeyProxy();1347 return;1348 }13491350 if (!listenersRegistered) {1351 console.log("[Peach passkey] registering webAuthenticationProxy listeners");1352 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1353 1354 1355 1356 1357 1358 1359 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1360 requestId: requestInfo.requestId,1361 isUvpaa: true,1362 });1363 });1364 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1365 console.log("[Peach passkey] onCreateRequest fired", { requestId: requestInfo.requestId });1366 void handleCreateRequest(requestInfo).catch(async (error) => {1367 1368 1369 1370 if (isInvalidRequestIdError(error)) {1371 console.log("[Peach passkey] create handler resolved after cancel", { requestId: requestInfo.requestId });1372 return;1373 }1374 console.error("Peach passkey create failed", error);1375 await rejectCreate(1376 requestInfo.requestId,1377 "UnknownError",1378 error instanceof Error ? error.message : "Passkey creation failed."1379 );1380 });1381 });1382 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1383 console.log("[Peach passkey] onGetRequest fired", { requestId: requestInfo.requestId });1384 void handleGetRequest(requestInfo).catch(async (error) => {1385 if (isInvalidRequestIdError(error)) {1386 console.log("[Peach passkey] get handler resolved after cancel", { requestId: requestInfo.requestId });1387 return;1388 }1389 console.error("Peach passkey get failed", error);1390 await rejectGet(1391 requestInfo.requestId,1392 "UnknownError",1393 error instanceof Error ? error.message : "Passkey authentication failed."1394 );1395 });1396 });1397 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1398 console.log("[Peach passkey] onRequestCanceled", { requestId });1399 markCancelled(requestId);1400 1401 const waiter = unlockWaiters.get(requestId);1402 if (waiter) {1403 clearTimeout(waiter.timeoutId);1404 waiter.resolve(false);1405 unlockWaiters.delete(requestId);1406 }1407 void resolvePendingPasskeyRequest(requestId, false);1408 });1409 listenersRegistered = true;1410 }14111412 await refreshPasskeyProxy();1413}