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}194195async function rejectCreate(196 requestId: number,197 name: string,198 message = ""199): Promise<void> {200 try {201 await chrome.webAuthenticationProxy.completeCreateRequest({202 requestId,203 error: { name, message },204 });205 } catch (err) {206 if (isInvalidRequestIdError(err)) {207 console.log("[Peach passkey] completeCreateRequest skipped (already cancelled)", { requestId });208 return;209 }210 throw err;211 }212}213214async function rejectGet(215 requestId: number,216 name: string,217 message = ""218): Promise<void> {219 try {220 await chrome.webAuthenticationProxy.completeGetRequest({221 requestId,222 error: { name, message },223 });224 } catch (err) {225 if (isInvalidRequestIdError(err)) {226 console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId });227 return;228 }229 throw err;230 }231}232233export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {234 if (!chrome.storage.session) return null; 235 const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;236 const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));237 if (!key) return null;238 const pending = stored[key];239 return pending && typeof pending === "object"240 ? (pending as PendingPasskeyRequest)241 : null;242}243244export async function resolvePendingPasskeyRequest(245 requestId: number,246 approved: boolean247): Promise<void> {248 const pending = approvals.get(requestId);249 if (!pending) {250 251 252 253 254 await clearPendingRequest(requestId);255 if (chrome.storage.session) {256 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);257 }258 return;259 }260261 clearTimeout(pending.timeoutId);262 approvals.delete(requestId);263 await clearPendingRequest(requestId);264 pending.resolve(approved);265}266267async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {268 if (chrome.storage.session) {269 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });270 }271 await openApprovalPopup();272273 return new Promise<boolean>((resolve) => {274 const timeoutId = setTimeout(async () => {275 approvals.delete(request.requestId);276 await clearPendingRequest(request.requestId);277 resolve(false);278 }, APPROVAL_TIMEOUT_MS);279280 approvals.set(request.requestId, { resolve, timeoutId });281 });282}283284export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {285 const settings = await vault.ensureSettingsLoaded();286 const supported = isPasskeySupported();287 const enabled = settings.passkeyProviderEnabled === true;288 return {289 supported,290 enabled,291 attached: supported && attached,292 browserLabel: getBrowserLabel(),293 statusLabel: !supported294 ? "Interception unavailable"295 : attached296 ? "Active"297 : conflict298 ? "Another provider is active"299 : enabled300 ? "Inactive"301 : "Disabled",302 conflict,303 };304}305306async function attachPasskeyProxy(): Promise<void> {307 if (!isPasskeySupported()) {308 attached = false;309 conflict = "This browser does not expose chrome.webAuthenticationProxy.";310 return;311 }312313 314 315 316 if (detectSharedBrowser() === "firefox") {317 attached = true;318 conflict = undefined;319 return;320 }321322 if (attached) return;323324 try {325 await chrome.webAuthenticationProxy.attach();326 attached = true;327 conflict = undefined;328 console.log("[Peach passkey] proxy attached successfully");329 } catch (error) {330 attached = false;331 conflict = error instanceof Error ? error.message : "Another provider is active.";332 console.warn("[Peach passkey] proxy attach failed", { conflict });333 }334}335336async function detachPasskeyProxy(): Promise<void> {337 if (!isPasskeySupported()) {338 attached = false;339 return;340 }341342 if (detectSharedBrowser() === "firefox") {343 attached = false;344 conflict = undefined;345 return;346 }347348 try {349 await chrome.webAuthenticationProxy.detach();350 } catch {351 352 }353354 attached = false;355 conflict = undefined;356}357358export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {359 await vault.updateSettings({ passkeyProviderEnabled: enabled });360 if (enabled) {361 await attachPasskeyProxy();362 } else {363 await detachPasskeyProxy();364 }365 return getPasskeyProviderState();366}367368async function refreshPasskeyProxy(): Promise<void> {369 const settings = await vault.ensureSettingsLoaded();370 if (settings.passkeyProviderEnabled === true) {371 await attachPasskeyProxy();372 return;373 }374 await detachPasskeyProxy();375}376377378379380381382383384function findAttachTarget(rpId: string): VaultEntry | null {385 const all = vault.getEntries();386 const passkeyMatch = all.find(387 (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt388 );389 if (passkeyMatch) return passkeyMatch;390 const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);391 if (loginMatches.length === 0) return null;392 393 return loginMatches.reduce((best, e) =>394 (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best395 );396}397398async function handleCreateRequest(399 request: chrome.webAuthenticationProxy.CreateRequest400): Promise<void> {401 console.log("[Peach passkey] create event received", {402 requestId: request.requestId,403 detailsJson: request.requestDetailsJson,404 });405 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;406 const rpId = options.rp.id;407 const rpName = options.rp.name ?? options.rp.id;408 const userName = options.user.name;409 const userDisplayName = options.user.displayName ?? options.user.name;410 console.log("[Peach passkey] create parsed", { rpId, rpName, userName, unlocked: vault.isUnlocked() });411412 if (!rpId) {413 console.warn("[Peach passkey] reject: missing rp.id");414 await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");415 return;416 }417418 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {419 console.warn("[Peach passkey] reject: user is not Pro");420 await rejectCreate(421 request.requestId,422 "NotAllowedError",423 "Passkey creation requires Peach Pro."424 );425 426 broadcastPasskeyProGate();427 return;428 }429430 if (!vault.isUnlocked()) {431 console.log("[Peach passkey] vault locked, summoning inline unlock");432 433 434 435 436 437 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>438 chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)439 );440 const activeTab = tabs[0];441 console.log("[Peach passkey] active tab for unlock summon", { id: activeTab?.id, url: activeTab?.url });442 if (activeTab?.id) {443 chrome.tabs.sendMessage(activeTab.id, {444 type: 'SUMMON_AUTOFILL',445 source: 'passkey-unlock',446 }).catch((err) => {447 console.warn("[Peach passkey] SUMMON_AUTOFILL failed, falling back to popup", err);448 void openApprovalPopup();449 });450 } else {451 await openApprovalPopup();452 }453454 const unlocked = await new Promise<boolean>((resolve) => {455 const timeoutId = setTimeout(() => {456 unlockWaiters.delete(request.requestId);457 resolve(false);458 }, APPROVAL_TIMEOUT_MS);459 unlockWaiters.set(request.requestId, { resolve, timeoutId });460 });461462 console.log("[Peach passkey] unlock wait resolved", { unlocked, vaultUnlocked: vault.isUnlocked() });463 if (!unlocked || !vault.isUnlocked()) {464 console.warn("[Peach passkey] reject: unlock timeout");465 await rejectCreate(request.requestId, "NotAllowedError", "Not unlocked in time.");466 return;467 }468 469 }470471 console.log("[Peach passkey] requesting approval via popup");472 const approved = await promptUserApproval({473 requestId: request.requestId,474 type: "create",475 rpId,476 rpName,477 userName,478 userDisplayName,479 });480 console.log("[Peach passkey] approval resolved", { approved });481 if (!approved) {482 console.warn("[Peach passkey] reject: user denied");483 await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");484 return;485 }486487 const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);488 if (!algorithm) {489 console.warn("[Peach passkey] reject: no supported algorithm", options.pubKeyCredParams);490 await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");491 return;492 }493 console.log("[Peach passkey] algorithm selected", algorithm);494495 const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));496 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(497 algorithm498 );499 const authData = await buildAuthenticatorData({500 rpId,501 flags: 0x4d,502 signCount: 0,503 credentialId: credentialIdBytes,504 publicKeyCose,505 });506 const attestationObject = createAttestationObject(authData);507 const clientDataJSON = buildClientDataJSON(508 "webauthn.create",509 options.challenge,510 await resolvePageOrigin(rpId)511 );512513 const passkey: PasskeyData = {514 rpId,515 rpName,516 credentialId: base64urlEncode(credentialIdBytes),517 publicKeyCose: base64urlEncode(publicKeyCose),518 privateKeyJwk,519 userHandle: options.user.id,520 userName,521 userDisplayName,522 algorithm,523 signCount: 0,524 aaguid: base64urlEncode(new Uint8Array(16)),525 backupEligible: false,526 backupState: false,527 browser: "chrome",528 transports: ["internal"],529 createdAt: Date.now(),530 };531532 533 534 const target = findAttachTarget(rpId);535 if (target) {536 await vault.updateEntry(target.id, { passkey });537 } else {538 await vault.addEntry({539 type: "login",540 title: rpName,541 url: `https://${rpId}`,542 username: userName,543 passkey,544 });545 }546547 try {548 await chrome.webAuthenticationProxy.completeCreateRequest({549 requestId: request.requestId,550 responseJson: JSON.stringify({551 id: passkey.credentialId,552 rawId: passkey.credentialId,553 type: "public-key",554 response: {555 clientDataJSON: base64urlEncode(clientDataJSON),556 authenticatorData: base64urlEncode(authData),557 attestationObject: base64urlEncode(attestationObject),558 transports: ["internal"],559 publicKey: base64urlEncode(publicKeySpki),560 publicKeyAlgorithm: algorithm,561 },562 authenticatorAttachment: "platform",563 clientExtensionResults: {},564 }),565 });566 console.log("[Peach passkey] completeCreateRequest success", { requestId: request.requestId });567 } catch (err) {568 if (isInvalidRequestIdError(err)) {569 console.log("[Peach passkey] completeCreateRequest skipped (already cancelled)", { requestId: request.requestId });570 return;571 }572 throw err;573 }574}575576function broadcastPasskeyProGate(): void {577 chrome.tabs.query({}, (tabs) => {578 for (const tab of tabs) {579 if (tab.id !== undefined) {580 chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});581 }582 }583 });584}585586async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {587 if (chrome.storage.session) {588 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });589 }590591 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {592 chrome.tabs.query({ active: true, currentWindow: true }, resolve);593 });594 const tab = tabs[0];595596 if (tab?.id) {597 chrome.tabs.sendMessage(tab.id, {598 type: 'SHOW_PASSKEY_TOAST',599 payload: {600 site: request.rpId,601 displayName: request.userDisplayName ?? request.userName,602 username: request.userName,603 requestId: String(request.requestId),604 },605 }).catch(() => {});606 } else {607 await openApprovalPopup();608 }609610 return new Promise<boolean>((resolve) => {611 const timeoutId = setTimeout(async () => {612 approvals.delete(request.requestId);613 await clearPendingRequest(request.requestId);614 resolve(false);615 }, APPROVAL_TIMEOUT_MS);616617 approvals.set(request.requestId, { resolve, timeoutId });618 });619}620621async function handleGetRequest(622 request: chrome.webAuthenticationProxy.GetRequest623): Promise<void> {624 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;625 if (!vault.isUnlocked()) {626 627 628 629 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>630 chrome.tabs.query({ active: true, currentWindow: true }, resolve)631 );632 const activeTab = tabs[0];633 if (activeTab?.id) {634 chrome.tabs.sendMessage(activeTab.id, {635 type: 'SUMMON_AUTOFILL',636 source: 'passkey-unlock',637 }).catch(() => { void openApprovalPopup(); });638 } else {639 await openApprovalPopup();640 }641642 643 const unlocked = await new Promise<boolean>((resolve) => {644 const timeoutId = setTimeout(() => {645 unlockWaiters.delete(request.requestId);646 resolve(false);647 }, APPROVAL_TIMEOUT_MS);648 unlockWaiters.set(request.requestId, { resolve, timeoutId });649 });650651 if (!unlocked || !vault.isUnlocked()) {652 await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");653 return;654 }655 656 }657658 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {659 await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");660 broadcastPasskeyProGate();661 return;662 }663664 const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>665 entry.passkey !== undefined && !entry.deletedAt666 );667 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);668669 const match = passkeyBearing.find((entry) => {670 if (allowedCredentialIds.length > 0) {671 return allowedCredentialIds.includes(entry.passkey.credentialId);672 }673 if (!options.rpId) return false;674 return entry.passkey.rpId === options.rpId;675 });676677 if (!match) {678 await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");679 return;680 }681682 const credential = match.passkey;683 684 685 686 687 if (!credential.privateKeyJwk) {688 await rejectGet(689 request.requestId,690 "NotAllowedError",691 "This passkey was created on Firefox; sign in via the platform authenticator."692 );693 return;694 }695696 const approved = await promptViaToast({697 requestId: request.requestId,698 type: "get",699 rpId: credential.rpId,700 rpName: credential.rpName,701 userName: credential.userName,702 userDisplayName: credential.userDisplayName,703 });704 if (!approved) {705 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");706 return;707 }708709 const nextSignCount = credential.signCount + 1;710 const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);711 const authData = await buildAuthenticatorData({712 rpId: credential.rpId,713 flags,714 signCount: nextSignCount,715 });716 const clientDataJSON = buildClientDataJSON(717 "webauthn.get",718 options.challenge,719 await resolvePageOrigin(credential.rpId)720 );721 const clientDataHash = new Uint8Array(722 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))723 );724 const signature = await signPasskeyAssertion(725 credential.privateKeyJwk,726 credential.algorithm,727 authData,728 clientDataHash729 );730731 await vault.updateEntry(match.id, {732 passkey: {733 ...credential,734 signCount: nextSignCount,735 lastUsedAt: Date.now(),736 },737 });738739 const responseJson = JSON.stringify({740 id: credential.credentialId,741 rawId: credential.credentialId,742 type: "public-key",743 response: {744 clientDataJSON: base64urlEncode(clientDataJSON),745 authenticatorData: base64urlEncode(authData),746 signature: base64urlEncode(signature),747 userHandle: credential.userHandle,748 },749 authenticatorAttachment: "platform",750 clientExtensionResults: {},751 });752753 try {754 await chrome.webAuthenticationProxy.completeGetRequest({755 requestId: request.requestId,756 responseJson,757 });758 console.log("[Peach passkey] completeGetRequest success", { requestId: request.requestId });759 } catch (err) {760 if (isInvalidRequestIdError(err)) {761 console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId: request.requestId });762 return;763 }764 throw err;765 }766}767768769770771772773774775776777const FIREFOX_PENDING_KEY = "passkey_pending_firefox";778const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";779const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; 780781export async function initiateFirefoxPasskeyCreate(args: {782 rpId: string;783 rpName: string;784 userName: string;785 userDisplayName?: string;786}): Promise<{ ok: boolean; error?: string }> {787 if (!(await isProUser())) {788 broadcastPasskeyProGate();789 return { ok: false, error: "passkey creation requires Peach Pro" };790 }791 if (!vault.isUnlocked()) {792 await openApprovalPopup();793 return { ok: false, error: "vault_locked" };794 }795 if (!chrome.storage.session) {796 return { ok: false, error: "session storage unavailable" };797 }798799 800 801 802 const requestId = Date.now() + Math.floor(Math.random() * 1_000_000);803 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));804 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));805806 const pending: PendingPasskeyRequest = {807 requestId,808 type: "create",809 rpId: args.rpId,810 rpName: args.rpName,811 userName: args.userName,812 userDisplayName: args.userDisplayName,813 browser: "firefox",814 challenge,815 userHandle,816 };817818 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });819 await openApprovalPopup();820 return { ok: true };821}822823export async function completeFirefoxPasskeyCreate(payload: {824 requestId: number;825 rpId: string;826 rpName: string;827 userName: string;828 userDisplayName?: string;829 userHandle: string;830 credentialId: string;831 publicKeyCose: string;832 algorithm: -7 | -8;833 aaguid?: string;834 transports?: string[];835 backupEligible?: boolean;836 backupState?: boolean;837}): Promise<{ ok: boolean; error?: string }> {838 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };839840 const passkey: PasskeyData = {841 rpId: payload.rpId,842 rpName: payload.rpName,843 credentialId: payload.credentialId,844 publicKeyCose: payload.publicKeyCose,845 userHandle: payload.userHandle,846 userName: payload.userName,847 userDisplayName: payload.userDisplayName ?? payload.userName,848 algorithm: payload.algorithm,849 signCount: 0,850 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),851 backupEligible: payload.backupEligible ?? false,852 backupState: payload.backupState ?? false,853 browser: "firefox",854 transports: payload.transports ?? ["internal"],855 createdAt: Date.now(),856 };857858 859 860 861 const target = findAttachTarget(payload.rpId);862 if (target) {863 await vault.updateEntry(target.id, { passkey });864 } else {865 await vault.addEntry({866 type: "login",867 title: payload.rpName || payload.rpId,868 url: `https://${payload.rpId}`,869 username: payload.userName,870 passkey,871 });872 }873874 if (chrome.storage.session) {875 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);876 }877 return { ok: true };878}879880export async function cancelFirefoxPasskeyCreate(): Promise<void> {881 if (chrome.storage.session) {882 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);883 }884}885886function declineKey(domain: string): string {887 return `${FIREFOX_DECLINE_PREFIX}${domain}`;888}889890export async function recordPasskeyOfferDecline(domain: string): Promise<void> {891 if (!domain) return;892 await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });893}894895export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {896 if (!domain) return false;897 const key = declineKey(domain);898 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;899 const at = stored[key];900 if (typeof at !== "number") return false;901 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {902 await chrome.storage.local.remove(key);903 return false;904 }905 return true;906}907908909910911912913914915916917918919920921interface FirefoxAllowCredential {922 id: string;923 type?: string;924}925926interface FirefoxPubKeyCredParam {927 type?: string;928 alg: number;929}930931export interface FirefoxPasskeyHasCredentialsPayload {932 rpId: string;933 allowCredentials: FirefoxAllowCredential[];934}935936export interface FirefoxPasskeyCreatePayload {937 rpId: string;938 rpName?: string;939 userId: string;940 userName: string;941 userDisplayName?: string;942 challenge: string;943 clientDataJSON: number[];944 origin: string;945 pubKeyCredParams?: FirefoxPubKeyCredParam[];946 authenticatorSelection?: Record<string, unknown>;947 timeout?: number;948 attestation?: string;949}950951export interface FirefoxPasskeyGetPayload {952 rpId: string;953 challenge: string;954 clientDataJSON: number[];955 origin: string;956 allowCredentials: FirefoxAllowCredential[];957 userVerification?: string;958}959960interface FirefoxCreateCredentialResponse {961 type: "create";962 id: string;963 clientDataJSON: number[];964 attestationObject: string;965 authenticatorData: string;966 publicKey: string;967 algorithm: -7 | -8;968}969970interface FirefoxGetCredentialResponse {971 type: "get";972 id: string;973 clientDataJSON: number[];974 authenticatorData: string;975 signature: string;976 userHandle: string;977}978979type FirefoxPasskeyResult<T> =980 | { ok: true; credential: T }981 | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };982983function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {984 return vault.getEntries().filter(985 (entry): entry is VaultEntry & { passkey: PasskeyData } =>986 entry.passkey !== undefined && !entry.deletedAt987 );988}989990991function clientDataJsonOrigin(bytes: number[] | undefined): string | null {992 if (!Array.isArray(bytes)) return null;993 try {994 const obj = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes))) as { origin?: unknown };995 return typeof obj.origin === "string" ? obj.origin : null;996 } catch {997 return null;998 }999}100010011002100310041005100610071008100910101011function assertOriginOwnsRpId(1012 rpId: string,1013 callerOrigin: string | undefined,1014 clientDataJSON?: number[],1015): void {1016 if (!callerOrigin || !isValidRpId(rpId, callerOrigin)) {1017 throw new DOMException(1018 `SecurityError: relying party ID "${rpId}" is not a registrable domain ` +1019 `suffix of the calling origin "${callerOrigin ?? "(unknown)"}".`,1020 "SecurityError",1021 );1022 }1023 const cdjOrigin = clientDataJsonOrigin(clientDataJSON);1024 if (cdjOrigin !== null && cdjOrigin !== callerOrigin) {1025 throw new DOMException(1026 `SecurityError: clientDataJSON origin "${cdjOrigin}" does not match the ` +1027 `attested calling origin "${callerOrigin}".`,1028 "SecurityError",1029 );1030 }1031}10321033export async function handlePasskeyHasCredentials(1034 payload: FirefoxPasskeyHasCredentialsPayload,1035 callerOrigin?: string,1036): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {1037 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1038 return { ok: false, error: "not_applicable" };1039 }1040 1041 1042 1043 if (!callerOrigin || !isValidRpId(payload.rpId, callerOrigin)) {1044 return { ok: true, hasCredentials: false };1045 }1046 if (!vault.isUnlocked()) {1047 1048 1049 1050 return { ok: true, hasCredentials: false };1051 }1052 const passkeys = listPasskeyEntries().filter(1053 (entry) => entry.passkey.rpId === payload.rpId1054 );1055 if (passkeys.length === 0) return { ok: true, hasCredentials: false };10561057 const allow = payload.allowCredentials ?? [];1058 if (allow.length === 0) return { ok: true, hasCredentials: true };10591060 const hasMatch = passkeys.some((entry) =>1061 allow.some((ac) => ac.id === entry.passkey.credentialId)1062 );1063 return { ok: true, hasCredentials: hasMatch };1064}106510661067106810691070107110721073async function requestFirefoxApproval(args: {1074 type: "create" | "get";1075 rpId: string;1076 rpName?: string;1077 userName: string;1078 userDisplayName?: string;1079}): Promise<boolean> {1080 1081 1082 const requestId = Date.now() + Math.floor(Math.random() * 1000);1083 return promptUserApproval({1084 requestId,1085 type: args.type,1086 rpId: args.rpId,1087 rpName: args.rpName ?? args.rpId,1088 userName: args.userName,1089 userDisplayName: args.userDisplayName,1090 });1091}10921093export async function handleFirefoxPasskeyCreate(1094 payload: FirefoxPasskeyCreatePayload,1095 callerOrigin?: string,1096): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {1097 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1098 return { ok: false, error: "not_applicable" };1099 }1100 1101 1102 1103 1104 1105 assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1106 if (!(await isProUser())) {1107 broadcastPasskeyProGate();1108 return { ok: false, error: "pro_required" };1109 }1110 if (!vault.isUnlocked()) {1111 await openApprovalPopup();1112 return { ok: false, error: "vault_locked" };1113 }11141115 const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);1116 if (!algorithm) {1117 return { ok: false, error: "internal_error", message: "No supported algorithm offered" };1118 }11191120 const approved = await requestFirefoxApproval({1121 type: "create",1122 rpId: payload.rpId,1123 rpName: payload.rpName,1124 userName: payload.userName,1125 userDisplayName: payload.userDisplayName,1126 });1127 if (!approved) return { ok: false, error: "user_cancelled" };11281129 const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1130 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);11311132 1133 1134 const authenticatorData = await buildAuthenticatorData({1135 rpId: payload.rpId,1136 flags: 0x45,1137 signCount: 0,1138 credentialId: credentialIdBytes,1139 publicKeyCose,1140 });1141 const attestationObject = createAttestationObject(authenticatorData);11421143 const passkey: PasskeyData = {1144 rpId: payload.rpId,1145 rpName: payload.rpName ?? payload.rpId,1146 credentialId,1147 publicKeyCose: base64urlEncode(publicKeyCose),1148 privateKeyJwk,1149 userHandle: payload.userId,1150 userName: payload.userName,1151 userDisplayName: payload.userDisplayName ?? payload.userName,1152 algorithm,1153 signCount: 0,1154 aaguid: base64urlEncode(new Uint8Array(16)),1155 backupEligible: false,1156 backupState: false,1157 browser: "firefox",1158 transports: ["internal"],1159 createdAt: Date.now(),1160 };11611162 const target = findAttachTarget(payload.rpId);1163 if (target) {1164 await vault.updateEntry(target.id, { passkey });1165 } else {1166 await vault.addEntry({1167 type: "login",1168 title: payload.rpName || payload.rpId,1169 url: `https://${payload.rpId}`,1170 username: payload.userName,1171 passkey,1172 });1173 }11741175 return {1176 ok: true,1177 credential: {1178 type: "create",1179 id: credentialId,1180 1181 1182 1183 clientDataJSON: payload.clientDataJSON,1184 attestationObject: base64urlEncode(attestationObject),1185 authenticatorData: base64urlEncode(authenticatorData),1186 publicKey: base64urlEncode(publicKeySpki),1187 algorithm,1188 },1189 };1190}11911192export async function handleFirefoxPasskeyGet(1193 payload: FirefoxPasskeyGetPayload,1194 callerOrigin?: string,1195): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1196 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1197 return { ok: false, error: "not_applicable" };1198 }1199 1200 1201 1202 1203 1204 1205 assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1206 if (!(await isProUser())) {1207 broadcastPasskeyProGate();1208 return { ok: false, error: "pro_required" };1209 }1210 if (!vault.isUnlocked()) {1211 await openApprovalPopup();1212 return { ok: false, error: "vault_locked" };1213 }12141215 const allow = payload.allowCredentials ?? [];1216 const candidates = listPasskeyEntries().filter((entry) => {1217 if (entry.passkey.rpId !== payload.rpId) return false;1218 if (allow.length === 0) return true;1219 return allow.some((ac) => ac.id === entry.passkey.credentialId);1220 });12211222 if (candidates.length === 0) return { ok: false, error: "no_credentials" };12231224 1225 1226 1227 candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1228 const match = candidates[0]!;1229 const credential = match.passkey;12301231 if (!credential.privateKeyJwk) {1232 1233 1234 1235 return { ok: false, error: "no_credentials" };1236 }12371238 const approved = await requestFirefoxApproval({1239 type: "get",1240 rpId: credential.rpId,1241 rpName: credential.rpName,1242 userName: credential.userName,1243 userDisplayName: credential.userDisplayName,1244 });1245 if (!approved) return { ok: false, error: "user_cancelled" };12461247 const nextSignCount = credential.signCount + 1;1248 1249 1250 const flags =1251 0x05 |1252 (credential.backupEligible ? 0x08 : 0) |1253 (credential.backupState ? 0x10 : 0);1254 const authenticatorData = await buildAuthenticatorData({1255 rpId: credential.rpId,1256 flags,1257 signCount: nextSignCount,1258 });12591260 1261 1262 1263 const clientDataBytes = new Uint8Array(payload.clientDataJSON);1264 const clientDataHash = new Uint8Array(1265 await crypto.subtle.digest("SHA-256", clientDataBytes)1266 );1267 const signature = await signPasskeyAssertion(1268 credential.privateKeyJwk,1269 credential.algorithm,1270 authenticatorData,1271 clientDataHash1272 );12731274 await vault.updateEntry(match.id, {1275 passkey: {1276 ...credential,1277 signCount: nextSignCount,1278 lastUsedAt: Date.now(),1279 },1280 });12811282 return {1283 ok: true,1284 credential: {1285 type: "get",1286 id: credential.credentialId,1287 clientDataJSON: payload.clientDataJSON,1288 authenticatorData: base64urlEncode(authenticatorData),1289 signature: base64urlEncode(signature),1290 userHandle: credential.userHandle,1291 },1292 };1293}12941295export async function setupPasskeyProxy(): Promise<void> {1296 if (!isPasskeySupported()) {1297 console.log("[Peach passkey] setupPasskeyProxy: not supported on this browser");1298 return;1299 }13001301 1302 1303 if (detectSharedBrowser() === "firefox") {1304 await refreshPasskeyProxy();1305 return;1306 }13071308 if (!listenersRegistered) {1309 console.log("[Peach passkey] registering webAuthenticationProxy listeners");1310 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1311 1312 1313 1314 1315 1316 1317 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1318 requestId: requestInfo.requestId,1319 isUvpaa: true,1320 });1321 });1322 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1323 console.log("[Peach passkey] onCreateRequest fired", { requestId: requestInfo.requestId });1324 void handleCreateRequest(requestInfo).catch(async (error) => {1325 1326 1327 1328 if (isInvalidRequestIdError(error)) {1329 console.log("[Peach passkey] create handler resolved after cancel", { requestId: requestInfo.requestId });1330 return;1331 }1332 console.error("Peach passkey create failed", error);1333 await rejectCreate(1334 requestInfo.requestId,1335 "UnknownError",1336 error instanceof Error ? error.message : "Passkey creation failed."1337 );1338 });1339 });1340 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1341 console.log("[Peach passkey] onGetRequest fired", { requestId: requestInfo.requestId });1342 void handleGetRequest(requestInfo).catch(async (error) => {1343 if (isInvalidRequestIdError(error)) {1344 console.log("[Peach passkey] get handler resolved after cancel", { requestId: requestInfo.requestId });1345 return;1346 }1347 console.error("Peach passkey get failed", error);1348 await rejectGet(1349 requestInfo.requestId,1350 "UnknownError",1351 error instanceof Error ? error.message : "Passkey authentication failed."1352 );1353 });1354 });1355 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1356 console.log("[Peach passkey] onRequestCanceled", { requestId });1357 markCancelled(requestId);1358 1359 const waiter = unlockWaiters.get(requestId);1360 if (waiter) {1361 clearTimeout(waiter.timeoutId);1362 waiter.resolve(false);1363 unlockWaiters.delete(requestId);1364 }1365 void resolvePendingPasskeyRequest(requestId, false);1366 });1367 listenersRegistered = true;1368 }13691370 await refreshPasskeyProxy();1371}