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 type {16 PasskeyData,17 PasskeyProviderState,18 PendingPasskeyRequest,19 VaultEntry,20} from "../shared/types";2122declare const __PEACH_E2E_PASSKEYS__: boolean;2324const PENDING_PREFIX = "passkey_pending_";25const APPROVAL_TIMEOUT_MS = 60_000;2627let listenersRegistered = false;28let attached = false;29let conflict: string | undefined;30const PASSKEY_CREATE_E2E_BYPASS = __PEACH_E2E_PASSKEYS__ === true;3132const approvals = new Map<33 number,34 { resolve: (approved: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }35>();36373839const unlockWaiters = new Map<number, { resolve: (ok: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }>();4041424344const cancelledRequests = new Set<number>();45function markCancelled(requestId: number): void {46 cancelledRequests.add(requestId);47 48 49 50 if (cancelledRequests.size > 256) {51 const iter = cancelledRequests.values();52 for (let i = 0; i < 128; i += 1) cancelledRequests.delete(iter.next().value as number);53 }54}5556export function onVaultUnlocked(): void {57 for (const { resolve, timeoutId } of unlockWaiters.values()) {58 clearTimeout(timeoutId);59 resolve(true);60 }61 unlockWaiters.clear();62}6364656667686970export async function denyAllPendingPasskeyRequests(): Promise<void> {71 if (approvals.size === 0 && unlockWaiters.size === 0) return;72 console.log("[Peach passkey] denyAllPendingPasskeyRequests (popup disconnected)", {73 approvals: approvals.size,74 unlockWaiters: unlockWaiters.size,75 });76 for (const [rid, { resolve, timeoutId }] of approvals.entries()) {77 clearTimeout(timeoutId);78 resolve(false);79 approvals.delete(rid);80 await clearPendingRequest(rid).catch(() => {});81 }82 for (const { resolve, timeoutId } of unlockWaiters.values()) {83 clearTimeout(timeoutId);84 resolve(false);85 }86 unlockWaiters.clear();87}8889function isPasskeySupported(): boolean {90 if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;91 92 93 94 95 if (detectSharedBrowser() === "firefox") {96 const ver = firefoxVersion();97 if (Number.isFinite(ver) && ver >= 150) return true;98 }99 return false;100}101102export const detectBrowser = detectSharedBrowser;103104function getBrowserLabel(): string {105 const ua = navigator.userAgent;106 if (/Firefox\/(\d+)/.test(ua)) {107 const version = ua.match(/Firefox\/(\d+)/)?.[1];108 return version ? `Firefox ${version}` : "Firefox";109 }110 const version = ua.match(/Chrome\/(\d+)/)?.[1];111 return version ? `Chrome ${version}` : "Chromium";112}113114function pendingStorageKey(requestId: number): string {115 return `${PENDING_PREFIX}${requestId}`;116}117118function originFromRpId(rpId: string): string {119 return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)120 ? `http://${rpId}`121 : `https://${rpId}`;122}123124125126127128129130131132133134135136137138139async function resolvePageOrigin(rpId: string): Promise<string> {140 try {141 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>142 chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)143 );144 const url = tabs[0]?.url;145 if (url) {146 const parsed = new URL(url);147 const host = parsed.hostname.toLowerCase();148 const rp = rpId.toLowerCase();149 if (host === rp || host.endsWith(`.${rp}`)) {150 return parsed.origin;151 }152 }153 } catch {154 155 }156 return originFromRpId(rpId);157}158159async function setBadge(text: string): Promise<void> {160 if (!chrome.action?.setBadgeText) return;161 await chrome.action.setBadgeText({ text });162 if (text) {163 await chrome.action.setBadgeBackgroundColor({ color: "#E5805C" });164 }165}166167async function clearPendingRequest(requestId: number): Promise<void> {168 if (chrome.storage.session) {169 await chrome.storage.session.remove(pendingStorageKey(requestId));170 }171 await setBadge("");172}173174async function openApprovalPopup(): Promise<void> {175 try {176 await chrome.action.openPopup();177 } catch {178 await setBadge("PK");179 }180}181182183184185186187188189190function isInvalidRequestIdError(err: unknown): boolean {191 return err instanceof Error && /invalid requestid/i.test(err.message);192}193194async function rejectCreate(195 requestId: number,196 name: string,197 message = ""198): Promise<void> {199 try {200 await chrome.webAuthenticationProxy.completeCreateRequest({201 requestId,202 error: { name, message },203 });204 } catch (err) {205 if (isInvalidRequestIdError(err)) {206 console.log("[Peach passkey] completeCreateRequest skipped (already cancelled)", { requestId });207 return;208 }209 throw err;210 }211}212213async function rejectGet(214 requestId: number,215 name: string,216 message = ""217): Promise<void> {218 try {219 await chrome.webAuthenticationProxy.completeGetRequest({220 requestId,221 error: { name, message },222 });223 } catch (err) {224 if (isInvalidRequestIdError(err)) {225 console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId });226 return;227 }228 throw err;229 }230}231232export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {233 if (!chrome.storage.session) return null; 234 const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;235 const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));236 if (!key) return null;237 const pending = stored[key];238 return pending && typeof pending === "object"239 ? (pending as PendingPasskeyRequest)240 : null;241}242243export async function resolvePendingPasskeyRequest(244 requestId: number,245 approved: boolean246): Promise<void> {247 const pending = approvals.get(requestId);248 if (!pending) {249 250 251 252 253 await clearPendingRequest(requestId);254 if (chrome.storage.session) {255 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);256 }257 return;258 }259260 clearTimeout(pending.timeoutId);261 approvals.delete(requestId);262 await clearPendingRequest(requestId);263 pending.resolve(approved);264}265266async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {267 if (chrome.storage.session) {268 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });269 }270 await openApprovalPopup();271272 return new Promise<boolean>((resolve) => {273 const timeoutId = setTimeout(async () => {274 approvals.delete(request.requestId);275 await clearPendingRequest(request.requestId);276 resolve(false);277 }, APPROVAL_TIMEOUT_MS);278279 approvals.set(request.requestId, { resolve, timeoutId });280 });281}282283export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {284 const settings = await vault.ensureSettingsLoaded();285 const supported = isPasskeySupported();286 const enabled = settings.passkeyProviderEnabled === true;287 return {288 supported,289 enabled,290 attached: supported && attached,291 browserLabel: getBrowserLabel(),292 statusLabel: !supported293 ? "Interception unavailable"294 : attached295 ? "Active"296 : conflict297 ? "Another provider is active"298 : enabled299 ? "Inactive"300 : "Disabled",301 conflict,302 };303}304305async function attachPasskeyProxy(): Promise<void> {306 if (!isPasskeySupported()) {307 attached = false;308 conflict = "This browser does not expose chrome.webAuthenticationProxy.";309 return;310 }311312 313 314 315 if (detectSharedBrowser() === "firefox") {316 attached = true;317 conflict = undefined;318 return;319 }320321 if (attached) return;322323 try {324 await chrome.webAuthenticationProxy.attach();325 attached = true;326 conflict = undefined;327 console.log("[Peach passkey] proxy attached successfully");328 } catch (error) {329 attached = false;330 conflict = error instanceof Error ? error.message : "Another provider is active.";331 console.warn("[Peach passkey] proxy attach failed", { conflict });332 }333}334335async function detachPasskeyProxy(): Promise<void> {336 if (!isPasskeySupported()) {337 attached = false;338 return;339 }340341 if (detectSharedBrowser() === "firefox") {342 attached = false;343 conflict = undefined;344 return;345 }346347 try {348 await chrome.webAuthenticationProxy.detach();349 } catch {350 351 }352353 attached = false;354 conflict = undefined;355}356357export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {358 await vault.updateSettings({ passkeyProviderEnabled: enabled });359 if (enabled) {360 await attachPasskeyProxy();361 } else {362 await detachPasskeyProxy();363 }364 return getPasskeyProviderState();365}366367async function refreshPasskeyProxy(): Promise<void> {368 const settings = await vault.ensureSettingsLoaded();369 if (settings.passkeyProviderEnabled === true) {370 await attachPasskeyProxy();371 return;372 }373 await detachPasskeyProxy();374}375376377378379380381382383function findAttachTarget(rpId: string): VaultEntry | null {384 const all = vault.getEntries();385 const passkeyMatch = all.find(386 (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt387 );388 if (passkeyMatch) return passkeyMatch;389 const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);390 if (loginMatches.length === 0) return null;391 392 return loginMatches.reduce((best, e) =>393 (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best394 );395}396397async function handleCreateRequest(398 request: chrome.webAuthenticationProxy.CreateRequest399): Promise<void> {400 console.log("[Peach passkey] create event received", {401 requestId: request.requestId,402 detailsJson: request.requestDetailsJson,403 });404 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;405 const rpId = options.rp.id;406 const rpName = options.rp.name ?? options.rp.id;407 const userName = options.user.name;408 const userDisplayName = options.user.displayName ?? options.user.name;409 console.log("[Peach passkey] create parsed", { rpId, rpName, userName, unlocked: vault.isUnlocked() });410411 if (!rpId) {412 console.warn("[Peach passkey] reject: missing rp.id");413 await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");414 return;415 }416417 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {418 console.warn("[Peach passkey] reject: user is not Pro");419 await rejectCreate(420 request.requestId,421 "NotAllowedError",422 "Passkey creation requires Peach Pro."423 );424 425 broadcastPasskeyProGate();426 return;427 }428429 if (!vault.isUnlocked()) {430 console.log("[Peach passkey] vault locked, summoning inline unlock");431 432 433 434 435 436 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>437 chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)438 );439 const activeTab = tabs[0];440 console.log("[Peach passkey] active tab for unlock summon", { id: activeTab?.id, url: activeTab?.url });441 if (activeTab?.id) {442 chrome.tabs.sendMessage(activeTab.id, {443 type: 'SUMMON_AUTOFILL',444 source: 'passkey-unlock',445 }).catch((err) => {446 console.warn("[Peach passkey] SUMMON_AUTOFILL failed, falling back to popup", err);447 void openApprovalPopup();448 });449 } else {450 await openApprovalPopup();451 }452453 const unlocked = await new Promise<boolean>((resolve) => {454 const timeoutId = setTimeout(() => {455 unlockWaiters.delete(request.requestId);456 resolve(false);457 }, APPROVAL_TIMEOUT_MS);458 unlockWaiters.set(request.requestId, { resolve, timeoutId });459 });460461 console.log("[Peach passkey] unlock wait resolved", { unlocked, vaultUnlocked: vault.isUnlocked() });462 if (!unlocked || !vault.isUnlocked()) {463 console.warn("[Peach passkey] reject: unlock timeout");464 await rejectCreate(request.requestId, "NotAllowedError", "Not unlocked in time.");465 return;466 }467 468 }469470 console.log("[Peach passkey] requesting approval via popup");471 const approved = await promptUserApproval({472 requestId: request.requestId,473 type: "create",474 rpId,475 rpName,476 userName,477 userDisplayName,478 });479 console.log("[Peach passkey] approval resolved", { approved });480 if (!approved) {481 console.warn("[Peach passkey] reject: user denied");482 await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");483 return;484 }485486 const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);487 if (!algorithm) {488 console.warn("[Peach passkey] reject: no supported algorithm", options.pubKeyCredParams);489 await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");490 return;491 }492 console.log("[Peach passkey] algorithm selected", algorithm);493494 const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));495 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(496 algorithm497 );498 const authData = await buildAuthenticatorData({499 rpId,500 flags: 0x4d,501 signCount: 0,502 credentialId: credentialIdBytes,503 publicKeyCose,504 });505 const attestationObject = createAttestationObject(authData);506 const clientDataJSON = buildClientDataJSON(507 "webauthn.create",508 options.challenge,509 await resolvePageOrigin(rpId)510 );511512 const passkey: PasskeyData = {513 rpId,514 rpName,515 credentialId: base64urlEncode(credentialIdBytes),516 publicKeyCose: base64urlEncode(publicKeyCose),517 privateKeyJwk,518 userHandle: options.user.id,519 userName,520 userDisplayName,521 algorithm,522 signCount: 0,523 aaguid: base64urlEncode(new Uint8Array(16)),524 backupEligible: false,525 backupState: false,526 browser: "chrome",527 transports: ["internal"],528 createdAt: Date.now(),529 };530531 532 533 const target = findAttachTarget(rpId);534 if (target) {535 await vault.updateEntry(target.id, { passkey });536 } else {537 await vault.addEntry({538 type: "login",539 title: rpName,540 url: `https://${rpId}`,541 username: userName,542 passkey,543 });544 }545546 try {547 await chrome.webAuthenticationProxy.completeCreateRequest({548 requestId: request.requestId,549 responseJson: JSON.stringify({550 id: passkey.credentialId,551 rawId: passkey.credentialId,552 type: "public-key",553 response: {554 clientDataJSON: base64urlEncode(clientDataJSON),555 authenticatorData: base64urlEncode(authData),556 attestationObject: base64urlEncode(attestationObject),557 transports: ["internal"],558 publicKey: base64urlEncode(publicKeySpki),559 publicKeyAlgorithm: algorithm,560 },561 authenticatorAttachment: "platform",562 clientExtensionResults: {},563 }),564 });565 console.log("[Peach passkey] completeCreateRequest success", { requestId: request.requestId });566 } catch (err) {567 if (isInvalidRequestIdError(err)) {568 console.log("[Peach passkey] completeCreateRequest skipped (already cancelled)", { requestId: request.requestId });569 return;570 }571 throw err;572 }573}574575function broadcastPasskeyProGate(): void {576 chrome.tabs.query({}, (tabs) => {577 for (const tab of tabs) {578 if (tab.id !== undefined) {579 chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});580 }581 }582 });583}584585async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {586 if (chrome.storage.session) {587 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });588 }589590 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {591 chrome.tabs.query({ active: true, currentWindow: true }, resolve);592 });593 const tab = tabs[0];594595 if (tab?.id) {596 chrome.tabs.sendMessage(tab.id, {597 type: 'SHOW_PASSKEY_TOAST',598 payload: {599 site: request.rpId,600 displayName: request.userDisplayName ?? request.userName,601 username: request.userName,602 requestId: String(request.requestId),603 },604 }).catch(() => {});605 } else {606 await openApprovalPopup();607 }608609 return new Promise<boolean>((resolve) => {610 const timeoutId = setTimeout(async () => {611 approvals.delete(request.requestId);612 await clearPendingRequest(request.requestId);613 resolve(false);614 }, APPROVAL_TIMEOUT_MS);615616 approvals.set(request.requestId, { resolve, timeoutId });617 });618}619620async function handleGetRequest(621 request: chrome.webAuthenticationProxy.GetRequest622): Promise<void> {623 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;624 if (!vault.isUnlocked()) {625 626 627 628 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>629 chrome.tabs.query({ active: true, currentWindow: true }, resolve)630 );631 const activeTab = tabs[0];632 if (activeTab?.id) {633 chrome.tabs.sendMessage(activeTab.id, {634 type: 'SUMMON_AUTOFILL',635 source: 'passkey-unlock',636 }).catch(() => { void openApprovalPopup(); });637 } else {638 await openApprovalPopup();639 }640641 642 const unlocked = await new Promise<boolean>((resolve) => {643 const timeoutId = setTimeout(() => {644 unlockWaiters.delete(request.requestId);645 resolve(false);646 }, APPROVAL_TIMEOUT_MS);647 unlockWaiters.set(request.requestId, { resolve, timeoutId });648 });649650 if (!unlocked || !vault.isUnlocked()) {651 await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");652 return;653 }654 655 }656657 if (!(await isProUser())) {658 await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");659 broadcastPasskeyProGate();660 return;661 }662663 const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>664 entry.passkey !== undefined && !entry.deletedAt665 );666 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);667668 const match = passkeyBearing.find((entry) => {669 if (allowedCredentialIds.length > 0) {670 return allowedCredentialIds.includes(entry.passkey.credentialId);671 }672 if (!options.rpId) return false;673 return entry.passkey.rpId === options.rpId;674 });675676 if (!match) {677 await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");678 return;679 }680681 const credential = match.passkey;682 683 684 685 686 if (!credential.privateKeyJwk) {687 await rejectGet(688 request.requestId,689 "NotAllowedError",690 "This passkey was created on Firefox; sign in via the platform authenticator."691 );692 return;693 }694695 const approved = await promptViaToast({696 requestId: request.requestId,697 type: "get",698 rpId: credential.rpId,699 rpName: credential.rpName,700 userName: credential.userName,701 userDisplayName: credential.userDisplayName,702 });703 if (!approved) {704 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");705 return;706 }707708 const nextSignCount = credential.signCount + 1;709 const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);710 const authData = await buildAuthenticatorData({711 rpId: credential.rpId,712 flags,713 signCount: nextSignCount,714 });715 const clientDataJSON = buildClientDataJSON(716 "webauthn.get",717 options.challenge,718 await resolvePageOrigin(credential.rpId)719 );720 const clientDataHash = new Uint8Array(721 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))722 );723 const signature = await signPasskeyAssertion(724 credential.privateKeyJwk,725 credential.algorithm,726 authData,727 clientDataHash728 );729730 await vault.updateEntry(match.id, {731 passkey: {732 ...credential,733 signCount: nextSignCount,734 lastUsedAt: Date.now(),735 },736 });737738 const responseJson = JSON.stringify({739 id: credential.credentialId,740 rawId: credential.credentialId,741 type: "public-key",742 response: {743 clientDataJSON: base64urlEncode(clientDataJSON),744 authenticatorData: base64urlEncode(authData),745 signature: base64urlEncode(signature),746 userHandle: credential.userHandle,747 },748 authenticatorAttachment: "platform",749 clientExtensionResults: {},750 });751752 try {753 await chrome.webAuthenticationProxy.completeGetRequest({754 requestId: request.requestId,755 responseJson,756 });757 console.log("[Peach passkey] completeGetRequest success", { requestId: request.requestId });758 } catch (err) {759 if (isInvalidRequestIdError(err)) {760 console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId: request.requestId });761 return;762 }763 throw err;764 }765}766767768769770771772773774775776const FIREFOX_PENDING_KEY = "passkey_pending_firefox";777const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";778const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; 779780export async function initiateFirefoxPasskeyCreate(args: {781 rpId: string;782 rpName: string;783 userName: string;784 userDisplayName?: string;785}): Promise<{ ok: boolean; error?: string }> {786 if (!(await isProUser())) {787 broadcastPasskeyProGate();788 return { ok: false, error: "passkey creation requires Peach Pro" };789 }790 if (!vault.isUnlocked()) {791 await openApprovalPopup();792 return { ok: false, error: "vault_locked" };793 }794 if (!chrome.storage.session) {795 return { ok: false, error: "session storage unavailable" };796 }797798 799 800 801 const requestId = Date.now() + Math.floor(Math.random() * 1_000_000);802 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));803 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));804805 const pending: PendingPasskeyRequest = {806 requestId,807 type: "create",808 rpId: args.rpId,809 rpName: args.rpName,810 userName: args.userName,811 userDisplayName: args.userDisplayName,812 browser: "firefox",813 challenge,814 userHandle,815 };816817 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });818 await openApprovalPopup();819 return { ok: true };820}821822export async function completeFirefoxPasskeyCreate(payload: {823 requestId: number;824 rpId: string;825 rpName: string;826 userName: string;827 userDisplayName?: string;828 userHandle: string;829 credentialId: string;830 publicKeyCose: string;831 algorithm: -7 | -8;832 aaguid?: string;833 transports?: string[];834 backupEligible?: boolean;835 backupState?: boolean;836}): Promise<{ ok: boolean; error?: string }> {837 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };838839 const passkey: PasskeyData = {840 rpId: payload.rpId,841 rpName: payload.rpName,842 credentialId: payload.credentialId,843 publicKeyCose: payload.publicKeyCose,844 userHandle: payload.userHandle,845 userName: payload.userName,846 userDisplayName: payload.userDisplayName ?? payload.userName,847 algorithm: payload.algorithm,848 signCount: 0,849 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),850 backupEligible: payload.backupEligible ?? false,851 backupState: payload.backupState ?? false,852 browser: "firefox",853 transports: payload.transports ?? ["internal"],854 createdAt: Date.now(),855 };856857 858 859 860 const target = findAttachTarget(payload.rpId);861 if (target) {862 await vault.updateEntry(target.id, { passkey });863 } else {864 await vault.addEntry({865 type: "login",866 title: payload.rpName || payload.rpId,867 url: `https://${payload.rpId}`,868 username: payload.userName,869 passkey,870 });871 }872873 if (chrome.storage.session) {874 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);875 }876 return { ok: true };877}878879export async function cancelFirefoxPasskeyCreate(): Promise<void> {880 if (chrome.storage.session) {881 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);882 }883}884885function declineKey(domain: string): string {886 return `${FIREFOX_DECLINE_PREFIX}${domain}`;887}888889export async function recordPasskeyOfferDecline(domain: string): Promise<void> {890 if (!domain) return;891 await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });892}893894export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {895 if (!domain) return false;896 const key = declineKey(domain);897 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;898 const at = stored[key];899 if (typeof at !== "number") return false;900 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {901 await chrome.storage.local.remove(key);902 return false;903 }904 return true;905}906907908909910911912913914915916917918919920interface FirefoxAllowCredential {921 id: string;922 type?: string;923}924925interface FirefoxPubKeyCredParam {926 type?: string;927 alg: number;928}929930export interface FirefoxPasskeyHasCredentialsPayload {931 rpId: string;932 allowCredentials: FirefoxAllowCredential[];933}934935export interface FirefoxPasskeyCreatePayload {936 rpId: string;937 rpName?: string;938 userId: string;939 userName: string;940 userDisplayName?: string;941 challenge: string;942 clientDataJSON: number[];943 origin: string;944 pubKeyCredParams?: FirefoxPubKeyCredParam[];945 authenticatorSelection?: Record<string, unknown>;946 timeout?: number;947 attestation?: string;948}949950export interface FirefoxPasskeyGetPayload {951 rpId: string;952 challenge: string;953 clientDataJSON: number[];954 origin: string;955 allowCredentials: FirefoxAllowCredential[];956 userVerification?: string;957}958959interface FirefoxCreateCredentialResponse {960 type: "create";961 id: string;962 clientDataJSON: number[];963 attestationObject: string;964 authenticatorData: string;965 publicKey: string;966 algorithm: -7 | -8;967}968969interface FirefoxGetCredentialResponse {970 type: "get";971 id: string;972 clientDataJSON: number[];973 authenticatorData: string;974 signature: string;975 userHandle: string;976}977978type FirefoxPasskeyResult<T> =979 | { ok: true; credential: T }980 | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };981982function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {983 return vault.getEntries().filter(984 (entry): entry is VaultEntry & { passkey: PasskeyData } =>985 entry.passkey !== undefined && !entry.deletedAt986 );987}988989export async function handlePasskeyHasCredentials(990 payload: FirefoxPasskeyHasCredentialsPayload991): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {992 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {993 return { ok: false, error: "not_applicable" };994 }995 if (!vault.isUnlocked()) {996 997 998 999 return { ok: true, hasCredentials: false };1000 }1001 const passkeys = listPasskeyEntries().filter(1002 (entry) => entry.passkey.rpId === payload.rpId1003 );1004 if (passkeys.length === 0) return { ok: true, hasCredentials: false };10051006 const allow = payload.allowCredentials ?? [];1007 if (allow.length === 0) return { ok: true, hasCredentials: true };10081009 const hasMatch = passkeys.some((entry) =>1010 allow.some((ac) => ac.id === entry.passkey.credentialId)1011 );1012 return { ok: true, hasCredentials: hasMatch };1013}101410151016101710181019102010211022async function requestFirefoxApproval(args: {1023 type: "create" | "get";1024 rpId: string;1025 rpName?: string;1026 userName: string;1027 userDisplayName?: string;1028}): Promise<boolean> {1029 1030 1031 const requestId = Date.now() + Math.floor(Math.random() * 1000);1032 return promptUserApproval({1033 requestId,1034 type: args.type,1035 rpId: args.rpId,1036 rpName: args.rpName ?? args.rpId,1037 userName: args.userName,1038 userDisplayName: args.userDisplayName,1039 });1040}10411042export async function handleFirefoxPasskeyCreate(1043 payload: FirefoxPasskeyCreatePayload1044): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {1045 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1046 return { ok: false, error: "not_applicable" };1047 }1048 if (!(await isProUser())) {1049 broadcastPasskeyProGate();1050 return { ok: false, error: "pro_required" };1051 }1052 if (!vault.isUnlocked()) {1053 await openApprovalPopup();1054 return { ok: false, error: "vault_locked" };1055 }10561057 const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);1058 if (!algorithm) {1059 return { ok: false, error: "internal_error", message: "No supported algorithm offered" };1060 }10611062 const approved = await requestFirefoxApproval({1063 type: "create",1064 rpId: payload.rpId,1065 rpName: payload.rpName,1066 userName: payload.userName,1067 userDisplayName: payload.userDisplayName,1068 });1069 if (!approved) return { ok: false, error: "user_cancelled" };10701071 const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1072 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);10731074 1075 1076 const authenticatorData = await buildAuthenticatorData({1077 rpId: payload.rpId,1078 flags: 0x45,1079 signCount: 0,1080 credentialId: credentialIdBytes,1081 publicKeyCose,1082 });1083 const attestationObject = createAttestationObject(authenticatorData);10841085 const passkey: PasskeyData = {1086 rpId: payload.rpId,1087 rpName: payload.rpName ?? payload.rpId,1088 credentialId,1089 publicKeyCose: base64urlEncode(publicKeyCose),1090 privateKeyJwk,1091 userHandle: payload.userId,1092 userName: payload.userName,1093 userDisplayName: payload.userDisplayName ?? payload.userName,1094 algorithm,1095 signCount: 0,1096 aaguid: base64urlEncode(new Uint8Array(16)),1097 backupEligible: false,1098 backupState: false,1099 browser: "firefox",1100 transports: ["internal"],1101 createdAt: Date.now(),1102 };11031104 const target = findAttachTarget(payload.rpId);1105 if (target) {1106 await vault.updateEntry(target.id, { passkey });1107 } else {1108 await vault.addEntry({1109 type: "login",1110 title: payload.rpName || payload.rpId,1111 url: `https://${payload.rpId}`,1112 username: payload.userName,1113 passkey,1114 });1115 }11161117 return {1118 ok: true,1119 credential: {1120 type: "create",1121 id: credentialId,1122 1123 1124 1125 clientDataJSON: payload.clientDataJSON,1126 attestationObject: base64urlEncode(attestationObject),1127 authenticatorData: base64urlEncode(authenticatorData),1128 publicKey: base64urlEncode(publicKeySpki),1129 algorithm,1130 },1131 };1132}11331134export async function handleFirefoxPasskeyGet(1135 payload: FirefoxPasskeyGetPayload1136): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1137 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1138 return { ok: false, error: "not_applicable" };1139 }1140 if (!(await isProUser())) {1141 broadcastPasskeyProGate();1142 return { ok: false, error: "pro_required" };1143 }1144 if (!vault.isUnlocked()) {1145 await openApprovalPopup();1146 return { ok: false, error: "vault_locked" };1147 }11481149 const allow = payload.allowCredentials ?? [];1150 const candidates = listPasskeyEntries().filter((entry) => {1151 if (entry.passkey.rpId !== payload.rpId) return false;1152 if (allow.length === 0) return true;1153 return allow.some((ac) => ac.id === entry.passkey.credentialId);1154 });11551156 if (candidates.length === 0) return { ok: false, error: "no_credentials" };11571158 1159 1160 1161 candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1162 const match = candidates[0]!;1163 const credential = match.passkey;11641165 if (!credential.privateKeyJwk) {1166 1167 1168 1169 return { ok: false, error: "no_credentials" };1170 }11711172 const approved = await requestFirefoxApproval({1173 type: "get",1174 rpId: credential.rpId,1175 rpName: credential.rpName,1176 userName: credential.userName,1177 userDisplayName: credential.userDisplayName,1178 });1179 if (!approved) return { ok: false, error: "user_cancelled" };11801181 const nextSignCount = credential.signCount + 1;1182 1183 1184 const flags =1185 0x05 |1186 (credential.backupEligible ? 0x08 : 0) |1187 (credential.backupState ? 0x10 : 0);1188 const authenticatorData = await buildAuthenticatorData({1189 rpId: credential.rpId,1190 flags,1191 signCount: nextSignCount,1192 });11931194 1195 1196 1197 const clientDataBytes = new Uint8Array(payload.clientDataJSON);1198 const clientDataHash = new Uint8Array(1199 await crypto.subtle.digest("SHA-256", clientDataBytes)1200 );1201 const signature = await signPasskeyAssertion(1202 credential.privateKeyJwk,1203 credential.algorithm,1204 authenticatorData,1205 clientDataHash1206 );12071208 await vault.updateEntry(match.id, {1209 passkey: {1210 ...credential,1211 signCount: nextSignCount,1212 lastUsedAt: Date.now(),1213 },1214 });12151216 return {1217 ok: true,1218 credential: {1219 type: "get",1220 id: credential.credentialId,1221 clientDataJSON: payload.clientDataJSON,1222 authenticatorData: base64urlEncode(authenticatorData),1223 signature: base64urlEncode(signature),1224 userHandle: credential.userHandle,1225 },1226 };1227}12281229export async function setupPasskeyProxy(): Promise<void> {1230 if (!isPasskeySupported()) {1231 console.log("[Peach passkey] setupPasskeyProxy: not supported on this browser");1232 return;1233 }12341235 1236 1237 if (detectSharedBrowser() === "firefox") {1238 await refreshPasskeyProxy();1239 return;1240 }12411242 if (!listenersRegistered) {1243 console.log("[Peach passkey] registering webAuthenticationProxy listeners");1244 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1245 1246 1247 1248 1249 1250 1251 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1252 requestId: requestInfo.requestId,1253 isUvpaa: true,1254 });1255 });1256 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1257 console.log("[Peach passkey] onCreateRequest fired", { requestId: requestInfo.requestId });1258 void handleCreateRequest(requestInfo).catch(async (error) => {1259 1260 1261 1262 if (isInvalidRequestIdError(error)) {1263 console.log("[Peach passkey] create handler resolved after cancel", { requestId: requestInfo.requestId });1264 return;1265 }1266 console.error("Peach passkey create failed", error);1267 await rejectCreate(1268 requestInfo.requestId,1269 "UnknownError",1270 error instanceof Error ? error.message : "Passkey creation failed."1271 );1272 });1273 });1274 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1275 console.log("[Peach passkey] onGetRequest fired", { requestId: requestInfo.requestId });1276 void handleGetRequest(requestInfo).catch(async (error) => {1277 if (isInvalidRequestIdError(error)) {1278 console.log("[Peach passkey] get handler resolved after cancel", { requestId: requestInfo.requestId });1279 return;1280 }1281 console.error("Peach passkey get failed", error);1282 await rejectGet(1283 requestInfo.requestId,1284 "UnknownError",1285 error instanceof Error ? error.message : "Passkey authentication failed."1286 );1287 });1288 });1289 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1290 console.log("[Peach passkey] onRequestCanceled", { requestId });1291 markCancelled(requestId);1292 1293 const waiter = unlockWaiters.get(requestId);1294 if (waiter) {1295 clearTimeout(waiter.timeoutId);1296 waiter.resolve(false);1297 unlockWaiters.delete(requestId);1298 }1299 void resolvePendingPasskeyRequest(requestId, false);1300 });1301 listenersRegistered = true;1302 }13031304 await refreshPasskeyProxy();1305}