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> }>();4142export function onVaultUnlocked(): void {43 for (const { resolve, timeoutId } of unlockWaiters.values()) {44 clearTimeout(timeoutId);45 resolve(true);46 }47 unlockWaiters.clear();48}4950515253545556export async function denyAllPendingPasskeyRequests(): Promise<void> {57 if (approvals.size === 0 && unlockWaiters.size === 0) return;58 for (const [rid, { resolve, timeoutId }] of approvals.entries()) {59 clearTimeout(timeoutId);60 resolve(false);61 approvals.delete(rid);62 await clearPendingRequest(rid).catch(() => {});63 }64 for (const { resolve, timeoutId } of unlockWaiters.values()) {65 clearTimeout(timeoutId);66 resolve(false);67 }68 unlockWaiters.clear();69}7071function isPasskeySupported(): boolean {72 if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;73 74 75 76 77 if (detectSharedBrowser() === "firefox") {78 const ver = firefoxVersion();79 if (Number.isFinite(ver) && ver >= 150) return true;80 }81 return false;82}8384export const detectBrowser = detectSharedBrowser;8586function getBrowserLabel(): string {87 const ua = navigator.userAgent;88 if (/Firefox\/(\d+)/.test(ua)) {89 const version = ua.match(/Firefox\/(\d+)/)?.[1];90 return version ? `Firefox ${version}` : "Firefox";91 }92 const version = ua.match(/Chrome\/(\d+)/)?.[1];93 return version ? `Chrome ${version}` : "Chromium";94}9596function pendingStorageKey(requestId: number): string {97 return `${PENDING_PREFIX}${requestId}`;98}99100function randomRequestId(): number {101 102 103 104 105 const u = crypto.getRandomValues(new Uint32Array(2));106 return u[0] * 0x200000 + (u[1] >>> 11);107}108109function originFromRpId(rpId: string): string {110 return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)111 ? `http://${rpId}`112 : `https://${rpId}`;113}114115116117118119120121122123124125126127128129130async function resolvePageOrigin(rpId: string): Promise<string> {131 try {132 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>133 chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)134 );135 const url = tabs[0]?.url;136 if (url) {137 const parsed = new URL(url);138 const host = parsed.hostname.toLowerCase();139 const rp = rpId.toLowerCase();140 if (host === rp || host.endsWith(`.${rp}`)) {141 return parsed.origin;142 }143 }144 } catch {145 146 }147 return originFromRpId(rpId);148}149150async function setBadge(text: string): Promise<void> {151 if (!chrome.action?.setBadgeText) return;152 await chrome.action.setBadgeText({ text });153 if (text) {154 await chrome.action.setBadgeBackgroundColor({ color: "#E5805C" });155 }156}157158async function clearPendingRequest(requestId: number): Promise<void> {159 if (chrome.storage.session) {160 await chrome.storage.session.remove(pendingStorageKey(requestId));161 }162 await setBadge("");163}164165async function openApprovalPopup(): Promise<void> {166 try {167 await chrome.action.openPopup();168 } catch {169 await setBadge("PK");170 }171}172173174175176177178179180181function isInvalidRequestIdError(err: unknown): boolean {182 return err instanceof Error && /invalid requestid/i.test(err.message);183}184185186187188189190191192193194function isMissingResponseJsonError(err: unknown): boolean {195 return err instanceof Error && /missing getresponsedetails/i.test(err.message);196}197198async function rejectCreate(199 requestId: number,200 name: string,201 message = ""202): Promise<void> {203 try {204 await chrome.webAuthenticationProxy.completeCreateRequest({205 requestId,206 error: { name, message },207 });208 } catch (err) {209 if (isInvalidRequestIdError(err)) {210 return;211 }212 throw err;213 }214}215216async function rejectGet(217 requestId: number,218 name: string,219 message = ""220): Promise<void> {221 try {222 await chrome.webAuthenticationProxy.completeGetRequest({223 requestId,224 error: { name, message },225 });226 } catch (err) {227 if (isInvalidRequestIdError(err)) {228 return;229 }230 throw err;231 }232}233234export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {235 if (!chrome.storage.session) return null; 236 const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;237 const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));238 if (!key) return null;239 const pending = stored[key];240 return pending && typeof pending === "object"241 ? (pending as PendingPasskeyRequest)242 : null;243}244245export async function resolvePendingPasskeyRequest(246 requestId: number,247 approved: boolean248): Promise<void> {249 const pending = approvals.get(requestId);250 if (!pending) {251 252 253 254 255 await clearPendingRequest(requestId);256 if (chrome.storage.session) {257 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);258 }259 return;260 }261262 clearTimeout(pending.timeoutId);263 approvals.delete(requestId);264 await clearPendingRequest(requestId);265 pending.resolve(approved);266}267268async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {269 if (chrome.storage.session) {270 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });271 }272 await openApprovalPopup();273274 return new Promise<boolean>((resolve) => {275 const timeoutId = setTimeout(async () => {276 approvals.delete(request.requestId);277 await clearPendingRequest(request.requestId);278 resolve(false);279 }, APPROVAL_TIMEOUT_MS);280281 approvals.set(request.requestId, { resolve, timeoutId });282 });283}284285export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {286 const settings = await vault.ensureSettingsLoaded();287 const supported = isPasskeySupported();288 const enabled = settings.passkeyProviderEnabled === true;289 return {290 supported,291 enabled,292 attached: supported && attached,293 browserLabel: getBrowserLabel(),294 statusLabel: !supported295 ? "Interception unavailable"296 : attached297 ? "Active"298 : conflict299 ? "Another provider is active"300 : enabled301 ? "Inactive"302 : "Disabled",303 conflict,304 };305}306307async function attachPasskeyProxy(): Promise<void> {308 if (!isPasskeySupported()) {309 attached = false;310 conflict = "This browser does not expose chrome.webAuthenticationProxy.";311 return;312 }313314 315 316 317 if (detectSharedBrowser() === "firefox") {318 attached = true;319 conflict = undefined;320 return;321 }322323 if (attached) return;324325 try {326 await chrome.webAuthenticationProxy.attach();327 attached = true;328 conflict = undefined;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 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;402 const rpId = options.rp.id;403 const rpName = options.rp.name ?? options.rp.id;404 const userName = options.user.name;405 const userDisplayName = options.user.displayName ?? options.user.name;406 if (!rpId) {407 console.warn("[Peach passkey] reject: missing rp.id");408 await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");409 return;410 }411412 413 414 415 416 417 418 const pageOrigin = await resolvePageOrigin(rpId);419420 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {421 console.warn("[Peach passkey] reject: user is not Pro");422 await rejectCreate(423 request.requestId,424 "NotAllowedError",425 "Passkey creation requires Peach Pro."426 );427 428 broadcastPasskeyProGate();429 return;430 }431432 if (!vault.isUnlocked()) {433 434 435 436 437 438 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>439 chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)440 );441 const activeTab = tabs[0];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 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 const approved = await promptUserApproval({471 requestId: request.requestId,472 type: "create",473 rpId,474 rpName,475 userName,476 userDisplayName,477 });478 if (!approved) {479 console.warn("[Peach passkey] reject: user denied");480 await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");481 return;482 }483484 const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);485 if (!algorithm) {486 console.warn("[Peach passkey] reject: no supported algorithm", options.pubKeyCredParams);487 await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");488 return;489 }490 const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));491 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(492 algorithm493 );494 const authData = await buildAuthenticatorData({495 rpId,496 flags: 0x4d,497 signCount: 0,498 credentialId: credentialIdBytes,499 publicKeyCose,500 });501 const attestationObject = createAttestationObject(authData);502 const clientDataJSON = buildClientDataJSON(503 "webauthn.create",504 options.challenge,505 pageOrigin506 );507508 const passkey: PasskeyData = {509 rpId,510 rpName,511 credentialId: base64urlEncode(credentialIdBytes),512 publicKeyCose: base64urlEncode(publicKeyCose),513 privateKeyJwk,514 userHandle: options.user.id,515 userName,516 userDisplayName,517 algorithm,518 signCount: 0,519 aaguid: base64urlEncode(new Uint8Array(16)),520 backupEligible: true,521 backupState: false,522 browser: "chrome",523 transports: ["internal"],524 createdAt: Date.now(),525 };526527 528 529 const target = findAttachTarget(rpId);530 if (target) {531 await vault.updateEntry(target.id, { passkey });532 } else {533 await vault.addEntry({534 type: "login",535 title: rpName,536 url: `https://${rpId}`,537 username: userName,538 passkey,539 });540 }541 542 543 544 await vault.consolidatePasskeyEntries();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 } catch (err) {566 if (isInvalidRequestIdError(err)) {567 return;568 }569 throw err;570 }571}572573function broadcastPasskeyProGate(): void {574 chrome.tabs.query({}, (tabs) => {575 for (const tab of tabs) {576 if (tab.id !== undefined) {577 chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});578 }579 }580 });581}582583async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {584 if (chrome.storage.session) {585 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });586 }587588 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {589 chrome.tabs.query({ active: true, currentWindow: true }, resolve);590 });591 const tab = tabs[0];592593 if (tab?.id) {594 chrome.tabs.sendMessage(tab.id, {595 type: 'SHOW_PASSKEY_TOAST',596 payload: {597 toastToken: crypto.randomUUID(),598 site: request.rpId,599 displayName: request.userDisplayName ?? request.userName,600 username: request.userName,601 requestId: String(request.requestId),602 },603 }).catch(() => {});604 } else {605 await openApprovalPopup();606 }607608 return new Promise<boolean>((resolve) => {609 const timeoutId = setTimeout(async () => {610 approvals.delete(request.requestId);611 await clearPendingRequest(request.requestId);612 resolve(false);613 }, APPROVAL_TIMEOUT_MS);614615 approvals.set(request.requestId, { resolve, timeoutId });616 });617}618619async function handleGetRequest(620 request: chrome.webAuthenticationProxy.GetRequest621): Promise<void> {622 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;623 if (!vault.isUnlocked()) {624 625 626 627 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>628 chrome.tabs.query({ active: true, currentWindow: true }, resolve)629 );630 const activeTab = tabs[0];631 if (activeTab?.id) {632 chrome.tabs.sendMessage(activeTab.id, {633 type: 'SUMMON_AUTOFILL',634 source: 'passkey-unlock',635 }).catch(() => { void openApprovalPopup(); });636 } else {637 await openApprovalPopup();638 }639640 641 const unlocked = await new Promise<boolean>((resolve) => {642 const timeoutId = setTimeout(() => {643 unlockWaiters.delete(request.requestId);644 resolve(false);645 }, APPROVAL_TIMEOUT_MS);646 unlockWaiters.set(request.requestId, { resolve, timeoutId });647 });648649 if (!unlocked || !vault.isUnlocked()) {650 await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");651 return;652 }653 654 }655656 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {657 await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");658 broadcastPasskeyProGate();659 return;660 }661662 const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>663 entry.passkey !== undefined && !entry.deletedAt664 );665 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);666667 const match = passkeyBearing.find((entry) => {668 669 670 671 if (options.rpId && entry.passkey.rpId !== options.rpId) return false;672 if (allowedCredentialIds.length > 0) {673 return allowedCredentialIds.includes(entry.passkey.credentialId);674 }675 if (!options.rpId) return false;676 return entry.passkey.rpId === options.rpId;677 });678679 if (!match) {680 681 682 683 684 685 686 try {687 await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });688 } catch (err) {689 if (isInvalidRequestIdError(err)) return;690 if (isMissingResponseJsonError(err)) return;691 throw err;692 }693 return;694 }695696 const credential = match.passkey;697 698 699 700 if (!credential.privateKeyJwk) {701 try {702 await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });703 } catch (err) {704 if (isInvalidRequestIdError(err)) return;705 if (isMissingResponseJsonError(err)) return;706 throw err;707 }708 return;709 }710711 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {712 chrome.tabs.query({ active: true, currentWindow: true }, resolve);713 });714 const approvalBinding = {715 tabId: tabs[0]?.id,716 origin: await resolvePageOrigin(credential.rpId),717 };718 const approved = await promptViaToast({719 requestId: request.requestId,720 type: "get",721 rpId: credential.rpId,722 rpName: credential.rpName,723 userName: credential.userName,724 userDisplayName: credential.userDisplayName,725 ...approvalBinding,726 });727 if (!approved) {728 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");729 return;730 }731732 const nextSignCount = credential.signCount + 1;733 const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);734 const authData = await buildAuthenticatorData({735 rpId: credential.rpId,736 flags,737 signCount: nextSignCount,738 });739 const clientDataJSON = buildClientDataJSON(740 "webauthn.get",741 options.challenge,742 await resolvePageOrigin(credential.rpId)743 );744 const clientDataHash = new Uint8Array(745 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))746 );747 const signature = await signPasskeyAssertion(748 credential.privateKeyJwk,749 credential.algorithm,750 authData,751 clientDataHash752 );753754 await vault.updateEntry(match.id, {755 passkey: {756 ...credential,757 signCount: nextSignCount,758 lastUsedAt: Date.now(),759 },760 });761762 const responseJson = JSON.stringify({763 id: credential.credentialId,764 rawId: credential.credentialId,765 type: "public-key",766 response: {767 clientDataJSON: base64urlEncode(clientDataJSON),768 authenticatorData: base64urlEncode(authData),769 signature: base64urlEncode(signature),770 userHandle: credential.userHandle,771 },772 authenticatorAttachment: "platform",773 clientExtensionResults: {},774 });775776 try {777 await chrome.webAuthenticationProxy.completeGetRequest({778 requestId: request.requestId,779 responseJson,780 });781 } catch (err) {782 if (isInvalidRequestIdError(err)) {783 return;784 }785 throw err;786 }787}788789790791792793794795796797798const FIREFOX_PENDING_KEY = "passkey_pending_firefox";799const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";800const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; 801802export async function initiateFirefoxPasskeyCreate(args: {803 rpId: string;804 rpName: string;805 userName: string;806 userDisplayName?: string;807}, callerOrigin?: string): Promise<{ ok: boolean; error?: string }> {808 809 810 811 812 813 if (!callerOrigin || !isValidRpId(args.rpId, callerOrigin)) {814 return { ok: false, error: "rpid_origin_mismatch" };815 }816 if (!(await isProUser())) {817 broadcastPasskeyProGate();818 return { ok: false, error: "passkey creation requires Peach Pro" };819 }820 if (!vault.isUnlocked()) {821 await openApprovalPopup();822 return { ok: false, error: "vault_locked" };823 }824 if (!chrome.storage.session) {825 return { ok: false, error: "session storage unavailable" };826 }827828 const requestId = randomRequestId();829 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));830 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));831832 const pending: PendingPasskeyRequest = {833 requestId,834 type: "create",835 rpId: args.rpId,836 rpName: args.rpName,837 userName: args.userName,838 userDisplayName: args.userDisplayName,839 browser: "firefox",840 challenge,841 userHandle,842 };843844 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });845 await openApprovalPopup();846 return { ok: true };847}848849export async function completeFirefoxPasskeyCreate(payload: {850 requestId: number;851 rpId: string;852 rpName: string;853 userName: string;854 userDisplayName?: string;855 userHandle: string;856 credentialId: string;857 publicKeyCose: string;858 algorithm: -7 | -8;859 aaguid?: string;860 transports?: string[];861 backupEligible?: boolean;862 backupState?: boolean;863}): Promise<{ ok: boolean; error?: string }> {864 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };865866 867 868 869 870 871 872 const pending = chrome.storage.session873 ? ((await chrome.storage.session.get(FIREFOX_PENDING_KEY))[FIREFOX_PENDING_KEY] as874 | PendingPasskeyRequest875 | undefined)876 : undefined;877 if (878 !pending ||879 pending.requestId !== payload.requestId ||880 pending.rpId !== payload.rpId ||881 pending.userHandle !== payload.userHandle882 ) {883 return { ok: false, error: "no_pending_request" };884 }885886 const passkey: PasskeyData = {887 rpId: payload.rpId,888 rpName: payload.rpName,889 credentialId: payload.credentialId,890 publicKeyCose: payload.publicKeyCose,891 userHandle: payload.userHandle,892 userName: payload.userName,893 userDisplayName: payload.userDisplayName ?? payload.userName,894 algorithm: payload.algorithm,895 signCount: 0,896 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),897 backupEligible: payload.backupEligible ?? false,898 backupState: payload.backupState ?? false,899 browser: "firefox",900 transports: payload.transports ?? ["internal"],901 createdAt: Date.now(),902 };903904 905 906 907 const target = findAttachTarget(payload.rpId);908 if (target) {909 await vault.updateEntry(target.id, { passkey });910 } else {911 await vault.addEntry({912 type: "login",913 title: payload.rpName || payload.rpId,914 url: `https://${payload.rpId}`,915 username: payload.userName,916 passkey,917 });918 }919 await vault.consolidatePasskeyEntries();920921 if (chrome.storage.session) {922 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);923 }924 return { ok: true };925}926927const MAX_DECLINE_DOMAIN_LENGTH = 253;928929function normaliseDeclineDomain(domain: string): string | null {930 if (!domain || domain.length > MAX_DECLINE_DOMAIN_LENGTH) return null;931 try {932 const url = new URL(`https://${domain}`);933 return isValidRpId(url.hostname, url.origin) ? url.hostname : null;934 } catch {935 return null;936 }937}938939function declineKey(domain: string): string {940 return `${FIREFOX_DECLINE_PREFIX}${domain}`;941}942943export async function recordPasskeyOfferDecline(domain: string): Promise<void> {944 const validDomain = normaliseDeclineDomain(domain);945 if (!validDomain) return;946 await chrome.storage.local.set({ [declineKey(validDomain)]: Date.now() });947}948949export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {950 const validDomain = normaliseDeclineDomain(domain);951 if (!validDomain) return false;952 const key = declineKey(validDomain);953954 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;955 const at = stored[key];956 if (typeof at !== "number") return false;957 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {958 await chrome.storage.local.remove(key);959 return false;960 }961 return true;962}963964965966967968969970971972973974975976977interface FirefoxAllowCredential {978 id: string;979 type?: string;980}981982interface FirefoxPubKeyCredParam {983 type?: string;984 alg: number;985}986987export interface FirefoxPasskeyHasCredentialsPayload {988 rpId: string;989 allowCredentials: FirefoxAllowCredential[];990}991992export interface FirefoxPasskeyCreatePayload {993 rpId: string;994 rpName?: string;995 userId: string;996 userName: string;997 userDisplayName?: string;998 challenge: string;999 clientDataJSON: number[];1000 origin: string;1001 pubKeyCredParams?: FirefoxPubKeyCredParam[];1002 authenticatorSelection?: Record<string, unknown>;1003 timeout?: number;1004 attestation?: string;1005}10061007export interface FirefoxPasskeyGetPayload {1008 rpId: string;1009 challenge: string;1010 clientDataJSON: number[];1011 origin: string;1012 allowCredentials: FirefoxAllowCredential[];1013 userVerification?: string;1014}10151016interface FirefoxCreateCredentialResponse {1017 type: "create";1018 id: string;1019 clientDataJSON: number[];1020 attestationObject: string;1021 authenticatorData: string;1022 publicKey: string;1023 algorithm: -7 | -8;1024}10251026interface FirefoxGetCredentialResponse {1027 type: "get";1028 id: string;1029 clientDataJSON: number[];1030 authenticatorData: string;1031 signature: string;1032 userHandle: string;1033}10341035type FirefoxPasskeyResult<T> =1036 | { ok: true; credential: T }1037 | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };10381039function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {1040 return vault.getEntries().filter(1041 (entry): entry is VaultEntry & { passkey: PasskeyData } =>1042 entry.passkey !== undefined && !entry.deletedAt1043 );1044}104510461047function clientDataJsonOrigin(bytes: number[] | undefined): string | null {1048 if (!Array.isArray(bytes)) return null;1049 try {1050 const obj = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes))) as { origin?: unknown };1051 return typeof obj.origin === "string" ? obj.origin : null;1052 } catch {1053 return null;1054 }1055}105610571058105910601061106210631064106510661067function assertOriginOwnsRpId(1068 rpId: string,1069 callerOrigin: string | undefined,1070 clientDataJSON?: number[],1071): void {1072 if (!callerOrigin || !isValidRpId(rpId, callerOrigin)) {1073 throw new DOMException(1074 `SecurityError: relying party ID "${rpId}" is not a registrable domain ` +1075 `suffix of the calling origin "${callerOrigin ?? "(unknown)"}".`,1076 "SecurityError",1077 );1078 }1079 const cdjOrigin = clientDataJsonOrigin(clientDataJSON);1080 if (cdjOrigin !== null && cdjOrigin !== callerOrigin) {1081 throw new DOMException(1082 `SecurityError: clientDataJSON origin "${cdjOrigin}" does not match the ` +1083 `attested calling origin "${callerOrigin}".`,1084 "SecurityError",1085 );1086 }1087}10881089export async function handlePasskeyHasCredentials(1090 payload: FirefoxPasskeyHasCredentialsPayload,1091 callerOrigin?: string,1092): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {1093 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1094 return { ok: false, error: "not_applicable" };1095 }1096 1097 1098 1099 if (!callerOrigin || !isValidRpId(payload.rpId, callerOrigin)) {1100 return { ok: true, hasCredentials: false };1101 }1102 if (!vault.isUnlocked()) {1103 1104 1105 1106 return { ok: true, hasCredentials: false };1107 }1108 const passkeys = listPasskeyEntries().filter(1109 (entry) => entry.passkey.rpId === payload.rpId1110 );1111 if (passkeys.length === 0) return { ok: true, hasCredentials: false };11121113 const allow = payload.allowCredentials ?? [];1114 if (allow.length === 0) return { ok: true, hasCredentials: true };11151116 const hasMatch = passkeys.some((entry) =>1117 allow.some((ac) => ac.id === entry.passkey.credentialId)1118 );1119 return { ok: true, hasCredentials: hasMatch };1120}112111221123112411251126112711281129async function requestFirefoxApproval(args: {1130 type: "create" | "get";1131 rpId: string;1132 rpName?: string;1133 userName: string;1134 userDisplayName?: string;1135}): Promise<boolean> {1136 1137 1138 const requestId = randomRequestId();1139 return promptUserApproval({1140 requestId,1141 type: args.type,1142 rpId: args.rpId,1143 rpName: args.rpName ?? args.rpId,1144 userName: args.userName,1145 userDisplayName: args.userDisplayName,1146 });1147}11481149export async function handleFirefoxPasskeyCreate(1150 payload: FirefoxPasskeyCreatePayload,1151 callerOrigin?: string,1152): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {1153 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1154 return { ok: false, error: "not_applicable" };1155 }1156 1157 1158 1159 1160 1161 assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1162 if (!(await isProUser())) {1163 broadcastPasskeyProGate();1164 return { ok: false, error: "pro_required" };1165 }1166 if (!vault.isUnlocked()) {1167 await openApprovalPopup();1168 return { ok: false, error: "vault_locked" };1169 }11701171 const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);1172 if (!algorithm) {1173 return { ok: false, error: "internal_error", message: "No supported algorithm offered" };1174 }11751176 const approved = await requestFirefoxApproval({1177 type: "create",1178 rpId: payload.rpId,1179 rpName: payload.rpName,1180 userName: payload.userName,1181 userDisplayName: payload.userDisplayName,1182 });1183 if (!approved) return { ok: false, error: "user_cancelled" };11841185 const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1186 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);11871188 1189 1190 const authenticatorData = await buildAuthenticatorData({1191 rpId: payload.rpId,1192 flags: 0x4d,1193 signCount: 0,1194 credentialId: credentialIdBytes,1195 publicKeyCose,1196 });1197 const attestationObject = createAttestationObject(authenticatorData);11981199 const passkey: PasskeyData = {1200 rpId: payload.rpId,1201 rpName: payload.rpName ?? payload.rpId,1202 credentialId,1203 publicKeyCose: base64urlEncode(publicKeyCose),1204 privateKeyJwk,1205 userHandle: payload.userId,1206 userName: payload.userName,1207 userDisplayName: payload.userDisplayName ?? payload.userName,1208 algorithm,1209 signCount: 0,1210 aaguid: base64urlEncode(new Uint8Array(16)),1211 backupEligible: true,1212 backupState: false,1213 browser: "firefox",1214 transports: ["internal"],1215 createdAt: Date.now(),1216 };12171218 const target = findAttachTarget(payload.rpId);1219 if (target) {1220 await vault.updateEntry(target.id, { passkey });1221 } else {1222 await vault.addEntry({1223 type: "login",1224 title: payload.rpName || payload.rpId,1225 url: `https://${payload.rpId}`,1226 username: payload.userName,1227 passkey,1228 });1229 }1230 await vault.consolidatePasskeyEntries();12311232 return {1233 ok: true,1234 credential: {1235 type: "create",1236 id: credentialId,1237 1238 1239 1240 clientDataJSON: payload.clientDataJSON,1241 attestationObject: base64urlEncode(attestationObject),1242 authenticatorData: base64urlEncode(authenticatorData),1243 publicKey: base64urlEncode(publicKeySpki),1244 algorithm,1245 },1246 };1247}12481249export async function handleFirefoxPasskeyGet(1250 payload: FirefoxPasskeyGetPayload,1251 callerOrigin?: string,1252): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1253 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1254 return { ok: false, error: "not_applicable" };1255 }1256 1257 1258 1259 1260 1261 1262 assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1263 if (!(await isProUser())) {1264 broadcastPasskeyProGate();1265 return { ok: false, error: "pro_required" };1266 }1267 if (!vault.isUnlocked()) {1268 await openApprovalPopup();1269 return { ok: false, error: "vault_locked" };1270 }12711272 const allow = payload.allowCredentials ?? [];1273 const candidates = listPasskeyEntries().filter((entry) => {1274 if (entry.passkey.rpId !== payload.rpId) return false;1275 if (allow.length === 0) return true;1276 return allow.some((ac) => ac.id === entry.passkey.credentialId);1277 });12781279 if (candidates.length === 0) return { ok: false, error: "no_credentials" };12801281 1282 1283 1284 candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1285 const match = candidates[0]!;1286 const credential = match.passkey;12871288 if (!credential.privateKeyJwk) {1289 1290 1291 1292 return { ok: false, error: "no_credentials" };1293 }12941295 const approved = await requestFirefoxApproval({1296 type: "get",1297 rpId: credential.rpId,1298 rpName: credential.rpName,1299 userName: credential.userName,1300 userDisplayName: credential.userDisplayName,1301 });1302 if (!approved) return { ok: false, error: "user_cancelled" };13031304 const nextSignCount = credential.signCount + 1;1305 1306 1307 const flags =1308 0x05 |1309 (credential.backupEligible ? 0x08 : 0) |1310 (credential.backupState ? 0x10 : 0);1311 const authenticatorData = await buildAuthenticatorData({1312 rpId: credential.rpId,1313 flags,1314 signCount: nextSignCount,1315 });13161317 1318 1319 1320 const clientDataBytes = new Uint8Array(payload.clientDataJSON);1321 const clientDataHash = new Uint8Array(1322 await crypto.subtle.digest("SHA-256", clientDataBytes)1323 );1324 const signature = await signPasskeyAssertion(1325 credential.privateKeyJwk,1326 credential.algorithm,1327 authenticatorData,1328 clientDataHash1329 );13301331 await vault.updateEntry(match.id, {1332 passkey: {1333 ...credential,1334 signCount: nextSignCount,1335 lastUsedAt: Date.now(),1336 },1337 });13381339 return {1340 ok: true,1341 credential: {1342 type: "get",1343 id: credential.credentialId,1344 clientDataJSON: payload.clientDataJSON,1345 authenticatorData: base64urlEncode(authenticatorData),1346 signature: base64urlEncode(signature),1347 userHandle: credential.userHandle,1348 },1349 };1350}13511352export async function setupPasskeyProxy(): Promise<void> {1353 if (!isPasskeySupported()) {1354 return;1355 }13561357 1358 1359 if (detectSharedBrowser() === "firefox") {1360 await refreshPasskeyProxy();1361 return;1362 }13631364 if (!listenersRegistered) {1365 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1366 1367 1368 1369 1370 1371 1372 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1373 requestId: requestInfo.requestId,1374 isUvpaa: true,1375 });1376 });1377 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1378 void handleCreateRequest(requestInfo).catch(async (error) => {1379 1380 1381 1382 if (isInvalidRequestIdError(error)) {1383 return;1384 }1385 console.error("Peach passkey create failed", error);1386 await rejectCreate(1387 requestInfo.requestId,1388 "UnknownError",1389 error instanceof Error ? error.message : "Passkey creation failed."1390 );1391 });1392 });1393 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1394 void handleGetRequest(requestInfo).catch(async (error) => {1395 if (isInvalidRequestIdError(error)) {1396 return;1397 }1398 console.error("Peach passkey get failed", error);1399 await rejectGet(1400 requestInfo.requestId,1401 "UnknownError",1402 error instanceof Error ? error.message : "Passkey authentication failed."1403 );1404 });1405 });1406 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1407 1408 const waiter = unlockWaiters.get(requestId);1409 if (waiter) {1410 clearTimeout(waiter.timeoutId);1411 waiter.resolve(false);1412 unlockWaiters.delete(requestId);1413 }1414 void resolvePendingPasskeyRequest(requestId, false);1415 });1416 listenersRegistered = true;1417 }14181419 await refreshPasskeyProxy();1420}