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 679 680 681 682 683 684 console.log("[Peach passkey] no matching credential — passing through to platform", { requestId: request.requestId });685 try {686 await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });687 } catch (err) {688 if (isInvalidRequestIdError(err)) return;689 throw err;690 }691 return;692 }693694 const credential = match.passkey;695 696 697 698 if (!credential.privateKeyJwk) {699 console.log("[Peach passkey] no private key (Firefox passkey) — passing through to platform", { requestId: request.requestId });700 try {701 await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });702 } catch (err) {703 if (isInvalidRequestIdError(err)) return;704 throw err;705 }706 return;707 }708709 const approved = await promptViaToast({710 requestId: request.requestId,711 type: "get",712 rpId: credential.rpId,713 rpName: credential.rpName,714 userName: credential.userName,715 userDisplayName: credential.userDisplayName,716 });717 if (!approved) {718 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");719 return;720 }721722 const nextSignCount = credential.signCount + 1;723 const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);724 const authData = await buildAuthenticatorData({725 rpId: credential.rpId,726 flags,727 signCount: nextSignCount,728 });729 const clientDataJSON = buildClientDataJSON(730 "webauthn.get",731 options.challenge,732 await resolvePageOrigin(credential.rpId)733 );734 const clientDataHash = new Uint8Array(735 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))736 );737 const signature = await signPasskeyAssertion(738 credential.privateKeyJwk,739 credential.algorithm,740 authData,741 clientDataHash742 );743744 await vault.updateEntry(match.id, {745 passkey: {746 ...credential,747 signCount: nextSignCount,748 lastUsedAt: Date.now(),749 },750 });751752 const responseJson = JSON.stringify({753 id: credential.credentialId,754 rawId: credential.credentialId,755 type: "public-key",756 response: {757 clientDataJSON: base64urlEncode(clientDataJSON),758 authenticatorData: base64urlEncode(authData),759 signature: base64urlEncode(signature),760 userHandle: credential.userHandle,761 },762 authenticatorAttachment: "platform",763 clientExtensionResults: {},764 });765766 try {767 await chrome.webAuthenticationProxy.completeGetRequest({768 requestId: request.requestId,769 responseJson,770 });771 console.log("[Peach passkey] completeGetRequest success", { requestId: request.requestId });772 } catch (err) {773 if (isInvalidRequestIdError(err)) {774 console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId: request.requestId });775 return;776 }777 throw err;778 }779}780781782783784785786787788789790const FIREFOX_PENDING_KEY = "passkey_pending_firefox";791const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";792const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; 793794export async function initiateFirefoxPasskeyCreate(args: {795 rpId: string;796 rpName: string;797 userName: string;798 userDisplayName?: string;799}): Promise<{ ok: boolean; error?: string }> {800 if (!(await isProUser())) {801 broadcastPasskeyProGate();802 return { ok: false, error: "passkey creation requires Peach Pro" };803 }804 if (!vault.isUnlocked()) {805 await openApprovalPopup();806 return { ok: false, error: "vault_locked" };807 }808 if (!chrome.storage.session) {809 return { ok: false, error: "session storage unavailable" };810 }811812 813 814 815 const requestId = Date.now() + Math.floor(Math.random() * 1_000_000);816 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));817 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));818819 const pending: PendingPasskeyRequest = {820 requestId,821 type: "create",822 rpId: args.rpId,823 rpName: args.rpName,824 userName: args.userName,825 userDisplayName: args.userDisplayName,826 browser: "firefox",827 challenge,828 userHandle,829 };830831 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });832 await openApprovalPopup();833 return { ok: true };834}835836export async function completeFirefoxPasskeyCreate(payload: {837 requestId: number;838 rpId: string;839 rpName: string;840 userName: string;841 userDisplayName?: string;842 userHandle: string;843 credentialId: string;844 publicKeyCose: string;845 algorithm: -7 | -8;846 aaguid?: string;847 transports?: string[];848 backupEligible?: boolean;849 backupState?: boolean;850}): Promise<{ ok: boolean; error?: string }> {851 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };852853 const passkey: PasskeyData = {854 rpId: payload.rpId,855 rpName: payload.rpName,856 credentialId: payload.credentialId,857 publicKeyCose: payload.publicKeyCose,858 userHandle: payload.userHandle,859 userName: payload.userName,860 userDisplayName: payload.userDisplayName ?? payload.userName,861 algorithm: payload.algorithm,862 signCount: 0,863 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),864 backupEligible: payload.backupEligible ?? false,865 backupState: payload.backupState ?? false,866 browser: "firefox",867 transports: payload.transports ?? ["internal"],868 createdAt: Date.now(),869 };870871 872 873 874 const target = findAttachTarget(payload.rpId);875 if (target) {876 await vault.updateEntry(target.id, { passkey });877 } else {878 await vault.addEntry({879 type: "login",880 title: payload.rpName || payload.rpId,881 url: `https://${payload.rpId}`,882 username: payload.userName,883 passkey,884 });885 }886887 if (chrome.storage.session) {888 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);889 }890 return { ok: true };891}892893export async function cancelFirefoxPasskeyCreate(): Promise<void> {894 if (chrome.storage.session) {895 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);896 }897}898899function declineKey(domain: string): string {900 return `${FIREFOX_DECLINE_PREFIX}${domain}`;901}902903export async function recordPasskeyOfferDecline(domain: string): Promise<void> {904 if (!domain) return;905 await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });906}907908export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {909 if (!domain) return false;910 const key = declineKey(domain);911 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;912 const at = stored[key];913 if (typeof at !== "number") return false;914 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {915 await chrome.storage.local.remove(key);916 return false;917 }918 return true;919}920921922923924925926927928929930931932933934interface FirefoxAllowCredential {935 id: string;936 type?: string;937}938939interface FirefoxPubKeyCredParam {940 type?: string;941 alg: number;942}943944export interface FirefoxPasskeyHasCredentialsPayload {945 rpId: string;946 allowCredentials: FirefoxAllowCredential[];947}948949export interface FirefoxPasskeyCreatePayload {950 rpId: string;951 rpName?: string;952 userId: string;953 userName: string;954 userDisplayName?: string;955 challenge: string;956 clientDataJSON: number[];957 origin: string;958 pubKeyCredParams?: FirefoxPubKeyCredParam[];959 authenticatorSelection?: Record<string, unknown>;960 timeout?: number;961 attestation?: string;962}963964export interface FirefoxPasskeyGetPayload {965 rpId: string;966 challenge: string;967 clientDataJSON: number[];968 origin: string;969 allowCredentials: FirefoxAllowCredential[];970 userVerification?: string;971}972973interface FirefoxCreateCredentialResponse {974 type: "create";975 id: string;976 clientDataJSON: number[];977 attestationObject: string;978 authenticatorData: string;979 publicKey: string;980 algorithm: -7 | -8;981}982983interface FirefoxGetCredentialResponse {984 type: "get";985 id: string;986 clientDataJSON: number[];987 authenticatorData: string;988 signature: string;989 userHandle: string;990}991992type FirefoxPasskeyResult<T> =993 | { ok: true; credential: T }994 | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };995996function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {997 return vault.getEntries().filter(998 (entry): entry is VaultEntry & { passkey: PasskeyData } =>999 entry.passkey !== undefined && !entry.deletedAt1000 );1001}100210031004function clientDataJsonOrigin(bytes: number[] | undefined): string | null {1005 if (!Array.isArray(bytes)) return null;1006 try {1007 const obj = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes))) as { origin?: unknown };1008 return typeof obj.origin === "string" ? obj.origin : null;1009 } catch {1010 return null;1011 }1012}101310141015101610171018101910201021102210231024function assertOriginOwnsRpId(1025 rpId: string,1026 callerOrigin: string | undefined,1027 clientDataJSON?: number[],1028): void {1029 if (!callerOrigin || !isValidRpId(rpId, callerOrigin)) {1030 throw new DOMException(1031 `SecurityError: relying party ID "${rpId}" is not a registrable domain ` +1032 `suffix of the calling origin "${callerOrigin ?? "(unknown)"}".`,1033 "SecurityError",1034 );1035 }1036 const cdjOrigin = clientDataJsonOrigin(clientDataJSON);1037 if (cdjOrigin !== null && cdjOrigin !== callerOrigin) {1038 throw new DOMException(1039 `SecurityError: clientDataJSON origin "${cdjOrigin}" does not match the ` +1040 `attested calling origin "${callerOrigin}".`,1041 "SecurityError",1042 );1043 }1044}10451046export async function handlePasskeyHasCredentials(1047 payload: FirefoxPasskeyHasCredentialsPayload,1048 callerOrigin?: string,1049): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {1050 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1051 return { ok: false, error: "not_applicable" };1052 }1053 1054 1055 1056 if (!callerOrigin || !isValidRpId(payload.rpId, callerOrigin)) {1057 return { ok: true, hasCredentials: false };1058 }1059 if (!vault.isUnlocked()) {1060 1061 1062 1063 return { ok: true, hasCredentials: false };1064 }1065 const passkeys = listPasskeyEntries().filter(1066 (entry) => entry.passkey.rpId === payload.rpId1067 );1068 if (passkeys.length === 0) return { ok: true, hasCredentials: false };10691070 const allow = payload.allowCredentials ?? [];1071 if (allow.length === 0) return { ok: true, hasCredentials: true };10721073 const hasMatch = passkeys.some((entry) =>1074 allow.some((ac) => ac.id === entry.passkey.credentialId)1075 );1076 return { ok: true, hasCredentials: hasMatch };1077}107810791080108110821083108410851086async function requestFirefoxApproval(args: {1087 type: "create" | "get";1088 rpId: string;1089 rpName?: string;1090 userName: string;1091 userDisplayName?: string;1092}): Promise<boolean> {1093 1094 1095 const requestId = Date.now() + Math.floor(Math.random() * 1000);1096 return promptUserApproval({1097 requestId,1098 type: args.type,1099 rpId: args.rpId,1100 rpName: args.rpName ?? args.rpId,1101 userName: args.userName,1102 userDisplayName: args.userDisplayName,1103 });1104}11051106export async function handleFirefoxPasskeyCreate(1107 payload: FirefoxPasskeyCreatePayload,1108 callerOrigin?: string,1109): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {1110 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1111 return { ok: false, error: "not_applicable" };1112 }1113 1114 1115 1116 1117 1118 assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1119 if (!(await isProUser())) {1120 broadcastPasskeyProGate();1121 return { ok: false, error: "pro_required" };1122 }1123 if (!vault.isUnlocked()) {1124 await openApprovalPopup();1125 return { ok: false, error: "vault_locked" };1126 }11271128 const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);1129 if (!algorithm) {1130 return { ok: false, error: "internal_error", message: "No supported algorithm offered" };1131 }11321133 const approved = await requestFirefoxApproval({1134 type: "create",1135 rpId: payload.rpId,1136 rpName: payload.rpName,1137 userName: payload.userName,1138 userDisplayName: payload.userDisplayName,1139 });1140 if (!approved) return { ok: false, error: "user_cancelled" };11411142 const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1143 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);11441145 1146 1147 const authenticatorData = await buildAuthenticatorData({1148 rpId: payload.rpId,1149 flags: 0x45,1150 signCount: 0,1151 credentialId: credentialIdBytes,1152 publicKeyCose,1153 });1154 const attestationObject = createAttestationObject(authenticatorData);11551156 const passkey: PasskeyData = {1157 rpId: payload.rpId,1158 rpName: payload.rpName ?? payload.rpId,1159 credentialId,1160 publicKeyCose: base64urlEncode(publicKeyCose),1161 privateKeyJwk,1162 userHandle: payload.userId,1163 userName: payload.userName,1164 userDisplayName: payload.userDisplayName ?? payload.userName,1165 algorithm,1166 signCount: 0,1167 aaguid: base64urlEncode(new Uint8Array(16)),1168 backupEligible: false,1169 backupState: false,1170 browser: "firefox",1171 transports: ["internal"],1172 createdAt: Date.now(),1173 };11741175 const target = findAttachTarget(payload.rpId);1176 if (target) {1177 await vault.updateEntry(target.id, { passkey });1178 } else {1179 await vault.addEntry({1180 type: "login",1181 title: payload.rpName || payload.rpId,1182 url: `https://${payload.rpId}`,1183 username: payload.userName,1184 passkey,1185 });1186 }11871188 return {1189 ok: true,1190 credential: {1191 type: "create",1192 id: credentialId,1193 1194 1195 1196 clientDataJSON: payload.clientDataJSON,1197 attestationObject: base64urlEncode(attestationObject),1198 authenticatorData: base64urlEncode(authenticatorData),1199 publicKey: base64urlEncode(publicKeySpki),1200 algorithm,1201 },1202 };1203}12041205export async function handleFirefoxPasskeyGet(1206 payload: FirefoxPasskeyGetPayload,1207 callerOrigin?: string,1208): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1209 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1210 return { ok: false, error: "not_applicable" };1211 }1212 1213 1214 1215 1216 1217 1218 assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1219 if (!(await isProUser())) {1220 broadcastPasskeyProGate();1221 return { ok: false, error: "pro_required" };1222 }1223 if (!vault.isUnlocked()) {1224 await openApprovalPopup();1225 return { ok: false, error: "vault_locked" };1226 }12271228 const allow = payload.allowCredentials ?? [];1229 const candidates = listPasskeyEntries().filter((entry) => {1230 if (entry.passkey.rpId !== payload.rpId) return false;1231 if (allow.length === 0) return true;1232 return allow.some((ac) => ac.id === entry.passkey.credentialId);1233 });12341235 if (candidates.length === 0) return { ok: false, error: "no_credentials" };12361237 1238 1239 1240 candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1241 const match = candidates[0]!;1242 const credential = match.passkey;12431244 if (!credential.privateKeyJwk) {1245 1246 1247 1248 return { ok: false, error: "no_credentials" };1249 }12501251 const approved = await requestFirefoxApproval({1252 type: "get",1253 rpId: credential.rpId,1254 rpName: credential.rpName,1255 userName: credential.userName,1256 userDisplayName: credential.userDisplayName,1257 });1258 if (!approved) return { ok: false, error: "user_cancelled" };12591260 const nextSignCount = credential.signCount + 1;1261 1262 1263 const flags =1264 0x05 |1265 (credential.backupEligible ? 0x08 : 0) |1266 (credential.backupState ? 0x10 : 0);1267 const authenticatorData = await buildAuthenticatorData({1268 rpId: credential.rpId,1269 flags,1270 signCount: nextSignCount,1271 });12721273 1274 1275 1276 const clientDataBytes = new Uint8Array(payload.clientDataJSON);1277 const clientDataHash = new Uint8Array(1278 await crypto.subtle.digest("SHA-256", clientDataBytes)1279 );1280 const signature = await signPasskeyAssertion(1281 credential.privateKeyJwk,1282 credential.algorithm,1283 authenticatorData,1284 clientDataHash1285 );12861287 await vault.updateEntry(match.id, {1288 passkey: {1289 ...credential,1290 signCount: nextSignCount,1291 lastUsedAt: Date.now(),1292 },1293 });12941295 return {1296 ok: true,1297 credential: {1298 type: "get",1299 id: credential.credentialId,1300 clientDataJSON: payload.clientDataJSON,1301 authenticatorData: base64urlEncode(authenticatorData),1302 signature: base64urlEncode(signature),1303 userHandle: credential.userHandle,1304 },1305 };1306}13071308export async function setupPasskeyProxy(): Promise<void> {1309 if (!isPasskeySupported()) {1310 console.log("[Peach passkey] setupPasskeyProxy: not supported on this browser");1311 return;1312 }13131314 1315 1316 if (detectSharedBrowser() === "firefox") {1317 await refreshPasskeyProxy();1318 return;1319 }13201321 if (!listenersRegistered) {1322 console.log("[Peach passkey] registering webAuthenticationProxy listeners");1323 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1324 1325 1326 1327 1328 1329 1330 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1331 requestId: requestInfo.requestId,1332 isUvpaa: true,1333 });1334 });1335 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1336 console.log("[Peach passkey] onCreateRequest fired", { requestId: requestInfo.requestId });1337 void handleCreateRequest(requestInfo).catch(async (error) => {1338 1339 1340 1341 if (isInvalidRequestIdError(error)) {1342 console.log("[Peach passkey] create handler resolved after cancel", { requestId: requestInfo.requestId });1343 return;1344 }1345 console.error("Peach passkey create failed", error);1346 await rejectCreate(1347 requestInfo.requestId,1348 "UnknownError",1349 error instanceof Error ? error.message : "Passkey creation failed."1350 );1351 });1352 });1353 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1354 console.log("[Peach passkey] onGetRequest fired", { requestId: requestInfo.requestId });1355 void handleGetRequest(requestInfo).catch(async (error) => {1356 if (isInvalidRequestIdError(error)) {1357 console.log("[Peach passkey] get handler resolved after cancel", { requestId: requestInfo.requestId });1358 return;1359 }1360 console.error("Peach passkey get failed", error);1361 await rejectGet(1362 requestInfo.requestId,1363 "UnknownError",1364 error instanceof Error ? error.message : "Passkey authentication failed."1365 );1366 });1367 });1368 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1369 console.log("[Peach passkey] onRequestCanceled", { requestId });1370 markCancelled(requestId);1371 1372 const waiter = unlockWaiters.get(requestId);1373 if (waiter) {1374 clearTimeout(waiter.timeoutId);1375 waiter.resolve(false);1376 unlockWaiters.delete(requestId);1377 }1378 void resolvePendingPasskeyRequest(requestId, false);1379 });1380 listenersRegistered = true;1381 }13821383 await refreshPasskeyProxy();1384}