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 randomRequestId(): number {120 121 122 123 124 const u = crypto.getRandomValues(new Uint32Array(2));125 return u[0] * 0x200000 + (u[1] >>> 11);126}127128function originFromRpId(rpId: string): string {129 return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)130 ? `http://${rpId}`131 : `https://${rpId}`;132}133134135136137138139140141142143144145146147148149async function resolvePageOrigin(rpId: string): Promise<string> {150 try {151 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>152 chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)153 );154 const url = tabs[0]?.url;155 if (url) {156 const parsed = new URL(url);157 const host = parsed.hostname.toLowerCase();158 const rp = rpId.toLowerCase();159 if (host === rp || host.endsWith(`.${rp}`)) {160 return parsed.origin;161 }162 }163 } catch {164 165 }166 return originFromRpId(rpId);167}168169async function setBadge(text: string): Promise<void> {170 if (!chrome.action?.setBadgeText) return;171 await chrome.action.setBadgeText({ text });172 if (text) {173 await chrome.action.setBadgeBackgroundColor({ color: "#E5805C" });174 }175}176177async function clearPendingRequest(requestId: number): Promise<void> {178 if (chrome.storage.session) {179 await chrome.storage.session.remove(pendingStorageKey(requestId));180 }181 await setBadge("");182}183184async function openApprovalPopup(): Promise<void> {185 try {186 await chrome.action.openPopup();187 } catch {188 await setBadge("PK");189 }190}191192193194195196197198199200function isInvalidRequestIdError(err: unknown): boolean {201 return err instanceof Error && /invalid requestid/i.test(err.message);202}203204205206207208209210211212213function isMissingResponseJsonError(err: unknown): boolean {214 return err instanceof Error && /missing getresponsedetails/i.test(err.message);215}216217async function rejectCreate(218 requestId: number,219 name: string,220 message = ""221): Promise<void> {222 try {223 await chrome.webAuthenticationProxy.completeCreateRequest({224 requestId,225 error: { name, message },226 });227 } catch (err) {228 if (isInvalidRequestIdError(err)) {229 console.log("[Peach passkey] completeCreateRequest skipped (already cancelled)", { requestId });230 return;231 }232 throw err;233 }234}235236async function rejectGet(237 requestId: number,238 name: string,239 message = ""240): Promise<void> {241 try {242 await chrome.webAuthenticationProxy.completeGetRequest({243 requestId,244 error: { name, message },245 });246 } catch (err) {247 if (isInvalidRequestIdError(err)) {248 console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId });249 return;250 }251 throw err;252 }253}254255export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {256 if (!chrome.storage.session) return null; 257 const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;258 const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));259 if (!key) return null;260 const pending = stored[key];261 return pending && typeof pending === "object"262 ? (pending as PendingPasskeyRequest)263 : null;264}265266export async function resolvePendingPasskeyRequest(267 requestId: number,268 approved: boolean269): Promise<void> {270 const pending = approvals.get(requestId);271 if (!pending) {272 273 274 275 276 await clearPendingRequest(requestId);277 if (chrome.storage.session) {278 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);279 }280 return;281 }282283 clearTimeout(pending.timeoutId);284 approvals.delete(requestId);285 await clearPendingRequest(requestId);286 pending.resolve(approved);287}288289async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {290 if (chrome.storage.session) {291 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });292 }293 await openApprovalPopup();294295 return new Promise<boolean>((resolve) => {296 const timeoutId = setTimeout(async () => {297 approvals.delete(request.requestId);298 await clearPendingRequest(request.requestId);299 resolve(false);300 }, APPROVAL_TIMEOUT_MS);301302 approvals.set(request.requestId, { resolve, timeoutId });303 });304}305306export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {307 const settings = await vault.ensureSettingsLoaded();308 const supported = isPasskeySupported();309 const enabled = settings.passkeyProviderEnabled === true;310 return {311 supported,312 enabled,313 attached: supported && attached,314 browserLabel: getBrowserLabel(),315 statusLabel: !supported316 ? "Interception unavailable"317 : attached318 ? "Active"319 : conflict320 ? "Another provider is active"321 : enabled322 ? "Inactive"323 : "Disabled",324 conflict,325 };326}327328async function attachPasskeyProxy(): Promise<void> {329 if (!isPasskeySupported()) {330 attached = false;331 conflict = "This browser does not expose chrome.webAuthenticationProxy.";332 return;333 }334335 336 337 338 if (detectSharedBrowser() === "firefox") {339 attached = true;340 conflict = undefined;341 return;342 }343344 if (attached) return;345346 try {347 await chrome.webAuthenticationProxy.attach();348 attached = true;349 conflict = undefined;350 console.log("[Peach passkey] proxy attached successfully");351 } catch (error) {352 attached = false;353 conflict = error instanceof Error ? error.message : "Another provider is active.";354 console.warn("[Peach passkey] proxy attach failed", { conflict });355 }356}357358async function detachPasskeyProxy(): Promise<void> {359 if (!isPasskeySupported()) {360 attached = false;361 return;362 }363364 if (detectSharedBrowser() === "firefox") {365 attached = false;366 conflict = undefined;367 return;368 }369370 try {371 await chrome.webAuthenticationProxy.detach();372 } catch {373 374 }375376 attached = false;377 conflict = undefined;378}379380export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {381 await vault.updateSettings({ passkeyProviderEnabled: enabled });382 if (enabled) {383 await attachPasskeyProxy();384 } else {385 await detachPasskeyProxy();386 }387 return getPasskeyProviderState();388}389390async function refreshPasskeyProxy(): Promise<void> {391 const settings = await vault.ensureSettingsLoaded();392 if (settings.passkeyProviderEnabled === true) {393 await attachPasskeyProxy();394 return;395 }396 await detachPasskeyProxy();397}398399400401402403404405406function findAttachTarget(rpId: string): VaultEntry | null {407 const all = vault.getEntries();408 const passkeyMatch = all.find(409 (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt410 );411 if (passkeyMatch) return passkeyMatch;412 const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);413 if (loginMatches.length === 0) return null;414 415 return loginMatches.reduce((best, e) =>416 (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best417 );418}419420async function handleCreateRequest(421 request: chrome.webAuthenticationProxy.CreateRequest422): Promise<void> {423 console.log("[Peach passkey] create event received", {424 requestId: request.requestId,425 detailsJson: request.requestDetailsJson,426 });427 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;428 const rpId = options.rp.id;429 const rpName = options.rp.name ?? options.rp.id;430 const userName = options.user.name;431 const userDisplayName = options.user.displayName ?? options.user.name;432 console.log("[Peach passkey] create parsed", { rpId, rpName, userName, unlocked: vault.isUnlocked() });433434 if (!rpId) {435 console.warn("[Peach passkey] reject: missing rp.id");436 await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");437 return;438 }439440 441 442 443 444 445 446 const pageOrigin = await resolvePageOrigin(rpId);447448 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {449 console.warn("[Peach passkey] reject: user is not Pro");450 await rejectCreate(451 request.requestId,452 "NotAllowedError",453 "Passkey creation requires Peach Pro."454 );455 456 broadcastPasskeyProGate();457 return;458 }459460 if (!vault.isUnlocked()) {461 console.log("[Peach passkey] vault locked, summoning inline unlock");462 463 464 465 466 467 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>468 chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)469 );470 const activeTab = tabs[0];471 console.log("[Peach passkey] active tab for unlock summon", { id: activeTab?.id, url: activeTab?.url });472 if (activeTab?.id) {473 chrome.tabs.sendMessage(activeTab.id, {474 type: 'SUMMON_AUTOFILL',475 source: 'passkey-unlock',476 }).catch((err) => {477 console.warn("[Peach passkey] SUMMON_AUTOFILL failed, falling back to popup", err);478 void openApprovalPopup();479 });480 } else {481 await openApprovalPopup();482 }483484 const unlocked = await new Promise<boolean>((resolve) => {485 const timeoutId = setTimeout(() => {486 unlockWaiters.delete(request.requestId);487 resolve(false);488 }, APPROVAL_TIMEOUT_MS);489 unlockWaiters.set(request.requestId, { resolve, timeoutId });490 });491492 console.log("[Peach passkey] unlock wait resolved", { unlocked, vaultUnlocked: vault.isUnlocked() });493 if (!unlocked || !vault.isUnlocked()) {494 console.warn("[Peach passkey] reject: unlock timeout");495 await rejectCreate(request.requestId, "NotAllowedError", "Not unlocked in time.");496 return;497 }498 499 }500501 console.log("[Peach passkey] requesting approval via popup");502 const approved = await promptUserApproval({503 requestId: request.requestId,504 type: "create",505 rpId,506 rpName,507 userName,508 userDisplayName,509 });510 console.log("[Peach passkey] approval resolved", { approved });511 if (!approved) {512 console.warn("[Peach passkey] reject: user denied");513 await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");514 return;515 }516517 const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);518 if (!algorithm) {519 console.warn("[Peach passkey] reject: no supported algorithm", options.pubKeyCredParams);520 await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");521 return;522 }523 console.log("[Peach passkey] algorithm selected", algorithm);524525 const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));526 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(527 algorithm528 );529 const authData = await buildAuthenticatorData({530 rpId,531 flags: 0x4d,532 signCount: 0,533 credentialId: credentialIdBytes,534 publicKeyCose,535 });536 const attestationObject = createAttestationObject(authData);537 const clientDataJSON = buildClientDataJSON(538 "webauthn.create",539 options.challenge,540 pageOrigin541 );542543 const passkey: PasskeyData = {544 rpId,545 rpName,546 credentialId: base64urlEncode(credentialIdBytes),547 publicKeyCose: base64urlEncode(publicKeyCose),548 privateKeyJwk,549 userHandle: options.user.id,550 userName,551 userDisplayName,552 algorithm,553 signCount: 0,554 aaguid: base64urlEncode(new Uint8Array(16)),555 backupEligible: false,556 backupState: false,557 browser: "chrome",558 transports: ["internal"],559 createdAt: Date.now(),560 };561562 563 564 const target = findAttachTarget(rpId);565 if (target) {566 await vault.updateEntry(target.id, { passkey });567 } else {568 await vault.addEntry({569 type: "login",570 title: rpName,571 url: `https://${rpId}`,572 username: userName,573 passkey,574 });575 }576 577 578 579 await vault.consolidatePasskeyEntries();580581 try {582 await chrome.webAuthenticationProxy.completeCreateRequest({583 requestId: request.requestId,584 responseJson: JSON.stringify({585 id: passkey.credentialId,586 rawId: passkey.credentialId,587 type: "public-key",588 response: {589 clientDataJSON: base64urlEncode(clientDataJSON),590 authenticatorData: base64urlEncode(authData),591 attestationObject: base64urlEncode(attestationObject),592 transports: ["internal"],593 publicKey: base64urlEncode(publicKeySpki),594 publicKeyAlgorithm: algorithm,595 },596 authenticatorAttachment: "platform",597 clientExtensionResults: {},598 }),599 });600 console.log("[Peach passkey] completeCreateRequest success", { requestId: request.requestId });601 } catch (err) {602 if (isInvalidRequestIdError(err)) {603 console.log("[Peach passkey] completeCreateRequest skipped (already cancelled)", { requestId: request.requestId });604 return;605 }606 throw err;607 }608}609610function broadcastPasskeyProGate(): void {611 chrome.tabs.query({}, (tabs) => {612 for (const tab of tabs) {613 if (tab.id !== undefined) {614 chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});615 }616 }617 });618}619620async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {621 if (chrome.storage.session) {622 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });623 }624625 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {626 chrome.tabs.query({ active: true, currentWindow: true }, resolve);627 });628 const tab = tabs[0];629630 if (tab?.id) {631 chrome.tabs.sendMessage(tab.id, {632 type: 'SHOW_PASSKEY_TOAST',633 payload: {634 site: request.rpId,635 displayName: request.userDisplayName ?? request.userName,636 username: request.userName,637 requestId: String(request.requestId),638 },639 }).catch(() => {});640 } else {641 await openApprovalPopup();642 }643644 return new Promise<boolean>((resolve) => {645 const timeoutId = setTimeout(async () => {646 approvals.delete(request.requestId);647 await clearPendingRequest(request.requestId);648 resolve(false);649 }, APPROVAL_TIMEOUT_MS);650651 approvals.set(request.requestId, { resolve, timeoutId });652 });653}654655async function handleGetRequest(656 request: chrome.webAuthenticationProxy.GetRequest657): Promise<void> {658 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;659 if (!vault.isUnlocked()) {660 661 662 663 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>664 chrome.tabs.query({ active: true, currentWindow: true }, resolve)665 );666 const activeTab = tabs[0];667 if (activeTab?.id) {668 chrome.tabs.sendMessage(activeTab.id, {669 type: 'SUMMON_AUTOFILL',670 source: 'passkey-unlock',671 }).catch(() => { void openApprovalPopup(); });672 } else {673 await openApprovalPopup();674 }675676 677 const unlocked = await new Promise<boolean>((resolve) => {678 const timeoutId = setTimeout(() => {679 unlockWaiters.delete(request.requestId);680 resolve(false);681 }, APPROVAL_TIMEOUT_MS);682 unlockWaiters.set(request.requestId, { resolve, timeoutId });683 });684685 if (!unlocked || !vault.isUnlocked()) {686 await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");687 return;688 }689 690 }691692 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {693 await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");694 broadcastPasskeyProGate();695 return;696 }697698 const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>699 entry.passkey !== undefined && !entry.deletedAt700 );701 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);702703 const match = passkeyBearing.find((entry) => {704 if (allowedCredentialIds.length > 0) {705 return allowedCredentialIds.includes(entry.passkey.credentialId);706 }707 if (!options.rpId) return false;708 return entry.passkey.rpId === options.rpId;709 });710711 if (!match) {712 713 714 715 716 717 718 console.log("[Peach passkey] no matching credential — passing through to platform", { requestId: request.requestId });719 try {720 await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });721 } catch (err) {722 if (isInvalidRequestIdError(err)) return;723 if (isMissingResponseJsonError(err)) return;724 throw err;725 }726 return;727 }728729 const credential = match.passkey;730 731 732 733 if (!credential.privateKeyJwk) {734 console.log("[Peach passkey] no private key (Firefox passkey) — passing through to platform", { requestId: request.requestId });735 try {736 await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });737 } catch (err) {738 if (isInvalidRequestIdError(err)) return;739 if (isMissingResponseJsonError(err)) return;740 throw err;741 }742 return;743 }744745 const approved = await promptViaToast({746 requestId: request.requestId,747 type: "get",748 rpId: credential.rpId,749 rpName: credential.rpName,750 userName: credential.userName,751 userDisplayName: credential.userDisplayName,752 });753 if (!approved) {754 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");755 return;756 }757758 const nextSignCount = credential.signCount + 1;759 const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);760 const authData = await buildAuthenticatorData({761 rpId: credential.rpId,762 flags,763 signCount: nextSignCount,764 });765 const clientDataJSON = buildClientDataJSON(766 "webauthn.get",767 options.challenge,768 await resolvePageOrigin(credential.rpId)769 );770 const clientDataHash = new Uint8Array(771 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))772 );773 const signature = await signPasskeyAssertion(774 credential.privateKeyJwk,775 credential.algorithm,776 authData,777 clientDataHash778 );779780 await vault.updateEntry(match.id, {781 passkey: {782 ...credential,783 signCount: nextSignCount,784 lastUsedAt: Date.now(),785 },786 });787788 const responseJson = JSON.stringify({789 id: credential.credentialId,790 rawId: credential.credentialId,791 type: "public-key",792 response: {793 clientDataJSON: base64urlEncode(clientDataJSON),794 authenticatorData: base64urlEncode(authData),795 signature: base64urlEncode(signature),796 userHandle: credential.userHandle,797 },798 authenticatorAttachment: "platform",799 clientExtensionResults: {},800 });801802 try {803 await chrome.webAuthenticationProxy.completeGetRequest({804 requestId: request.requestId,805 responseJson,806 });807 console.log("[Peach passkey] completeGetRequest success", { requestId: request.requestId });808 } catch (err) {809 if (isInvalidRequestIdError(err)) {810 console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId: request.requestId });811 return;812 }813 throw err;814 }815}816817818819820821822823824825826const FIREFOX_PENDING_KEY = "passkey_pending_firefox";827const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";828const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; 829830export async function initiateFirefoxPasskeyCreate(args: {831 rpId: string;832 rpName: string;833 userName: string;834 userDisplayName?: string;835}, callerOrigin?: string): Promise<{ ok: boolean; error?: string }> {836 837 838 839 840 841 if (!callerOrigin || !isValidRpId(args.rpId, callerOrigin)) {842 return { ok: false, error: "rpid_origin_mismatch" };843 }844 if (!(await isProUser())) {845 broadcastPasskeyProGate();846 return { ok: false, error: "passkey creation requires Peach Pro" };847 }848 if (!vault.isUnlocked()) {849 await openApprovalPopup();850 return { ok: false, error: "vault_locked" };851 }852 if (!chrome.storage.session) {853 return { ok: false, error: "session storage unavailable" };854 }855856 const requestId = randomRequestId();857 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));858 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));859860 const pending: PendingPasskeyRequest = {861 requestId,862 type: "create",863 rpId: args.rpId,864 rpName: args.rpName,865 userName: args.userName,866 userDisplayName: args.userDisplayName,867 browser: "firefox",868 challenge,869 userHandle,870 };871872 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });873 await openApprovalPopup();874 return { ok: true };875}876877export async function completeFirefoxPasskeyCreate(payload: {878 requestId: number;879 rpId: string;880 rpName: string;881 userName: string;882 userDisplayName?: string;883 userHandle: string;884 credentialId: string;885 publicKeyCose: string;886 algorithm: -7 | -8;887 aaguid?: string;888 transports?: string[];889 backupEligible?: boolean;890 backupState?: boolean;891}): Promise<{ ok: boolean; error?: string }> {892 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };893894 895 896 897 898 899 900 const pending = chrome.storage.session901 ? ((await chrome.storage.session.get(FIREFOX_PENDING_KEY))[FIREFOX_PENDING_KEY] as902 | PendingPasskeyRequest903 | undefined)904 : undefined;905 if (906 !pending ||907 pending.requestId !== payload.requestId ||908 pending.rpId !== payload.rpId ||909 pending.userHandle !== payload.userHandle910 ) {911 return { ok: false, error: "no_pending_request" };912 }913914 const passkey: PasskeyData = {915 rpId: payload.rpId,916 rpName: payload.rpName,917 credentialId: payload.credentialId,918 publicKeyCose: payload.publicKeyCose,919 userHandle: payload.userHandle,920 userName: payload.userName,921 userDisplayName: payload.userDisplayName ?? payload.userName,922 algorithm: payload.algorithm,923 signCount: 0,924 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),925 backupEligible: payload.backupEligible ?? false,926 backupState: payload.backupState ?? false,927 browser: "firefox",928 transports: payload.transports ?? ["internal"],929 createdAt: Date.now(),930 };931932 933 934 935 const target = findAttachTarget(payload.rpId);936 if (target) {937 await vault.updateEntry(target.id, { passkey });938 } else {939 await vault.addEntry({940 type: "login",941 title: payload.rpName || payload.rpId,942 url: `https://${payload.rpId}`,943 username: payload.userName,944 passkey,945 });946 }947 await vault.consolidatePasskeyEntries();948949 if (chrome.storage.session) {950 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);951 }952 return { ok: true };953}954955function declineKey(domain: string): string {956 return `${FIREFOX_DECLINE_PREFIX}${domain}`;957}958959export async function recordPasskeyOfferDecline(domain: string): Promise<void> {960 if (!domain) return;961 await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });962}963964export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {965 if (!domain) return false;966 const key = declineKey(domain);967 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;968 const at = stored[key];969 if (typeof at !== "number") return false;970 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {971 await chrome.storage.local.remove(key);972 return false;973 }974 return true;975}976977978979980981982983984985986987988989990interface FirefoxAllowCredential {991 id: string;992 type?: string;993}994995interface FirefoxPubKeyCredParam {996 type?: string;997 alg: number;998}9991000export interface FirefoxPasskeyHasCredentialsPayload {1001 rpId: string;1002 allowCredentials: FirefoxAllowCredential[];1003}10041005export interface FirefoxPasskeyCreatePayload {1006 rpId: string;1007 rpName?: string;1008 userId: string;1009 userName: string;1010 userDisplayName?: string;1011 challenge: string;1012 clientDataJSON: number[];1013 origin: string;1014 pubKeyCredParams?: FirefoxPubKeyCredParam[];1015 authenticatorSelection?: Record<string, unknown>;1016 timeout?: number;1017 attestation?: string;1018}10191020export interface FirefoxPasskeyGetPayload {1021 rpId: string;1022 challenge: string;1023 clientDataJSON: number[];1024 origin: string;1025 allowCredentials: FirefoxAllowCredential[];1026 userVerification?: string;1027}10281029interface FirefoxCreateCredentialResponse {1030 type: "create";1031 id: string;1032 clientDataJSON: number[];1033 attestationObject: string;1034 authenticatorData: string;1035 publicKey: string;1036 algorithm: -7 | -8;1037}10381039interface FirefoxGetCredentialResponse {1040 type: "get";1041 id: string;1042 clientDataJSON: number[];1043 authenticatorData: string;1044 signature: string;1045 userHandle: string;1046}10471048type FirefoxPasskeyResult<T> =1049 | { ok: true; credential: T }1050 | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };10511052function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {1053 return vault.getEntries().filter(1054 (entry): entry is VaultEntry & { passkey: PasskeyData } =>1055 entry.passkey !== undefined && !entry.deletedAt1056 );1057}105810591060function clientDataJsonOrigin(bytes: number[] | undefined): string | null {1061 if (!Array.isArray(bytes)) return null;1062 try {1063 const obj = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes))) as { origin?: unknown };1064 return typeof obj.origin === "string" ? obj.origin : null;1065 } catch {1066 return null;1067 }1068}106910701071107210731074107510761077107810791080function assertOriginOwnsRpId(1081 rpId: string,1082 callerOrigin: string | undefined,1083 clientDataJSON?: number[],1084): void {1085 if (!callerOrigin || !isValidRpId(rpId, callerOrigin)) {1086 throw new DOMException(1087 `SecurityError: relying party ID "${rpId}" is not a registrable domain ` +1088 `suffix of the calling origin "${callerOrigin ?? "(unknown)"}".`,1089 "SecurityError",1090 );1091 }1092 const cdjOrigin = clientDataJsonOrigin(clientDataJSON);1093 if (cdjOrigin !== null && cdjOrigin !== callerOrigin) {1094 throw new DOMException(1095 `SecurityError: clientDataJSON origin "${cdjOrigin}" does not match the ` +1096 `attested calling origin "${callerOrigin}".`,1097 "SecurityError",1098 );1099 }1100}11011102export async function handlePasskeyHasCredentials(1103 payload: FirefoxPasskeyHasCredentialsPayload,1104 callerOrigin?: string,1105): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {1106 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1107 return { ok: false, error: "not_applicable" };1108 }1109 1110 1111 1112 if (!callerOrigin || !isValidRpId(payload.rpId, callerOrigin)) {1113 return { ok: true, hasCredentials: false };1114 }1115 if (!vault.isUnlocked()) {1116 1117 1118 1119 return { ok: true, hasCredentials: false };1120 }1121 const passkeys = listPasskeyEntries().filter(1122 (entry) => entry.passkey.rpId === payload.rpId1123 );1124 if (passkeys.length === 0) return { ok: true, hasCredentials: false };11251126 const allow = payload.allowCredentials ?? [];1127 if (allow.length === 0) return { ok: true, hasCredentials: true };11281129 const hasMatch = passkeys.some((entry) =>1130 allow.some((ac) => ac.id === entry.passkey.credentialId)1131 );1132 return { ok: true, hasCredentials: hasMatch };1133}113411351136113711381139114011411142async function requestFirefoxApproval(args: {1143 type: "create" | "get";1144 rpId: string;1145 rpName?: string;1146 userName: string;1147 userDisplayName?: string;1148}): Promise<boolean> {1149 1150 1151 const requestId = randomRequestId();1152 return promptUserApproval({1153 requestId,1154 type: args.type,1155 rpId: args.rpId,1156 rpName: args.rpName ?? args.rpId,1157 userName: args.userName,1158 userDisplayName: args.userDisplayName,1159 });1160}11611162export async function handleFirefoxPasskeyCreate(1163 payload: FirefoxPasskeyCreatePayload,1164 callerOrigin?: string,1165): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {1166 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1167 return { ok: false, error: "not_applicable" };1168 }1169 1170 1171 1172 1173 1174 assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1175 if (!(await isProUser())) {1176 broadcastPasskeyProGate();1177 return { ok: false, error: "pro_required" };1178 }1179 if (!vault.isUnlocked()) {1180 await openApprovalPopup();1181 return { ok: false, error: "vault_locked" };1182 }11831184 const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);1185 if (!algorithm) {1186 return { ok: false, error: "internal_error", message: "No supported algorithm offered" };1187 }11881189 const approved = await requestFirefoxApproval({1190 type: "create",1191 rpId: payload.rpId,1192 rpName: payload.rpName,1193 userName: payload.userName,1194 userDisplayName: payload.userDisplayName,1195 });1196 if (!approved) return { ok: false, error: "user_cancelled" };11971198 const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1199 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);12001201 1202 1203 const authenticatorData = await buildAuthenticatorData({1204 rpId: payload.rpId,1205 flags: 0x45,1206 signCount: 0,1207 credentialId: credentialIdBytes,1208 publicKeyCose,1209 });1210 const attestationObject = createAttestationObject(authenticatorData);12111212 const passkey: PasskeyData = {1213 rpId: payload.rpId,1214 rpName: payload.rpName ?? payload.rpId,1215 credentialId,1216 publicKeyCose: base64urlEncode(publicKeyCose),1217 privateKeyJwk,1218 userHandle: payload.userId,1219 userName: payload.userName,1220 userDisplayName: payload.userDisplayName ?? payload.userName,1221 algorithm,1222 signCount: 0,1223 aaguid: base64urlEncode(new Uint8Array(16)),1224 backupEligible: false,1225 backupState: false,1226 browser: "firefox",1227 transports: ["internal"],1228 createdAt: Date.now(),1229 };12301231 const target = findAttachTarget(payload.rpId);1232 if (target) {1233 await vault.updateEntry(target.id, { passkey });1234 } else {1235 await vault.addEntry({1236 type: "login",1237 title: payload.rpName || payload.rpId,1238 url: `https://${payload.rpId}`,1239 username: payload.userName,1240 passkey,1241 });1242 }1243 await vault.consolidatePasskeyEntries();12441245 return {1246 ok: true,1247 credential: {1248 type: "create",1249 id: credentialId,1250 1251 1252 1253 clientDataJSON: payload.clientDataJSON,1254 attestationObject: base64urlEncode(attestationObject),1255 authenticatorData: base64urlEncode(authenticatorData),1256 publicKey: base64urlEncode(publicKeySpki),1257 algorithm,1258 },1259 };1260}12611262export async function handleFirefoxPasskeyGet(1263 payload: FirefoxPasskeyGetPayload,1264 callerOrigin?: string,1265): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1266 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1267 return { ok: false, error: "not_applicable" };1268 }1269 1270 1271 1272 1273 1274 1275 assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1276 if (!(await isProUser())) {1277 broadcastPasskeyProGate();1278 return { ok: false, error: "pro_required" };1279 }1280 if (!vault.isUnlocked()) {1281 await openApprovalPopup();1282 return { ok: false, error: "vault_locked" };1283 }12841285 const allow = payload.allowCredentials ?? [];1286 const candidates = listPasskeyEntries().filter((entry) => {1287 if (entry.passkey.rpId !== payload.rpId) return false;1288 if (allow.length === 0) return true;1289 return allow.some((ac) => ac.id === entry.passkey.credentialId);1290 });12911292 if (candidates.length === 0) return { ok: false, error: "no_credentials" };12931294 1295 1296 1297 candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1298 const match = candidates[0]!;1299 const credential = match.passkey;13001301 if (!credential.privateKeyJwk) {1302 1303 1304 1305 return { ok: false, error: "no_credentials" };1306 }13071308 const approved = await requestFirefoxApproval({1309 type: "get",1310 rpId: credential.rpId,1311 rpName: credential.rpName,1312 userName: credential.userName,1313 userDisplayName: credential.userDisplayName,1314 });1315 if (!approved) return { ok: false, error: "user_cancelled" };13161317 const nextSignCount = credential.signCount + 1;1318 1319 1320 const flags =1321 0x05 |1322 (credential.backupEligible ? 0x08 : 0) |1323 (credential.backupState ? 0x10 : 0);1324 const authenticatorData = await buildAuthenticatorData({1325 rpId: credential.rpId,1326 flags,1327 signCount: nextSignCount,1328 });13291330 1331 1332 1333 const clientDataBytes = new Uint8Array(payload.clientDataJSON);1334 const clientDataHash = new Uint8Array(1335 await crypto.subtle.digest("SHA-256", clientDataBytes)1336 );1337 const signature = await signPasskeyAssertion(1338 credential.privateKeyJwk,1339 credential.algorithm,1340 authenticatorData,1341 clientDataHash1342 );13431344 await vault.updateEntry(match.id, {1345 passkey: {1346 ...credential,1347 signCount: nextSignCount,1348 lastUsedAt: Date.now(),1349 },1350 });13511352 return {1353 ok: true,1354 credential: {1355 type: "get",1356 id: credential.credentialId,1357 clientDataJSON: payload.clientDataJSON,1358 authenticatorData: base64urlEncode(authenticatorData),1359 signature: base64urlEncode(signature),1360 userHandle: credential.userHandle,1361 },1362 };1363}13641365export async function setupPasskeyProxy(): Promise<void> {1366 if (!isPasskeySupported()) {1367 console.log("[Peach passkey] setupPasskeyProxy: not supported on this browser");1368 return;1369 }13701371 1372 1373 if (detectSharedBrowser() === "firefox") {1374 await refreshPasskeyProxy();1375 return;1376 }13771378 if (!listenersRegistered) {1379 console.log("[Peach passkey] registering webAuthenticationProxy listeners");1380 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1381 1382 1383 1384 1385 1386 1387 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1388 requestId: requestInfo.requestId,1389 isUvpaa: true,1390 });1391 });1392 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1393 console.log("[Peach passkey] onCreateRequest fired", { requestId: requestInfo.requestId });1394 void handleCreateRequest(requestInfo).catch(async (error) => {1395 1396 1397 1398 if (isInvalidRequestIdError(error)) {1399 console.log("[Peach passkey] create handler resolved after cancel", { requestId: requestInfo.requestId });1400 return;1401 }1402 console.error("Peach passkey create failed", error);1403 await rejectCreate(1404 requestInfo.requestId,1405 "UnknownError",1406 error instanceof Error ? error.message : "Passkey creation failed."1407 );1408 });1409 });1410 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1411 console.log("[Peach passkey] onGetRequest fired", { requestId: requestInfo.requestId });1412 void handleGetRequest(requestInfo).catch(async (error) => {1413 if (isInvalidRequestIdError(error)) {1414 console.log("[Peach passkey] get handler resolved after cancel", { requestId: requestInfo.requestId });1415 return;1416 }1417 console.error("Peach passkey get failed", error);1418 await rejectGet(1419 requestInfo.requestId,1420 "UnknownError",1421 error instanceof Error ? error.message : "Passkey authentication failed."1422 );1423 });1424 });1425 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1426 console.log("[Peach passkey] onRequestCanceled", { requestId });1427 markCancelled(requestId);1428 1429 const waiter = unlockWaiters.get(requestId);1430 if (waiter) {1431 clearTimeout(waiter.timeoutId);1432 waiter.resolve(false);1433 unlockWaiters.delete(requestId);1434 }1435 void resolvePendingPasskeyRequest(requestId, false);1436 });1437 listenersRegistered = true;1438 }14391440 await refreshPasskeyProxy();1441}