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 for (const [rid, { resolve, timeoutId }] of approvals.entries()) {74 clearTimeout(timeoutId);75 resolve(false);76 approvals.delete(rid);77 await clearPendingRequest(rid).catch(() => {});78 }79 for (const { resolve, timeoutId } of unlockWaiters.values()) {80 clearTimeout(timeoutId);81 resolve(false);82 }83 unlockWaiters.clear();84}8586function isPasskeySupported(): boolean {87 if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;88 89 90 91 92 if (detectSharedBrowser() === "firefox") {93 const ver = firefoxVersion();94 if (Number.isFinite(ver) && ver >= 150) return true;95 }96 return false;97}9899export const detectBrowser = detectSharedBrowser;100101function getBrowserLabel(): string {102 const ua = navigator.userAgent;103 if (/Firefox\/(\d+)/.test(ua)) {104 const version = ua.match(/Firefox\/(\d+)/)?.[1];105 return version ? `Firefox ${version}` : "Firefox";106 }107 const version = ua.match(/Chrome\/(\d+)/)?.[1];108 return version ? `Chrome ${version}` : "Chromium";109}110111function pendingStorageKey(requestId: number): string {112 return `${PENDING_PREFIX}${requestId}`;113}114115function randomRequestId(): number {116 117 118 119 120 const u = crypto.getRandomValues(new Uint32Array(2));121 return u[0] * 0x200000 + (u[1] >>> 11);122}123124function originFromRpId(rpId: string): string {125 return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)126 ? `http://${rpId}`127 : `https://${rpId}`;128}129130131132133134135136137138139140141142143144145async function resolvePageOrigin(rpId: string): Promise<string> {146 try {147 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>148 chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)149 );150 const url = tabs[0]?.url;151 if (url) {152 const parsed = new URL(url);153 const host = parsed.hostname.toLowerCase();154 const rp = rpId.toLowerCase();155 if (host === rp || host.endsWith(`.${rp}`)) {156 return parsed.origin;157 }158 }159 } catch {160 161 }162 return originFromRpId(rpId);163}164165async function setBadge(text: string): Promise<void> {166 if (!chrome.action?.setBadgeText) return;167 await chrome.action.setBadgeText({ text });168 if (text) {169 await chrome.action.setBadgeBackgroundColor({ color: "#E5805C" });170 }171}172173async function clearPendingRequest(requestId: number): Promise<void> {174 if (chrome.storage.session) {175 await chrome.storage.session.remove(pendingStorageKey(requestId));176 }177 await setBadge("");178}179180async function openApprovalPopup(): Promise<void> {181 try {182 await chrome.action.openPopup();183 } catch {184 await setBadge("PK");185 }186}187188189190191192193194195196function isInvalidRequestIdError(err: unknown): boolean {197 return err instanceof Error && /invalid requestid/i.test(err.message);198}199200201202203204205206207208209function isMissingResponseJsonError(err: unknown): boolean {210 return err instanceof Error && /missing getresponsedetails/i.test(err.message);211}212213async function rejectCreate(214 requestId: number,215 name: string,216 message = ""217): Promise<void> {218 try {219 await chrome.webAuthenticationProxy.completeCreateRequest({220 requestId,221 error: { name, message },222 });223 } catch (err) {224 if (isInvalidRequestIdError(err)) {225 return;226 }227 throw err;228 }229}230231async function rejectGet(232 requestId: number,233 name: string,234 message = ""235): Promise<void> {236 try {237 await chrome.webAuthenticationProxy.completeGetRequest({238 requestId,239 error: { name, message },240 });241 } catch (err) {242 if (isInvalidRequestIdError(err)) {243 return;244 }245 throw err;246 }247}248249export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {250 if (!chrome.storage.session) return null; 251 const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;252 const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));253 if (!key) return null;254 const pending = stored[key];255 return pending && typeof pending === "object"256 ? (pending as PendingPasskeyRequest)257 : null;258}259260export async function resolvePendingPasskeyRequest(261 requestId: number,262 approved: boolean263): Promise<void> {264 const pending = approvals.get(requestId);265 if (!pending) {266 267 268 269 270 await clearPendingRequest(requestId);271 if (chrome.storage.session) {272 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);273 }274 return;275 }276277 clearTimeout(pending.timeoutId);278 approvals.delete(requestId);279 await clearPendingRequest(requestId);280 pending.resolve(approved);281}282283async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {284 if (chrome.storage.session) {285 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });286 }287 await openApprovalPopup();288289 return new Promise<boolean>((resolve) => {290 const timeoutId = setTimeout(async () => {291 approvals.delete(request.requestId);292 await clearPendingRequest(request.requestId);293 resolve(false);294 }, APPROVAL_TIMEOUT_MS);295296 approvals.set(request.requestId, { resolve, timeoutId });297 });298}299300export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {301 const settings = await vault.ensureSettingsLoaded();302 const supported = isPasskeySupported();303 const enabled = settings.passkeyProviderEnabled === true;304 return {305 supported,306 enabled,307 attached: supported && attached,308 browserLabel: getBrowserLabel(),309 statusLabel: !supported310 ? "Interception unavailable"311 : attached312 ? "Active"313 : conflict314 ? "Another provider is active"315 : enabled316 ? "Inactive"317 : "Disabled",318 conflict,319 };320}321322async function attachPasskeyProxy(): Promise<void> {323 if (!isPasskeySupported()) {324 attached = false;325 conflict = "This browser does not expose chrome.webAuthenticationProxy.";326 return;327 }328329 330 331 332 if (detectSharedBrowser() === "firefox") {333 attached = true;334 conflict = undefined;335 return;336 }337338 if (attached) return;339340 try {341 await chrome.webAuthenticationProxy.attach();342 attached = true;343 conflict = undefined;344 } catch (error) {345 attached = false;346 conflict = error instanceof Error ? error.message : "Another provider is active.";347 console.warn("[Peach passkey] proxy attach failed", { conflict });348 }349}350351async function detachPasskeyProxy(): Promise<void> {352 if (!isPasskeySupported()) {353 attached = false;354 return;355 }356357 if (detectSharedBrowser() === "firefox") {358 attached = false;359 conflict = undefined;360 return;361 }362363 try {364 await chrome.webAuthenticationProxy.detach();365 } catch {366 367 }368369 attached = false;370 conflict = undefined;371}372373export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {374 await vault.updateSettings({ passkeyProviderEnabled: enabled });375 if (enabled) {376 await attachPasskeyProxy();377 } else {378 await detachPasskeyProxy();379 }380 return getPasskeyProviderState();381}382383async function refreshPasskeyProxy(): Promise<void> {384 const settings = await vault.ensureSettingsLoaded();385 if (settings.passkeyProviderEnabled === true) {386 await attachPasskeyProxy();387 return;388 }389 await detachPasskeyProxy();390}391392393394395396397398399function findAttachTarget(rpId: string): VaultEntry | null {400 const all = vault.getEntries();401 const passkeyMatch = all.find(402 (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt403 );404 if (passkeyMatch) return passkeyMatch;405 const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);406 if (loginMatches.length === 0) return null;407 408 return loginMatches.reduce((best, e) =>409 (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best410 );411}412413async function handleCreateRequest(414 request: chrome.webAuthenticationProxy.CreateRequest415): Promise<void> {416 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;417 const rpId = options.rp.id;418 const rpName = options.rp.name ?? options.rp.id;419 const userName = options.user.name;420 const userDisplayName = options.user.displayName ?? options.user.name;421 if (!rpId) {422 console.warn("[Peach passkey] reject: missing rp.id");423 await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");424 return;425 }426427 428 429 430 431 432 433 const pageOrigin = await resolvePageOrigin(rpId);434435 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {436 console.warn("[Peach passkey] reject: user is not Pro");437 await rejectCreate(438 request.requestId,439 "NotAllowedError",440 "Passkey creation requires Peach Pro."441 );442 443 broadcastPasskeyProGate();444 return;445 }446447 if (!vault.isUnlocked()) {448 449 450 451 452 453 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>454 chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)455 );456 const activeTab = tabs[0];457 if (activeTab?.id) {458 chrome.tabs.sendMessage(activeTab.id, {459 type: 'SUMMON_AUTOFILL',460 source: 'passkey-unlock',461 }).catch((err) => {462 console.warn("[Peach passkey] SUMMON_AUTOFILL failed, falling back to popup", err);463 void openApprovalPopup();464 });465 } else {466 await openApprovalPopup();467 }468469 const unlocked = await new Promise<boolean>((resolve) => {470 const timeoutId = setTimeout(() => {471 unlockWaiters.delete(request.requestId);472 resolve(false);473 }, APPROVAL_TIMEOUT_MS);474 unlockWaiters.set(request.requestId, { resolve, timeoutId });475 });476477 if (!unlocked || !vault.isUnlocked()) {478 console.warn("[Peach passkey] reject: unlock timeout");479 await rejectCreate(request.requestId, "NotAllowedError", "Not unlocked in time.");480 return;481 }482 483 }484485 const approved = await promptUserApproval({486 requestId: request.requestId,487 type: "create",488 rpId,489 rpName,490 userName,491 userDisplayName,492 });493 if (!approved) {494 console.warn("[Peach passkey] reject: user denied");495 await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");496 return;497 }498499 const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);500 if (!algorithm) {501 console.warn("[Peach passkey] reject: no supported algorithm", options.pubKeyCredParams);502 await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");503 return;504 }505 const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));506 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(507 algorithm508 );509 const authData = await buildAuthenticatorData({510 rpId,511 flags: 0x4d,512 signCount: 0,513 credentialId: credentialIdBytes,514 publicKeyCose,515 });516 const attestationObject = createAttestationObject(authData);517 const clientDataJSON = buildClientDataJSON(518 "webauthn.create",519 options.challenge,520 pageOrigin521 );522523 const passkey: PasskeyData = {524 rpId,525 rpName,526 credentialId: base64urlEncode(credentialIdBytes),527 publicKeyCose: base64urlEncode(publicKeyCose),528 privateKeyJwk,529 userHandle: options.user.id,530 userName,531 userDisplayName,532 algorithm,533 signCount: 0,534 aaguid: base64urlEncode(new Uint8Array(16)),535 backupEligible: true,536 backupState: false,537 browser: "chrome",538 transports: ["internal"],539 createdAt: Date.now(),540 };541542 543 544 const target = findAttachTarget(rpId);545 if (target) {546 await vault.updateEntry(target.id, { passkey });547 } else {548 await vault.addEntry({549 type: "login",550 title: rpName,551 url: `https://${rpId}`,552 username: userName,553 passkey,554 });555 }556 557 558 559 await vault.consolidatePasskeyEntries();560561 try {562 await chrome.webAuthenticationProxy.completeCreateRequest({563 requestId: request.requestId,564 responseJson: JSON.stringify({565 id: passkey.credentialId,566 rawId: passkey.credentialId,567 type: "public-key",568 response: {569 clientDataJSON: base64urlEncode(clientDataJSON),570 authenticatorData: base64urlEncode(authData),571 attestationObject: base64urlEncode(attestationObject),572 transports: ["internal"],573 publicKey: base64urlEncode(publicKeySpki),574 publicKeyAlgorithm: algorithm,575 },576 authenticatorAttachment: "platform",577 clientExtensionResults: {},578 }),579 });580 } catch (err) {581 if (isInvalidRequestIdError(err)) {582 return;583 }584 throw err;585 }586}587588function broadcastPasskeyProGate(): void {589 chrome.tabs.query({}, (tabs) => {590 for (const tab of tabs) {591 if (tab.id !== undefined) {592 chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});593 }594 }595 });596}597598async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {599 if (chrome.storage.session) {600 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });601 }602603 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {604 chrome.tabs.query({ active: true, currentWindow: true }, resolve);605 });606 const tab = tabs[0];607608 if (tab?.id) {609 chrome.tabs.sendMessage(tab.id, {610 type: 'SHOW_PASSKEY_TOAST',611 payload: {612 toastToken: crypto.randomUUID(),613 site: request.rpId,614 displayName: request.userDisplayName ?? request.userName,615 username: request.userName,616 requestId: String(request.requestId),617 },618 }).catch(() => {});619 } else {620 await openApprovalPopup();621 }622623 return new Promise<boolean>((resolve) => {624 const timeoutId = setTimeout(async () => {625 approvals.delete(request.requestId);626 await clearPendingRequest(request.requestId);627 resolve(false);628 }, APPROVAL_TIMEOUT_MS);629630 approvals.set(request.requestId, { resolve, timeoutId });631 });632}633634async function handleGetRequest(635 request: chrome.webAuthenticationProxy.GetRequest636): Promise<void> {637 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;638 if (!vault.isUnlocked()) {639 640 641 642 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>643 chrome.tabs.query({ active: true, currentWindow: true }, resolve)644 );645 const activeTab = tabs[0];646 if (activeTab?.id) {647 chrome.tabs.sendMessage(activeTab.id, {648 type: 'SUMMON_AUTOFILL',649 source: 'passkey-unlock',650 }).catch(() => { void openApprovalPopup(); });651 } else {652 await openApprovalPopup();653 }654655 656 const unlocked = await new Promise<boolean>((resolve) => {657 const timeoutId = setTimeout(() => {658 unlockWaiters.delete(request.requestId);659 resolve(false);660 }, APPROVAL_TIMEOUT_MS);661 unlockWaiters.set(request.requestId, { resolve, timeoutId });662 });663664 if (!unlocked || !vault.isUnlocked()) {665 await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");666 return;667 }668 669 }670671 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {672 await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");673 broadcastPasskeyProGate();674 return;675 }676677 const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>678 entry.passkey !== undefined && !entry.deletedAt679 );680 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);681682 const match = passkeyBearing.find((entry) => {683 684 685 686 if (options.rpId && entry.passkey.rpId !== options.rpId) return false;687 if (allowedCredentialIds.length > 0) {688 return allowedCredentialIds.includes(entry.passkey.credentialId);689 }690 if (!options.rpId) return false;691 return entry.passkey.rpId === options.rpId;692 });693694 if (!match) {695 696 697 698 699 700 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 credential = match.passkey;712 713 714 715 if (!credential.privateKeyJwk) {716 try {717 await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });718 } catch (err) {719 if (isInvalidRequestIdError(err)) return;720 if (isMissingResponseJsonError(err)) return;721 throw err;722 }723 return;724 }725726 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {727 chrome.tabs.query({ active: true, currentWindow: true }, resolve);728 });729 const approvalBinding = {730 tabId: tabs[0]?.id,731 origin: await resolvePageOrigin(credential.rpId),732 };733 const approved = await promptViaToast({734 requestId: request.requestId,735 type: "get",736 rpId: credential.rpId,737 rpName: credential.rpName,738 userName: credential.userName,739 userDisplayName: credential.userDisplayName,740 ...approvalBinding,741 });742 if (!approved) {743 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");744 return;745 }746747 const nextSignCount = credential.signCount + 1;748 const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);749 const authData = await buildAuthenticatorData({750 rpId: credential.rpId,751 flags,752 signCount: nextSignCount,753 });754 const clientDataJSON = buildClientDataJSON(755 "webauthn.get",756 options.challenge,757 await resolvePageOrigin(credential.rpId)758 );759 const clientDataHash = new Uint8Array(760 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))761 );762 const signature = await signPasskeyAssertion(763 credential.privateKeyJwk,764 credential.algorithm,765 authData,766 clientDataHash767 );768769 await vault.updateEntry(match.id, {770 passkey: {771 ...credential,772 signCount: nextSignCount,773 lastUsedAt: Date.now(),774 },775 });776777 const responseJson = JSON.stringify({778 id: credential.credentialId,779 rawId: credential.credentialId,780 type: "public-key",781 response: {782 clientDataJSON: base64urlEncode(clientDataJSON),783 authenticatorData: base64urlEncode(authData),784 signature: base64urlEncode(signature),785 userHandle: credential.userHandle,786 },787 authenticatorAttachment: "platform",788 clientExtensionResults: {},789 });790791 try {792 await chrome.webAuthenticationProxy.completeGetRequest({793 requestId: request.requestId,794 responseJson,795 });796 } catch (err) {797 if (isInvalidRequestIdError(err)) {798 return;799 }800 throw err;801 }802}803804805806807808809810811812813const FIREFOX_PENDING_KEY = "passkey_pending_firefox";814const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";815const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; 816817export async function initiateFirefoxPasskeyCreate(args: {818 rpId: string;819 rpName: string;820 userName: string;821 userDisplayName?: string;822}, callerOrigin?: string): Promise<{ ok: boolean; error?: string }> {823 824 825 826 827 828 if (!callerOrigin || !isValidRpId(args.rpId, callerOrigin)) {829 return { ok: false, error: "rpid_origin_mismatch" };830 }831 if (!(await isProUser())) {832 broadcastPasskeyProGate();833 return { ok: false, error: "passkey creation requires Peach Pro" };834 }835 if (!vault.isUnlocked()) {836 await openApprovalPopup();837 return { ok: false, error: "vault_locked" };838 }839 if (!chrome.storage.session) {840 return { ok: false, error: "session storage unavailable" };841 }842843 const requestId = randomRequestId();844 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));845 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));846847 const pending: PendingPasskeyRequest = {848 requestId,849 type: "create",850 rpId: args.rpId,851 rpName: args.rpName,852 userName: args.userName,853 userDisplayName: args.userDisplayName,854 browser: "firefox",855 challenge,856 userHandle,857 };858859 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });860 await openApprovalPopup();861 return { ok: true };862}863864export async function completeFirefoxPasskeyCreate(payload: {865 requestId: number;866 rpId: string;867 rpName: string;868 userName: string;869 userDisplayName?: string;870 userHandle: string;871 credentialId: string;872 publicKeyCose: string;873 algorithm: -7 | -8;874 aaguid?: string;875 transports?: string[];876 backupEligible?: boolean;877 backupState?: boolean;878}): Promise<{ ok: boolean; error?: string }> {879 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };880881 882 883 884 885 886 887 const pending = chrome.storage.session888 ? ((await chrome.storage.session.get(FIREFOX_PENDING_KEY))[FIREFOX_PENDING_KEY] as889 | PendingPasskeyRequest890 | undefined)891 : undefined;892 if (893 !pending ||894 pending.requestId !== payload.requestId ||895 pending.rpId !== payload.rpId ||896 pending.userHandle !== payload.userHandle897 ) {898 return { ok: false, error: "no_pending_request" };899 }900901 const passkey: PasskeyData = {902 rpId: payload.rpId,903 rpName: payload.rpName,904 credentialId: payload.credentialId,905 publicKeyCose: payload.publicKeyCose,906 userHandle: payload.userHandle,907 userName: payload.userName,908 userDisplayName: payload.userDisplayName ?? payload.userName,909 algorithm: payload.algorithm,910 signCount: 0,911 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),912 backupEligible: payload.backupEligible ?? false,913 backupState: payload.backupState ?? false,914 browser: "firefox",915 transports: payload.transports ?? ["internal"],916 createdAt: Date.now(),917 };918919 920 921 922 const target = findAttachTarget(payload.rpId);923 if (target) {924 await vault.updateEntry(target.id, { passkey });925 } else {926 await vault.addEntry({927 type: "login",928 title: payload.rpName || payload.rpId,929 url: `https://${payload.rpId}`,930 username: payload.userName,931 passkey,932 });933 }934 await vault.consolidatePasskeyEntries();935936 if (chrome.storage.session) {937 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);938 }939 return { ok: true };940}941942const MAX_DECLINE_DOMAIN_LENGTH = 253;943944function normaliseDeclineDomain(domain: string): string | null {945 if (!domain || domain.length > MAX_DECLINE_DOMAIN_LENGTH) return null;946 try {947 const url = new URL(`https://${domain}`);948 return isValidRpId(url.hostname, url.origin) ? url.hostname : null;949 } catch {950 return null;951 }952}953954function declineKey(domain: string): string {955 return `${FIREFOX_DECLINE_PREFIX}${domain}`;956}957958export async function recordPasskeyOfferDecline(domain: string): Promise<void> {959 const validDomain = normaliseDeclineDomain(domain);960 if (!validDomain) return;961 await chrome.storage.local.set({ [declineKey(validDomain)]: Date.now() });962}963964export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {965 const validDomain = normaliseDeclineDomain(domain);966 if (!validDomain) return false;967 const key = declineKey(validDomain);968969 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;970 const at = stored[key];971 if (typeof at !== "number") return false;972 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {973 await chrome.storage.local.remove(key);974 return false;975 }976 return true;977}978979980981982983984985986987988989990991992interface FirefoxAllowCredential {993 id: string;994 type?: string;995}996997interface FirefoxPubKeyCredParam {998 type?: string;999 alg: number;1000}10011002export interface FirefoxPasskeyHasCredentialsPayload {1003 rpId: string;1004 allowCredentials: FirefoxAllowCredential[];1005}10061007export interface FirefoxPasskeyCreatePayload {1008 rpId: string;1009 rpName?: string;1010 userId: string;1011 userName: string;1012 userDisplayName?: string;1013 challenge: string;1014 clientDataJSON: number[];1015 origin: string;1016 pubKeyCredParams?: FirefoxPubKeyCredParam[];1017 authenticatorSelection?: Record<string, unknown>;1018 timeout?: number;1019 attestation?: string;1020}10211022export interface FirefoxPasskeyGetPayload {1023 rpId: string;1024 challenge: string;1025 clientDataJSON: number[];1026 origin: string;1027 allowCredentials: FirefoxAllowCredential[];1028 userVerification?: string;1029}10301031interface FirefoxCreateCredentialResponse {1032 type: "create";1033 id: string;1034 clientDataJSON: number[];1035 attestationObject: string;1036 authenticatorData: string;1037 publicKey: string;1038 algorithm: -7 | -8;1039}10401041interface FirefoxGetCredentialResponse {1042 type: "get";1043 id: string;1044 clientDataJSON: number[];1045 authenticatorData: string;1046 signature: string;1047 userHandle: string;1048}10491050type FirefoxPasskeyResult<T> =1051 | { ok: true; credential: T }1052 | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };10531054function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {1055 return vault.getEntries().filter(1056 (entry): entry is VaultEntry & { passkey: PasskeyData } =>1057 entry.passkey !== undefined && !entry.deletedAt1058 );1059}106010611062function clientDataJsonOrigin(bytes: number[] | undefined): string | null {1063 if (!Array.isArray(bytes)) return null;1064 try {1065 const obj = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes))) as { origin?: unknown };1066 return typeof obj.origin === "string" ? obj.origin : null;1067 } catch {1068 return null;1069 }1070}107110721073107410751076107710781079108010811082function assertOriginOwnsRpId(1083 rpId: string,1084 callerOrigin: string | undefined,1085 clientDataJSON?: number[],1086): void {1087 if (!callerOrigin || !isValidRpId(rpId, callerOrigin)) {1088 throw new DOMException(1089 `SecurityError: relying party ID "${rpId}" is not a registrable domain ` +1090 `suffix of the calling origin "${callerOrigin ?? "(unknown)"}".`,1091 "SecurityError",1092 );1093 }1094 const cdjOrigin = clientDataJsonOrigin(clientDataJSON);1095 if (cdjOrigin !== null && cdjOrigin !== callerOrigin) {1096 throw new DOMException(1097 `SecurityError: clientDataJSON origin "${cdjOrigin}" does not match the ` +1098 `attested calling origin "${callerOrigin}".`,1099 "SecurityError",1100 );1101 }1102}11031104export async function handlePasskeyHasCredentials(1105 payload: FirefoxPasskeyHasCredentialsPayload,1106 callerOrigin?: string,1107): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {1108 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1109 return { ok: false, error: "not_applicable" };1110 }1111 1112 1113 1114 if (!callerOrigin || !isValidRpId(payload.rpId, callerOrigin)) {1115 return { ok: true, hasCredentials: false };1116 }1117 if (!vault.isUnlocked()) {1118 1119 1120 1121 return { ok: true, hasCredentials: false };1122 }1123 const passkeys = listPasskeyEntries().filter(1124 (entry) => entry.passkey.rpId === payload.rpId1125 );1126 if (passkeys.length === 0) return { ok: true, hasCredentials: false };11271128 const allow = payload.allowCredentials ?? [];1129 if (allow.length === 0) return { ok: true, hasCredentials: true };11301131 const hasMatch = passkeys.some((entry) =>1132 allow.some((ac) => ac.id === entry.passkey.credentialId)1133 );1134 return { ok: true, hasCredentials: hasMatch };1135}113611371138113911401141114211431144async function requestFirefoxApproval(args: {1145 type: "create" | "get";1146 rpId: string;1147 rpName?: string;1148 userName: string;1149 userDisplayName?: string;1150}): Promise<boolean> {1151 1152 1153 const requestId = randomRequestId();1154 return promptUserApproval({1155 requestId,1156 type: args.type,1157 rpId: args.rpId,1158 rpName: args.rpName ?? args.rpId,1159 userName: args.userName,1160 userDisplayName: args.userDisplayName,1161 });1162}11631164export async function handleFirefoxPasskeyCreate(1165 payload: FirefoxPasskeyCreatePayload,1166 callerOrigin?: string,1167): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {1168 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1169 return { ok: false, error: "not_applicable" };1170 }1171 1172 1173 1174 1175 1176 assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1177 if (!(await isProUser())) {1178 broadcastPasskeyProGate();1179 return { ok: false, error: "pro_required" };1180 }1181 if (!vault.isUnlocked()) {1182 await openApprovalPopup();1183 return { ok: false, error: "vault_locked" };1184 }11851186 const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);1187 if (!algorithm) {1188 return { ok: false, error: "internal_error", message: "No supported algorithm offered" };1189 }11901191 const approved = await requestFirefoxApproval({1192 type: "create",1193 rpId: payload.rpId,1194 rpName: payload.rpName,1195 userName: payload.userName,1196 userDisplayName: payload.userDisplayName,1197 });1198 if (!approved) return { ok: false, error: "user_cancelled" };11991200 const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1201 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);12021203 1204 1205 const authenticatorData = await buildAuthenticatorData({1206 rpId: payload.rpId,1207 flags: 0x4d,1208 signCount: 0,1209 credentialId: credentialIdBytes,1210 publicKeyCose,1211 });1212 const attestationObject = createAttestationObject(authenticatorData);12131214 const passkey: PasskeyData = {1215 rpId: payload.rpId,1216 rpName: payload.rpName ?? payload.rpId,1217 credentialId,1218 publicKeyCose: base64urlEncode(publicKeyCose),1219 privateKeyJwk,1220 userHandle: payload.userId,1221 userName: payload.userName,1222 userDisplayName: payload.userDisplayName ?? payload.userName,1223 algorithm,1224 signCount: 0,1225 aaguid: base64urlEncode(new Uint8Array(16)),1226 backupEligible: true,1227 backupState: false,1228 browser: "firefox",1229 transports: ["internal"],1230 createdAt: Date.now(),1231 };12321233 const target = findAttachTarget(payload.rpId);1234 if (target) {1235 await vault.updateEntry(target.id, { passkey });1236 } else {1237 await vault.addEntry({1238 type: "login",1239 title: payload.rpName || payload.rpId,1240 url: `https://${payload.rpId}`,1241 username: payload.userName,1242 passkey,1243 });1244 }1245 await vault.consolidatePasskeyEntries();12461247 return {1248 ok: true,1249 credential: {1250 type: "create",1251 id: credentialId,1252 1253 1254 1255 clientDataJSON: payload.clientDataJSON,1256 attestationObject: base64urlEncode(attestationObject),1257 authenticatorData: base64urlEncode(authenticatorData),1258 publicKey: base64urlEncode(publicKeySpki),1259 algorithm,1260 },1261 };1262}12631264export async function handleFirefoxPasskeyGet(1265 payload: FirefoxPasskeyGetPayload,1266 callerOrigin?: string,1267): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1268 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1269 return { ok: false, error: "not_applicable" };1270 }1271 1272 1273 1274 1275 1276 1277 assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1278 if (!(await isProUser())) {1279 broadcastPasskeyProGate();1280 return { ok: false, error: "pro_required" };1281 }1282 if (!vault.isUnlocked()) {1283 await openApprovalPopup();1284 return { ok: false, error: "vault_locked" };1285 }12861287 const allow = payload.allowCredentials ?? [];1288 const candidates = listPasskeyEntries().filter((entry) => {1289 if (entry.passkey.rpId !== payload.rpId) return false;1290 if (allow.length === 0) return true;1291 return allow.some((ac) => ac.id === entry.passkey.credentialId);1292 });12931294 if (candidates.length === 0) return { ok: false, error: "no_credentials" };12951296 1297 1298 1299 candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1300 const match = candidates[0]!;1301 const credential = match.passkey;13021303 if (!credential.privateKeyJwk) {1304 1305 1306 1307 return { ok: false, error: "no_credentials" };1308 }13091310 const approved = await requestFirefoxApproval({1311 type: "get",1312 rpId: credential.rpId,1313 rpName: credential.rpName,1314 userName: credential.userName,1315 userDisplayName: credential.userDisplayName,1316 });1317 if (!approved) return { ok: false, error: "user_cancelled" };13181319 const nextSignCount = credential.signCount + 1;1320 1321 1322 const flags =1323 0x05 |1324 (credential.backupEligible ? 0x08 : 0) |1325 (credential.backupState ? 0x10 : 0);1326 const authenticatorData = await buildAuthenticatorData({1327 rpId: credential.rpId,1328 flags,1329 signCount: nextSignCount,1330 });13311332 1333 1334 1335 const clientDataBytes = new Uint8Array(payload.clientDataJSON);1336 const clientDataHash = new Uint8Array(1337 await crypto.subtle.digest("SHA-256", clientDataBytes)1338 );1339 const signature = await signPasskeyAssertion(1340 credential.privateKeyJwk,1341 credential.algorithm,1342 authenticatorData,1343 clientDataHash1344 );13451346 await vault.updateEntry(match.id, {1347 passkey: {1348 ...credential,1349 signCount: nextSignCount,1350 lastUsedAt: Date.now(),1351 },1352 });13531354 return {1355 ok: true,1356 credential: {1357 type: "get",1358 id: credential.credentialId,1359 clientDataJSON: payload.clientDataJSON,1360 authenticatorData: base64urlEncode(authenticatorData),1361 signature: base64urlEncode(signature),1362 userHandle: credential.userHandle,1363 },1364 };1365}13661367export async function setupPasskeyProxy(): Promise<void> {1368 if (!isPasskeySupported()) {1369 return;1370 }13711372 1373 1374 if (detectSharedBrowser() === "firefox") {1375 await refreshPasskeyProxy();1376 return;1377 }13781379 if (!listenersRegistered) {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 void handleCreateRequest(requestInfo).catch(async (error) => {1394 1395 1396 1397 if (isInvalidRequestIdError(error)) {1398 return;1399 }1400 console.error("Peach passkey create failed", error);1401 await rejectCreate(1402 requestInfo.requestId,1403 "UnknownError",1404 error instanceof Error ? error.message : "Passkey creation failed."1405 );1406 });1407 });1408 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1409 void handleGetRequest(requestInfo).catch(async (error) => {1410 if (isInvalidRequestIdError(error)) {1411 return;1412 }1413 console.error("Peach passkey get failed", error);1414 await rejectGet(1415 requestInfo.requestId,1416 "UnknownError",1417 error instanceof Error ? error.message : "Passkey authentication failed."1418 );1419 });1420 });1421 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1422 markCancelled(requestId);1423 1424 const waiter = unlockWaiters.get(requestId);1425 if (waiter) {1426 clearTimeout(waiter.timeoutId);1427 waiter.resolve(false);1428 unlockWaiters.delete(requestId);1429 }1430 void resolvePendingPasskeyRequest(requestId, false);1431 });1432 listenersRegistered = true;1433 }14341435 await refreshPasskeyProxy();1436}