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;323334const firefoxCeremonies = new Map<number, Promise<void>>();3536const approvals = new Map<37 number,38 { resolve: (approved: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }39>();40414243const unlockWaiters = new Map<number, { resolve: (ok: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }>();4445export function onVaultUnlocked(): void {46 for (const { resolve, timeoutId } of unlockWaiters.values()) {47 clearTimeout(timeoutId);48 resolve(true);49 }50 unlockWaiters.clear();51}5253545556575859export async function denyAllPendingPasskeyRequests(): Promise<void> {60 if (approvals.size === 0 && unlockWaiters.size === 0) return;61 for (const [rid, { resolve, timeoutId }] of approvals.entries()) {62 clearTimeout(timeoutId);63 resolve(false);64 approvals.delete(rid);65 await clearPendingRequest(rid).catch(() => {});66 }67 for (const { resolve, timeoutId } of unlockWaiters.values()) {68 clearTimeout(timeoutId);69 resolve(false);70 }71 unlockWaiters.clear();72}7374function isPasskeySupported(): boolean {75 if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;76 77 78 79 80 if (detectSharedBrowser() === "firefox") {81 const ver = firefoxVersion();82 if (Number.isFinite(ver) && ver >= 150) return true;83 }84 return false;85}8687export const detectBrowser = detectSharedBrowser;8889function getBrowserLabel(): string {90 const ua = navigator.userAgent;91 if (/Firefox\/(\d+)/.test(ua)) {92 const version = ua.match(/Firefox\/(\d+)/)?.[1];93 return version ? `Firefox ${version}` : "Firefox";94 }95 const version = ua.match(/Chrome\/(\d+)/)?.[1];96 return version ? `Chrome ${version}` : "Chromium";97}9899function pendingStorageKey(requestId: number): string {100 return `${PENDING_PREFIX}${requestId}`;101}102103function randomRequestId(): number {104 105 106 107 108 const u = crypto.getRandomValues(new Uint32Array(2));109 return u[0] * 0x200000 + (u[1] >>> 11);110}111112function originFromRpId(rpId: string): string {113 return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)114 ? `http://${rpId}`115 : `https://${rpId}`;116}117118119120121122123124125126127128129130131132133async function resolvePageOrigin(rpId: string): Promise<string> {134 try {135 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>136 chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)137 );138 const url = tabs[0]?.url;139 if (url) {140 const parsed = new URL(url);141 const host = parsed.hostname.toLowerCase();142 const rp = rpId.toLowerCase();143 if (host === rp || host.endsWith(`.${rp}`)) {144 return parsed.origin;145 }146 }147 } catch {148 149 }150 return originFromRpId(rpId);151}152153async function setBadge(text: string): Promise<void> {154 if (!chrome.action?.setBadgeText) return;155 await chrome.action.setBadgeText({ text });156 if (text) {157 await chrome.action.setBadgeBackgroundColor({ color: "#E5805C" });158 }159}160161async function clearPendingRequest(requestId: number): Promise<void> {162 if (chrome.storage.session) {163 await chrome.storage.session.remove(pendingStorageKey(requestId));164 }165 await setBadge("");166}167168async function openApprovalPopup(): Promise<void> {169 try {170 await chrome.action.openPopup();171 } catch {172 await setBadge("PK");173 }174}175176177178179180181182183184function isInvalidRequestIdError(err: unknown): boolean {185 return err instanceof Error && /invalid requestid/i.test(err.message);186}187188189190191192193194195196197function isMissingResponseJsonError(err: unknown): boolean {198 return err instanceof Error && /missing getresponsedetails/i.test(err.message);199}200201async function rejectCreate(202 requestId: number,203 name: string,204 message = ""205): Promise<void> {206 try {207 await chrome.webAuthenticationProxy.completeCreateRequest({208 requestId,209 error: { name, message },210 });211 } catch (err) {212 if (isInvalidRequestIdError(err)) {213 return;214 }215 throw err;216 }217}218219async function rejectGet(220 requestId: number,221 name: string,222 message = ""223): Promise<void> {224 try {225 await chrome.webAuthenticationProxy.completeGetRequest({226 requestId,227 error: { name, message },228 });229 } catch (err) {230 if (isInvalidRequestIdError(err)) {231 return;232 }233 throw err;234 }235}236237export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {238 if (!chrome.storage.session) return null; 239 const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;240 const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));241 if (!key) return null;242 const pending = stored[key];243 return pending && typeof pending === "object"244 ? (pending as PendingPasskeyRequest)245 : null;246}247248export async function resolvePendingPasskeyRequest(249 requestId: number,250 approved: boolean251): Promise<void> {252 const pending = approvals.get(requestId);253 if (!pending) {254 255 256 257 258 await clearPendingRequest(requestId);259 if (chrome.storage.session) {260 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);261 }262 if (approved) await firefoxCeremonies.get(requestId);263 return;264 }265266 clearTimeout(pending.timeoutId);267 approvals.delete(requestId);268 await clearPendingRequest(requestId);269 pending.resolve(approved);270 if (approved) await firefoxCeremonies.get(requestId);271}272273async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {274 275 const approval = new Promise<boolean>((resolve) => {276 const timeoutId = setTimeout(async () => {277 approvals.delete(request.requestId);278 await clearPendingRequest(request.requestId);279 resolve(false);280 }, APPROVAL_TIMEOUT_MS);281 approvals.set(request.requestId, { resolve, timeoutId });282 });283 try {284 if (chrome.storage.session) {285 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });286 }287 await openApprovalPopup();288 } catch (error) {289 await resolvePendingPasskeyRequest(request.requestId, false);290 throw error;291 }292 return approval;293}294295export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {296 const settings = await vault.ensureSettingsLoaded();297 const supported = isPasskeySupported();298 const enabled = settings.passkeyProviderEnabled ?? (detectSharedBrowser() === "firefox");299 return {300 supported,301 enabled,302 attached: supported && attached,303 browserLabel: getBrowserLabel(),304 statusLabel: !supported305 ? "Interception unavailable"306 : attached307 ? "Active"308 : conflict309 ? "Another provider is active"310 : enabled311 ? "Inactive"312 : "Disabled",313 conflict,314 };315}316317async function attachPasskeyProxy(): Promise<void> {318 if (!isPasskeySupported()) {319 attached = false;320 conflict = "This browser does not expose chrome.webAuthenticationProxy.";321 return;322 }323324 325 326 327 if (detectSharedBrowser() === "firefox") {328 attached = true;329 conflict = undefined;330 return;331 }332333 if (attached) return;334335 try {336 await chrome.webAuthenticationProxy.attach();337 attached = true;338 conflict = undefined;339 } catch (error) {340 attached = false;341 conflict = error instanceof Error ? error.message : "Another provider is active.";342 console.warn("[Peach passkey] proxy attach failed", { conflict });343 }344}345346async function detachPasskeyProxy(): Promise<void> {347 if (!isPasskeySupported()) {348 attached = false;349 return;350 }351352 if (detectSharedBrowser() === "firefox") {353 attached = false;354 conflict = undefined;355 return;356 }357358 try {359 await chrome.webAuthenticationProxy.detach();360 } catch {361 362 }363364 attached = false;365 conflict = undefined;366}367368export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {369 await vault.updateSettings({ passkeyProviderEnabled: enabled });370 if (enabled) {371 await attachPasskeyProxy();372 } else {373 await detachPasskeyProxy();374 }375 return getPasskeyProviderState();376}377378async function refreshPasskeyProxy(): Promise<void> {379 const settings = await vault.ensureSettingsLoaded();380 if (settings.passkeyProviderEnabled ?? (detectSharedBrowser() === "firefox")) {381 await attachPasskeyProxy();382 return;383 }384 await detachPasskeyProxy();385}386387388389390391392393394function findAttachTarget(rpId: string): VaultEntry | null {395 const all = vault.getEntries();396 const passkeyMatch = all.find(397 (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt398 );399 if (passkeyMatch) return passkeyMatch;400 const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);401 if (loginMatches.length === 0) return null;402 403 return loginMatches.reduce((best, e) =>404 (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best405 );406}407408async function handleCreateRequest(409 request: chrome.webAuthenticationProxy.CreateRequest410): Promise<void> {411 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;412 const rpId = options.rp.id;413 const rpName = options.rp.name ?? options.rp.id;414 const userName = options.user.name;415 const userDisplayName = options.user.displayName ?? options.user.name;416 if (!rpId) {417 console.warn("[Peach passkey] reject: missing rp.id");418 await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");419 return;420 }421422 423 424 425 426 427 428 const pageOrigin = await resolvePageOrigin(rpId);429430 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {431 console.warn("[Peach passkey] reject: user is not Pro");432 await rejectCreate(433 request.requestId,434 "NotAllowedError",435 "Passkey creation requires Peach Pro."436 );437 438 broadcastPasskeyProGate();439 return;440 }441442 if (!vault.isUnlocked()) {443 444 445 446 447 448 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>449 chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)450 );451 const activeTab = tabs[0];452 if (activeTab?.id) {453 chrome.tabs.sendMessage(activeTab.id, {454 type: 'SUMMON_AUTOFILL',455 source: 'passkey-unlock',456 }).catch((err) => {457 console.warn("[Peach passkey] SUMMON_AUTOFILL failed, falling back to popup", err);458 void openApprovalPopup();459 });460 } else {461 await openApprovalPopup();462 }463464 const unlocked = await new Promise<boolean>((resolve) => {465 const timeoutId = setTimeout(() => {466 unlockWaiters.delete(request.requestId);467 resolve(false);468 }, APPROVAL_TIMEOUT_MS);469 unlockWaiters.set(request.requestId, { resolve, timeoutId });470 });471472 if (!unlocked || !vault.isUnlocked()) {473 console.warn("[Peach passkey] reject: unlock timeout");474 await rejectCreate(request.requestId, "NotAllowedError", "Not unlocked in time.");475 return;476 }477 478 }479480 const approved = await promptUserApproval({481 requestId: request.requestId,482 type: "create",483 rpId,484 rpName,485 userName,486 userDisplayName,487 });488 if (!approved) {489 console.warn("[Peach passkey] reject: user denied");490 await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");491 return;492 }493494 const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);495 if (!algorithm) {496 console.warn("[Peach passkey] reject: no supported algorithm", options.pubKeyCredParams);497 await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");498 return;499 }500 const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));501 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(502 algorithm503 );504 const authData = await buildAuthenticatorData({505 rpId,506 flags: 0x4d,507 signCount: 0,508 credentialId: credentialIdBytes,509 publicKeyCose,510 });511 const attestationObject = createAttestationObject(authData);512 const clientDataJSON = buildClientDataJSON(513 "webauthn.create",514 options.challenge,515 pageOrigin516 );517518 const passkey: PasskeyData = {519 rpId,520 rpName,521 credentialId: base64urlEncode(credentialIdBytes),522 publicKeyCose: base64urlEncode(publicKeyCose),523 privateKeyJwk,524 userHandle: options.user.id,525 userName,526 userDisplayName,527 algorithm,528 signCount: 0,529 aaguid: base64urlEncode(new Uint8Array(16)),530 backupEligible: true,531 backupState: false,532 browser: "chrome",533 transports: ["internal"],534 createdAt: Date.now(),535 };536537 538 539 const target = findAttachTarget(rpId);540 if (target) {541 await vault.updateEntry(target.id, { passkey });542 } else {543 await vault.addEntry({544 type: "login",545 title: rpName,546 url: `https://${rpId}`,547 username: userName,548 passkey,549 });550 }551 552 553 554 await vault.consolidatePasskeyEntries();555556 try {557 await chrome.webAuthenticationProxy.completeCreateRequest({558 requestId: request.requestId,559 responseJson: JSON.stringify({560 id: passkey.credentialId,561 rawId: passkey.credentialId,562 type: "public-key",563 response: {564 clientDataJSON: base64urlEncode(clientDataJSON),565 authenticatorData: base64urlEncode(authData),566 attestationObject: base64urlEncode(attestationObject),567 transports: ["internal"],568 publicKey: base64urlEncode(publicKeySpki),569 publicKeyAlgorithm: algorithm,570 },571 authenticatorAttachment: "platform",572 clientExtensionResults: {},573 }),574 });575 } catch (err) {576 if (isInvalidRequestIdError(err)) {577 return;578 }579 throw err;580 }581}582583function broadcastPasskeyProGate(): void {584 chrome.tabs.query({}, (tabs) => {585 for (const tab of tabs) {586 if (tab.id !== undefined) {587 chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});588 }589 }590 });591}592593async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {594 if (chrome.storage.session) {595 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });596 }597598 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {599 chrome.tabs.query({ active: true, currentWindow: true }, resolve);600 });601 const tab = tabs[0];602603 if (tab?.id) {604 chrome.tabs.sendMessage(tab.id, {605 type: 'SHOW_PASSKEY_TOAST',606 payload: {607 toastToken: crypto.randomUUID(),608 site: request.rpId,609 displayName: request.userDisplayName ?? request.userName,610 username: request.userName,611 requestId: String(request.requestId),612 },613 }).catch(() => {});614 } else {615 await openApprovalPopup();616 }617618 return new Promise<boolean>((resolve) => {619 const timeoutId = setTimeout(async () => {620 approvals.delete(request.requestId);621 await clearPendingRequest(request.requestId);622 resolve(false);623 }, APPROVAL_TIMEOUT_MS);624625 approvals.set(request.requestId, { resolve, timeoutId });626 });627}628629async function handleGetRequest(630 request: chrome.webAuthenticationProxy.GetRequest631): Promise<void> {632 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;633 if (!vault.isUnlocked()) {634 635 636 637 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>638 chrome.tabs.query({ active: true, currentWindow: true }, resolve)639 );640 const activeTab = tabs[0];641 if (activeTab?.id) {642 chrome.tabs.sendMessage(activeTab.id, {643 type: 'SUMMON_AUTOFILL',644 source: 'passkey-unlock',645 }).catch(() => { void openApprovalPopup(); });646 } else {647 await openApprovalPopup();648 }649650 651 const unlocked = await new Promise<boolean>((resolve) => {652 const timeoutId = setTimeout(() => {653 unlockWaiters.delete(request.requestId);654 resolve(false);655 }, APPROVAL_TIMEOUT_MS);656 unlockWaiters.set(request.requestId, { resolve, timeoutId });657 });658659 if (!unlocked || !vault.isUnlocked()) {660 await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");661 return;662 }663 664 }665666 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {667 await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");668 broadcastPasskeyProGate();669 return;670 }671672 const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>673 entry.passkey !== undefined && !entry.deletedAt674 );675 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);676677 const match = passkeyBearing.find((entry) => {678 679 680 681 if (options.rpId && entry.passkey.rpId !== options.rpId) return false;682 if (allowedCredentialIds.length > 0) {683 return allowedCredentialIds.includes(entry.passkey.credentialId);684 }685 if (!options.rpId) return false;686 return entry.passkey.rpId === options.rpId;687 });688689 if (!match) {690 691 692 693 694 695 696 try {697 await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });698 } catch (err) {699 if (isInvalidRequestIdError(err)) return;700 if (isMissingResponseJsonError(err)) return;701 throw err;702 }703 return;704 }705706 const credential = match.passkey;707 708 709 710 if (!credential.privateKeyJwk) {711 try {712 await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });713 } catch (err) {714 if (isInvalidRequestIdError(err)) return;715 if (isMissingResponseJsonError(err)) return;716 throw err;717 }718 return;719 }720721 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {722 chrome.tabs.query({ active: true, currentWindow: true }, resolve);723 });724 const approvalBinding = {725 tabId: tabs[0]?.id,726 origin: await resolvePageOrigin(credential.rpId),727 };728 const approved = await promptViaToast({729 requestId: request.requestId,730 type: "get",731 rpId: credential.rpId,732 rpName: credential.rpName,733 userName: credential.userName,734 userDisplayName: credential.userDisplayName,735 ...approvalBinding,736 });737 if (!approved) {738 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");739 return;740 }741742 const nextSignCount = credential.signCount + 1;743 744 745 746 747 const backupEligible = credential.browser === "chrome" ? true : credential.backupEligible;748 const flags = 0x05 | (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 backupEligible,773 signCount: nextSignCount,774 lastUsedAt: Date.now(),775 },776 });777778 const responseJson = JSON.stringify({779 id: credential.credentialId,780 rawId: credential.credentialId,781 type: "public-key",782 response: {783 clientDataJSON: base64urlEncode(clientDataJSON),784 authenticatorData: base64urlEncode(authData),785 signature: base64urlEncode(signature),786 userHandle: credential.userHandle,787 },788 authenticatorAttachment: "platform",789 clientExtensionResults: {},790 });791792 try {793 await chrome.webAuthenticationProxy.completeGetRequest({794 requestId: request.requestId,795 responseJson,796 });797 } catch (err) {798 if (isInvalidRequestIdError(err)) {799 return;800 }801 throw err;802 }803}804805806807808809810811812813814const FIREFOX_PENDING_KEY = "passkey_pending_firefox";815const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";816const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; 817818export async function initiateFirefoxPasskeyCreate(args: {819 rpId: string;820 rpName: string;821 userName: string;822 userDisplayName?: string;823}, callerOrigin?: string): Promise<{ ok: boolean; error?: string }> {824 825 826 827 828 829 if (!callerOrigin || !isValidRpId(args.rpId, callerOrigin)) {830 return { ok: false, error: "rpid_origin_mismatch" };831 }832 if (!(await isProUser())) {833 broadcastPasskeyProGate();834 return { ok: false, error: "passkey creation requires Peach Pro" };835 }836 if (!vault.isUnlocked()) {837 await openApprovalPopup();838 return { ok: false, error: "vault_locked" };839 }840 if (!chrome.storage.session) {841 return { ok: false, error: "session storage unavailable" };842 }843844 const requestId = randomRequestId();845 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));846 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));847848 const pending: PendingPasskeyRequest = {849 requestId,850 type: "create",851 rpId: args.rpId,852 rpName: args.rpName,853 userName: args.userName,854 userDisplayName: args.userDisplayName,855 browser: "firefox",856 challenge,857 userHandle,858 };859860 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });861 await openApprovalPopup();862 return { ok: true };863}864865export async function completeFirefoxPasskeyCreate(payload: {866 requestId: number;867 rpId: string;868 rpName: string;869 userName: string;870 userDisplayName?: string;871 userHandle: string;872 credentialId: string;873 publicKeyCose: string;874 algorithm: -7 | -8;875 aaguid?: string;876 transports?: string[];877 backupEligible?: boolean;878 backupState?: boolean;879}): Promise<{ ok: boolean; error?: string }> {880 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };881882 883 884 885 886 887 888 const pending = chrome.storage.session889 ? ((await chrome.storage.session.get(FIREFOX_PENDING_KEY))[FIREFOX_PENDING_KEY] as890 | PendingPasskeyRequest891 | undefined)892 : undefined;893 if (894 !pending ||895 pending.requestId !== payload.requestId ||896 pending.rpId !== payload.rpId ||897 pending.userHandle !== payload.userHandle898 ) {899 return { ok: false, error: "no_pending_request" };900 }901902 const passkey: PasskeyData = {903 rpId: payload.rpId,904 rpName: payload.rpName,905 credentialId: payload.credentialId,906 publicKeyCose: payload.publicKeyCose,907 userHandle: payload.userHandle,908 userName: payload.userName,909 userDisplayName: payload.userDisplayName ?? payload.userName,910 algorithm: payload.algorithm,911 signCount: 0,912 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),913 backupEligible: payload.backupEligible ?? false,914 backupState: payload.backupState ?? false,915 browser: "firefox",916 transports: payload.transports ?? ["internal"],917 createdAt: Date.now(),918 };919920 921 922 923 const target = findAttachTarget(payload.rpId);924 if (target) {925 await vault.updateEntry(target.id, { passkey });926 } else {927 await vault.addEntry({928 type: "login",929 title: payload.rpName || payload.rpId,930 url: `https://${payload.rpId}`,931 username: payload.userName,932 passkey,933 });934 }935 await vault.consolidatePasskeyEntries();936937 if (chrome.storage.session) {938 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);939 }940 return { ok: true };941}942943const MAX_DECLINE_DOMAIN_LENGTH = 253;944945function normaliseDeclineDomain(domain: string): string | null {946 if (!domain || domain.length > MAX_DECLINE_DOMAIN_LENGTH) return null;947 try {948 const url = new URL(`https://${domain}`);949 return isValidRpId(url.hostname, url.origin) ? url.hostname : null;950 } catch {951 return null;952 }953}954955function declineKey(domain: string): string {956 return `${FIREFOX_DECLINE_PREFIX}${domain}`;957}958959export async function recordPasskeyOfferDecline(domain: string): Promise<void> {960 const validDomain = normaliseDeclineDomain(domain);961 if (!validDomain) return;962 await chrome.storage.local.set({ [declineKey(validDomain)]: Date.now() });963}964965export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {966 const validDomain = normaliseDeclineDomain(domain);967 if (!validDomain) return false;968 const key = declineKey(validDomain);969970 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;971 const at = stored[key];972 if (typeof at !== "number") return false;973 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {974 await chrome.storage.local.remove(key);975 return false;976 }977 return true;978}979980981982983984985986987988989990991992993interface FirefoxAllowCredential {994 id: string;995 type?: string;996}997998interface FirefoxPubKeyCredParam {999 type?: string;1000 alg: number;1001}10021003export interface FirefoxPasskeyHasCredentialsPayload {1004 rpId: string;1005 allowCredentials: FirefoxAllowCredential[];1006}10071008export interface FirefoxPasskeyCreatePayload {1009 rpId: string;1010 rpName?: string;1011 userId: string;1012 userName: string;1013 userDisplayName?: string;1014 challenge: string;1015 clientDataJSON: number[];1016 origin: string;1017 pubKeyCredParams?: FirefoxPubKeyCredParam[];1018 authenticatorSelection?: Record<string, unknown>;1019 timeout?: number;1020 attestation?: string;1021}10221023export interface FirefoxPasskeyGetPayload {1024 rpId: string;1025 challenge: string;1026 clientDataJSON: number[];1027 origin: string;1028 allowCredentials: FirefoxAllowCredential[];1029 userVerification?: string;1030}10311032interface FirefoxCreateCredentialResponse {1033 type: "create";1034 id: string;1035 clientDataJSON: number[];1036 attestationObject: string;1037 authenticatorData: string;1038 publicKey: string;1039 algorithm: -7 | -8;1040}10411042interface FirefoxGetCredentialResponse {1043 type: "get";1044 id: string;1045 clientDataJSON: number[];1046 authenticatorData: string;1047 signature: string;1048 userHandle: string;1049}10501051type FirefoxPasskeyResult<T> =1052 | { ok: true; credential: T }1053 | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };10541055function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {1056 return vault.getEntries().filter(1057 (entry): entry is VaultEntry & { passkey: PasskeyData } =>1058 entry.passkey !== undefined && !entry.deletedAt1059 );1060}106110621063function clientDataJsonOrigin(bytes: number[] | undefined): string | null {1064 if (!Array.isArray(bytes)) return null;1065 try {1066 const obj = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes))) as { origin?: unknown };1067 return typeof obj.origin === "string" ? obj.origin : null;1068 } catch {1069 return null;1070 }1071}107210731074107510761077107810791080108110821083function assertOriginOwnsRpId(1084 rpId: string,1085 callerOrigin: string | undefined,1086 clientDataJSON?: number[],1087): void {1088 if (!callerOrigin || !isValidRpId(rpId, callerOrigin)) {1089 throw new DOMException(1090 `SecurityError: relying party ID "${rpId}" is not a registrable domain ` +1091 `suffix of the calling origin "${callerOrigin ?? "(unknown)"}".`,1092 "SecurityError",1093 );1094 }1095 const cdjOrigin = clientDataJsonOrigin(clientDataJSON);1096 if (cdjOrigin !== null && cdjOrigin !== callerOrigin) {1097 throw new DOMException(1098 `SecurityError: clientDataJSON origin "${cdjOrigin}" does not match the ` +1099 `attested calling origin "${callerOrigin}".`,1100 "SecurityError",1101 );1102 }1103}11041105export async function handlePasskeyHasCredentials(1106 payload: FirefoxPasskeyHasCredentialsPayload,1107 callerOrigin?: string,1108): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {1109 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY || (await vault.ensureSettingsLoaded()).passkeyProviderEnabled === false) {1110 return { ok: false, error: "not_applicable" };1111 }1112 1113 1114 1115 if (!callerOrigin || !isValidRpId(payload.rpId, callerOrigin)) {1116 return { ok: true, hasCredentials: false };1117 }1118 if (!vault.isUnlocked()) {1119 1120 return { ok: false, error: "vault_locked" };1121 }1122 const passkeys = listPasskeyEntries().filter(1123 (entry) => entry.passkey.rpId === payload.rpId1124 );1125 if (passkeys.length === 0) return { ok: true, hasCredentials: false };11261127 const allow = payload.allowCredentials ?? [];1128 if (allow.length === 0) return { ok: true, hasCredentials: true };11291130 const hasMatch = passkeys.some((entry) =>1131 allow.some((ac) => ac.id === entry.passkey.credentialId)1132 );1133 return { ok: true, hasCredentials: hasMatch };1134}113511361137113811391140114111421143async function requestFirefoxApproval<T>(args: {1144 type: "create" | "get";1145 rpId: string;1146 rpName?: string;1147 userName: string;1148 userDisplayName?: string;1149}, operation: () => Promise<FirefoxPasskeyResult<T>>): Promise<FirefoxPasskeyResult<T>> {1150 const requestId = randomRequestId();1151 let finish!: () => void;1152 firefoxCeremonies.set(requestId, new Promise<void>((resolve) => { finish = resolve; }));1153 try {1154 const approved = await 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 if (!approved) return { ok: false, error: "user_cancelled" };1163 return await operation();1164 } finally {1165 finish();1166 firefoxCeremonies.delete(requestId);1167 }1168}11691170export async function handleFirefoxPasskeyCreate(1171 payload: FirefoxPasskeyCreatePayload,1172 callerOrigin?: string,1173): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {1174 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY || (await vault.ensureSettingsLoaded()).passkeyProviderEnabled === false) {1175 return { ok: false, error: "not_applicable" };1176 }1177 1178 1179 1180 1181 1182 assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1183 if (!(await isProUser())) {1184 broadcastPasskeyProGate();1185 return { ok: false, error: "pro_required" };1186 }1187 if (!vault.isUnlocked()) {1188 await openApprovalPopup();1189 return { ok: false, error: "vault_locked" };1190 }11911192 const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);1193 if (!algorithm) {1194 return { ok: false, error: "internal_error", message: "No supported algorithm offered" };1195 }11961197 return requestFirefoxApproval<FirefoxCreateCredentialResponse>({1198 type: "create",1199 rpId: payload.rpId,1200 rpName: payload.rpName,1201 userName: payload.userName,1202 userDisplayName: payload.userDisplayName,1203 }, async () => {12041205 const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1206 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);12071208 1209 1210 const authenticatorData = await buildAuthenticatorData({1211 rpId: payload.rpId,1212 flags: 0x4d,1213 signCount: 0,1214 credentialId: credentialIdBytes,1215 publicKeyCose,1216 });1217 const attestationObject = createAttestationObject(authenticatorData);12181219 const passkey: PasskeyData = {1220 rpId: payload.rpId,1221 rpName: payload.rpName ?? payload.rpId,1222 credentialId,1223 publicKeyCose: base64urlEncode(publicKeyCose),1224 privateKeyJwk,1225 userHandle: payload.userId,1226 userName: payload.userName,1227 userDisplayName: payload.userDisplayName ?? payload.userName,1228 algorithm,1229 signCount: 0,1230 aaguid: base64urlEncode(new Uint8Array(16)),1231 backupEligible: true,1232 backupState: false,1233 browser: "firefox",1234 transports: ["internal"],1235 createdAt: Date.now(),1236 };12371238 const target = findAttachTarget(payload.rpId);1239 if (target) {1240 await vault.updateEntry(target.id, { passkey });1241 } else {1242 await vault.addEntry({1243 type: "login",1244 title: payload.rpName || payload.rpId,1245 url: `https://${payload.rpId}`,1246 username: payload.userName,1247 passkey,1248 });1249 }1250 await vault.consolidatePasskeyEntries();12511252 return {1253 ok: true,1254 credential: {1255 type: "create",1256 id: credentialId,1257 1258 1259 1260 clientDataJSON: payload.clientDataJSON,1261 attestationObject: base64urlEncode(attestationObject),1262 authenticatorData: base64urlEncode(authenticatorData),1263 publicKey: base64urlEncode(publicKeySpki),1264 algorithm,1265 },1266 };1267 });1268}12691270export async function handleFirefoxPasskeyGet(1271 payload: FirefoxPasskeyGetPayload,1272 callerOrigin?: string,1273): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1274 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY || (await vault.ensureSettingsLoaded()).passkeyProviderEnabled === false) {1275 return { ok: false, error: "not_applicable" };1276 }1277 1278 1279 1280 1281 1282 1283 assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1284 if (!(await isProUser())) {1285 broadcastPasskeyProGate();1286 return { ok: false, error: "pro_required" };1287 }1288 if (!vault.isUnlocked()) {1289 const requestId = randomRequestId();1290 1291 const unlocked = new Promise<boolean>((resolve) => {1292 const timeoutId = setTimeout(() => {1293 unlockWaiters.delete(requestId);1294 resolve(false);1295 }, APPROVAL_TIMEOUT_MS);1296 unlockWaiters.set(requestId, { resolve, timeoutId });1297 });1298 await openApprovalPopup();1299 if (!(await unlocked) || !vault.isUnlocked()) {1300 return { ok: false, error: "user_cancelled" };1301 }1302 }13031304 const allow = payload.allowCredentials ?? [];1305 const candidates = listPasskeyEntries().filter((entry) => {1306 if (entry.passkey.rpId !== payload.rpId) return false;1307 if (allow.length === 0) return true;1308 return allow.some((ac) => ac.id === entry.passkey.credentialId);1309 });13101311 if (candidates.length === 0) return { ok: false, error: "no_credentials" };13121313 1314 1315 1316 candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1317 const match = candidates[0]!;1318 const credential = match.passkey;13191320 const privateKeyJwk = credential.privateKeyJwk;1321 if (!privateKeyJwk) {1322 1323 1324 1325 return { ok: false, error: "no_credentials" };1326 }13271328 return requestFirefoxApproval<FirefoxGetCredentialResponse>({1329 type: "get",1330 rpId: credential.rpId,1331 rpName: credential.rpName,1332 userName: credential.userName,1333 userDisplayName: credential.userDisplayName,1334 }, async () => {13351336 const nextSignCount = credential.signCount + 1;1337 1338 1339 const backupEligible = credential.browser === "chrome" ? true : credential.backupEligible;1340 const flags = 0x05 | (backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);1341 const authenticatorData = await buildAuthenticatorData({1342 rpId: credential.rpId,1343 flags,1344 signCount: nextSignCount,1345 });13461347 1348 1349 1350 const clientDataBytes = new Uint8Array(payload.clientDataJSON);1351 const clientDataHash = new Uint8Array(1352 await crypto.subtle.digest("SHA-256", clientDataBytes)1353 );1354 const signature = await signPasskeyAssertion(1355 privateKeyJwk,1356 credential.algorithm,1357 authenticatorData,1358 clientDataHash1359 );13601361 await vault.updateEntry(match.id, {1362 passkey: {1363 ...credential,1364 backupEligible,1365 signCount: nextSignCount,1366 lastUsedAt: Date.now(),1367 },1368 });13691370 return {1371 ok: true,1372 credential: {1373 type: "get",1374 id: credential.credentialId,1375 clientDataJSON: payload.clientDataJSON,1376 authenticatorData: base64urlEncode(authenticatorData),1377 signature: base64urlEncode(signature),1378 userHandle: credential.userHandle,1379 },1380 };1381 });1382}13831384export async function setupPasskeyProxy(): Promise<void> {1385 if (!isPasskeySupported()) {1386 return;1387 }13881389 1390 1391 if (detectSharedBrowser() === "firefox") {1392 await refreshPasskeyProxy();1393 return;1394 }13951396 if (!listenersRegistered) {1397 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1398 1399 1400 1401 1402 1403 1404 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1405 requestId: requestInfo.requestId,1406 isUvpaa: true,1407 });1408 });1409 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1410 void handleCreateRequest(requestInfo).catch(async (error) => {1411 1412 1413 1414 if (isInvalidRequestIdError(error)) {1415 return;1416 }1417 console.error("Peach passkey create failed", error);1418 await rejectCreate(1419 requestInfo.requestId,1420 "UnknownError",1421 error instanceof Error ? error.message : "Passkey creation failed."1422 );1423 });1424 });1425 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1426 void handleGetRequest(requestInfo).catch(async (error) => {1427 if (isInvalidRequestIdError(error)) {1428 return;1429 }1430 console.error("Peach passkey get failed", error);1431 await rejectGet(1432 requestInfo.requestId,1433 "UnknownError",1434 error instanceof Error ? error.message : "Passkey authentication failed."1435 );1436 });1437 });1438 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1439 1440 const waiter = unlockWaiters.get(requestId);1441 if (waiter) {1442 clearTimeout(waiter.timeoutId);1443 waiter.resolve(false);1444 unlockWaiters.delete(requestId);1445 }1446 void resolvePendingPasskeyRequest(requestId, false);1447 });1448 listenersRegistered = true;1449 }14501451 await refreshPasskeyProxy();1452}