1import { isProUser } from "./license";2import * as vault from "./vault";3import {4 base64urlEncode,5 buildAuthenticatorData,6 buildClientDataJSON,7 createAttestationObject,8 generateCredentialId,9 generatePasskeyKeypair,10 pickPasskeyAlgorithm,11 signPasskeyAssertion,12} from "./passkey-crypto";13import { detectBrowser as detectSharedBrowser, firefoxVersion } from "../shared/browser";14import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";15import { isValidRpId } from "../shared/passkey-utils";16import type {17 PasskeyData,18 PasskeyProviderState,19 PendingPasskeyRequest,20 VaultEntry,21} from "../shared/types";2223declare const __PEACH_E2E_PASSKEYS__: boolean;2425const PENDING_PREFIX = "passkey_pending_";26const APPROVAL_TIMEOUT_MS = 60_000;2728let listenersRegistered = false;29let attached = false;30let conflict: string | undefined;31const PASSKEY_CREATE_E2E_BYPASS = __PEACH_E2E_PASSKEYS__ === true;3233const approvals = new Map<34 number,35 { resolve: (approved: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }36>();37383940const unlockWaiters = new Map<number, { resolve: (ok: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }>();4142434445const cancelledRequests = new Set<number>();46function markCancelled(requestId: number): void {47 cancelledRequests.add(requestId);48 49 50 51 if (cancelledRequests.size > 256) {52 const iter = cancelledRequests.values();53 for (let i = 0; i < 128; i += 1) cancelledRequests.delete(iter.next().value as number);54 }55}5657export function onVaultUnlocked(): void {58 for (const { resolve, timeoutId } of unlockWaiters.values()) {59 clearTimeout(timeoutId);60 resolve(true);61 }62 unlockWaiters.clear();63}6465666768697071export async function denyAllPendingPasskeyRequests(): Promise<void> {72 if (approvals.size === 0 && unlockWaiters.size === 0) return;73 console.log("[Peach passkey] denyAllPendingPasskeyRequests (popup disconnected)", {74 approvals: approvals.size,75 unlockWaiters: unlockWaiters.size,76 });77 for (const [rid, { resolve, timeoutId }] of approvals.entries()) {78 clearTimeout(timeoutId);79 resolve(false);80 approvals.delete(rid);81 await clearPendingRequest(rid).catch(() => {});82 }83 for (const { resolve, timeoutId } of unlockWaiters.values()) {84 clearTimeout(timeoutId);85 resolve(false);86 }87 unlockWaiters.clear();88}8990function isPasskeySupported(): boolean {91 if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;92 93 94 95 96 if (detectSharedBrowser() === "firefox") {97 const ver = firefoxVersion();98 if (Number.isFinite(ver) && ver >= 150) return true;99 }100 return false;101}102103export const detectBrowser = detectSharedBrowser;104105function getBrowserLabel(): string {106 const ua = navigator.userAgent;107 if (/Firefox\/(\d+)/.test(ua)) {108 const version = ua.match(/Firefox\/(\d+)/)?.[1];109 return version ? `Firefox ${version}` : "Firefox";110 }111 const version = ua.match(/Chrome\/(\d+)/)?.[1];112 return version ? `Chrome ${version}` : "Chromium";113}114115function pendingStorageKey(requestId: number): string {116 return `${PENDING_PREFIX}${requestId}`;117}118119function randomRequestId(): number {120 121 122 123 124 const u = crypto.getRandomValues(new Uint32Array(2));125 return u[0] * 0x200000 + (u[1] >>> 11);126}127128function originFromRpId(rpId: string): string {129 return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)130 ? `http://${rpId}`131 : `https://${rpId}`;132}133134135136137138139140141142143144145146147148149async function resolvePageOrigin(rpId: string): Promise<string> {150 try {151 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>152 chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)153 );154 const url = tabs[0]?.url;155 if (url) {156 const parsed = new URL(url);157 const host = parsed.hostname.toLowerCase();158 const rp = rpId.toLowerCase();159 if (host === rp || host.endsWith(`.${rp}`)) {160 return parsed.origin;161 }162 }163 } catch {164 165 }166 return originFromRpId(rpId);167}168169async function setBadge(text: string): Promise<void> {170 if (!chrome.action?.setBadgeText) return;171 await chrome.action.setBadgeText({ text });172 if (text) {173 await chrome.action.setBadgeBackgroundColor({ color: "#E5805C" });174 }175}176177async function clearPendingRequest(requestId: number): Promise<void> {178 if (chrome.storage.session) {179 await chrome.storage.session.remove(pendingStorageKey(requestId));180 }181 await setBadge("");182}183184async function openApprovalPopup(): Promise<void> {185 try {186 await chrome.action.openPopup();187 } catch {188 await setBadge("PK");189 }190}191192193194195196197198199200function isInvalidRequestIdError(err: unknown): boolean {201 return err instanceof Error && /invalid requestid/i.test(err.message);202}203204205206207208209210211212213function isMissingResponseJsonError(err: unknown): boolean {214 return err instanceof Error && /missing getresponsedetails/i.test(err.message);215}216217async function rejectCreate(218 requestId: number,219 name: string,220 message = ""221): Promise<void> {222 try {223 await chrome.webAuthenticationProxy.completeCreateRequest({224 requestId,225 error: { name, message },226 });227 } catch (err) {228 if (isInvalidRequestIdError(err)) {229 console.log("[Peach passkey] completeCreateRequest skipped (already cancelled)", { requestId });230 return;231 }232 throw err;233 }234}235236async function rejectGet(237 requestId: number,238 name: string,239 message = ""240): Promise<void> {241 try {242 await chrome.webAuthenticationProxy.completeGetRequest({243 requestId,244 error: { name, message },245 });246 } catch (err) {247 if (isInvalidRequestIdError(err)) {248 console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId });249 return;250 }251 throw err;252 }253}254255export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {256 if (!chrome.storage.session) return null; 257 const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;258 const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));259 if (!key) return null;260 const pending = stored[key];261 return pending && typeof pending === "object"262 ? (pending as PendingPasskeyRequest)263 : null;264}265266export async function resolvePendingPasskeyRequest(267 requestId: number,268 approved: boolean269): Promise<void> {270 const pending = approvals.get(requestId);271 if (!pending) {272 273 274 275 276 await clearPendingRequest(requestId);277 if (chrome.storage.session) {278 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);279 }280 return;281 }282283 clearTimeout(pending.timeoutId);284 approvals.delete(requestId);285 await clearPendingRequest(requestId);286 pending.resolve(approved);287}288289async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {290 if (chrome.storage.session) {291 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });292 }293 await openApprovalPopup();294295 return new Promise<boolean>((resolve) => {296 const timeoutId = setTimeout(async () => {297 approvals.delete(request.requestId);298 await clearPendingRequest(request.requestId);299 resolve(false);300 }, APPROVAL_TIMEOUT_MS);301302 approvals.set(request.requestId, { resolve, timeoutId });303 });304}305306export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {307 const settings = await vault.ensureSettingsLoaded();308 const supported = isPasskeySupported();309 const enabled = settings.passkeyProviderEnabled === true;310 return {311 supported,312 enabled,313 attached: supported && attached,314 browserLabel: getBrowserLabel(),315 statusLabel: !supported316 ? "Interception unavailable"317 : attached318 ? "Active"319 : conflict320 ? "Another provider is active"321 : enabled322 ? "Inactive"323 : "Disabled",324 conflict,325 };326}327328async function attachPasskeyProxy(): Promise<void> {329 if (!isPasskeySupported()) {330 attached = false;331 conflict = "This browser does not expose chrome.webAuthenticationProxy.";332 return;333 }334335 336 337 338 if (detectSharedBrowser() === "firefox") {339 attached = true;340 conflict = undefined;341 return;342 }343344 if (attached) return;345346 try {347 await chrome.webAuthenticationProxy.attach();348 attached = true;349 conflict = undefined;350 console.log("[Peach passkey] proxy attached successfully");351 } catch (error) {352 attached = false;353 conflict = error instanceof Error ? error.message : "Another provider is active.";354 console.warn("[Peach passkey] proxy attach failed", { conflict });355 }356}357358async function detachPasskeyProxy(): Promise<void> {359 if (!isPasskeySupported()) {360 attached = false;361 return;362 }363364 if (detectSharedBrowser() === "firefox") {365 attached = false;366 conflict = undefined;367 return;368 }369370 try {371 await chrome.webAuthenticationProxy.detach();372 } catch {373 374 }375376 attached = false;377 conflict = undefined;378}379380export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {381 await vault.updateSettings({ passkeyProviderEnabled: enabled });382 if (enabled) {383 await attachPasskeyProxy();384 } else {385 await detachPasskeyProxy();386 }387 return getPasskeyProviderState();388}389390async function refreshPasskeyProxy(): Promise<void> {391 const settings = await vault.ensureSettingsLoaded();392 if (settings.passkeyProviderEnabled === true) {393 await attachPasskeyProxy();394 return;395 }396 await detachPasskeyProxy();397}398399400401402403404405406function findAttachTarget(rpId: string): VaultEntry | null {407 const all = vault.getEntries();408 const passkeyMatch = all.find(409 (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt410 );411 if (passkeyMatch) return passkeyMatch;412 const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);413 if (loginMatches.length === 0) return null;414 415 return loginMatches.reduce((best, e) =>416 (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best417 );418}419420async function handleCreateRequest(421 request: chrome.webAuthenticationProxy.CreateRequest422): Promise<void> {423 console.log("[Peach passkey] create event received", {424 requestId: request.requestId,425 detailsJson: request.requestDetailsJson,426 });427 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;428 const rpId = options.rp.id;429 const rpName = options.rp.name ?? options.rp.id;430 const userName = options.user.name;431 const userDisplayName = options.user.displayName ?? options.user.name;432 console.log("[Peach passkey] create parsed", { rpId, rpName, userName, unlocked: vault.isUnlocked() });433434 if (!rpId) {435 console.warn("[Peach passkey] reject: missing rp.id");436 await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");437 return;438 }439440 441 442 443 444 445 446 const pageOrigin = await resolvePageOrigin(rpId);447448 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {449 console.warn("[Peach passkey] reject: user is not Pro");450 await rejectCreate(451 request.requestId,452 "NotAllowedError",453 "Passkey creation requires Peach Pro."454 );455 456 broadcastPasskeyProGate();457 return;458 }459460 if (!vault.isUnlocked()) {461 console.log("[Peach passkey] vault locked, summoning inline unlock");462 463 464 465 466 467 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>468 chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)469 );470 const activeTab = tabs[0];471 console.log("[Peach passkey] active tab for unlock summon", { id: activeTab?.id, url: activeTab?.url });472 if (activeTab?.id) {473 chrome.tabs.sendMessage(activeTab.id, {474 type: 'SUMMON_AUTOFILL',475 source: 'passkey-unlock',476 }).catch((err) => {477 console.warn("[Peach passkey] SUMMON_AUTOFILL failed, falling back to popup", err);478 void openApprovalPopup();479 });480 } else {481 await openApprovalPopup();482 }483484 const unlocked = await new Promise<boolean>((resolve) => {485 const timeoutId = setTimeout(() => {486 unlockWaiters.delete(request.requestId);487 resolve(false);488 }, APPROVAL_TIMEOUT_MS);489 unlockWaiters.set(request.requestId, { resolve, timeoutId });490 });491492 console.log("[Peach passkey] unlock wait resolved", { unlocked, vaultUnlocked: vault.isUnlocked() });493 if (!unlocked || !vault.isUnlocked()) {494 console.warn("[Peach passkey] reject: unlock timeout");495 await rejectCreate(request.requestId, "NotAllowedError", "Not unlocked in time.");496 return;497 }498 499 }500501 console.log("[Peach passkey] requesting approval via popup");502 const approved = await promptUserApproval({503 requestId: request.requestId,504 type: "create",505 rpId,506 rpName,507 userName,508 userDisplayName,509 });510 console.log("[Peach passkey] approval resolved", { approved });511 if (!approved) {512 console.warn("[Peach passkey] reject: user denied");513 await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");514 return;515 }516517 const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);518 if (!algorithm) {519 console.warn("[Peach passkey] reject: no supported algorithm", options.pubKeyCredParams);520 await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");521 return;522 }523 console.log("[Peach passkey] algorithm selected", algorithm);524525 const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));526 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(527 algorithm528 );529 const authData = await buildAuthenticatorData({530 rpId,531 flags: 0x4d,532 signCount: 0,533 credentialId: credentialIdBytes,534 publicKeyCose,535 });536 const attestationObject = createAttestationObject(authData);537 const clientDataJSON = buildClientDataJSON(538 "webauthn.create",539 options.challenge,540 pageOrigin541 );542543 const passkey: PasskeyData = {544 rpId,545 rpName,546 credentialId: base64urlEncode(credentialIdBytes),547 publicKeyCose: base64urlEncode(publicKeyCose),548 privateKeyJwk,549 userHandle: options.user.id,550 userName,551 userDisplayName,552 algorithm,553 signCount: 0,554 aaguid: base64urlEncode(new Uint8Array(16)),555 backupEligible: false,556 backupState: false,557 browser: "chrome",558 transports: ["internal"],559 createdAt: Date.now(),560 };561562 563 564 const target = findAttachTarget(rpId);565 if (target) {566 await vault.updateEntry(target.id, { passkey });567 } else {568 await vault.addEntry({569 type: "login",570 title: rpName,571 url: `https://${rpId}`,572 username: userName,573 passkey,574 });575 }576 577 578 579 await vault.consolidatePasskeyEntries();580581 try {582 await chrome.webAuthenticationProxy.completeCreateRequest({583 requestId: request.requestId,584 responseJson: JSON.stringify({585 id: passkey.credentialId,586 rawId: passkey.credentialId,587 type: "public-key",588 response: {589 clientDataJSON: base64urlEncode(clientDataJSON),590 authenticatorData: base64urlEncode(authData),591 attestationObject: base64urlEncode(attestationObject),592 transports: ["internal"],593 publicKey: base64urlEncode(publicKeySpki),594 publicKeyAlgorithm: algorithm,595 },596 authenticatorAttachment: "platform",597 clientExtensionResults: {},598 }),599 });600 console.log("[Peach passkey] completeCreateRequest success", { requestId: request.requestId });601 } catch (err) {602 if (isInvalidRequestIdError(err)) {603 console.log("[Peach passkey] completeCreateRequest skipped (already cancelled)", { requestId: request.requestId });604 return;605 }606 throw err;607 }608}609610function broadcastPasskeyProGate(): void {611 chrome.tabs.query({}, (tabs) => {612 for (const tab of tabs) {613 if (tab.id !== undefined) {614 chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});615 }616 }617 });618}619620async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {621 if (chrome.storage.session) {622 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });623 }624625 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {626 chrome.tabs.query({ active: true, currentWindow: true }, resolve);627 });628 const tab = tabs[0];629630 if (tab?.id) {631 chrome.tabs.sendMessage(tab.id, {632 type: 'SHOW_PASSKEY_TOAST',633 payload: {634 site: request.rpId,635 displayName: request.userDisplayName ?? request.userName,636 username: request.userName,637 requestId: String(request.requestId),638 },639 }).catch(() => {});640 } else {641 await openApprovalPopup();642 }643644 return new Promise<boolean>((resolve) => {645 const timeoutId = setTimeout(async () => {646 approvals.delete(request.requestId);647 await clearPendingRequest(request.requestId);648 resolve(false);649 }, APPROVAL_TIMEOUT_MS);650651 approvals.set(request.requestId, { resolve, timeoutId });652 });653}654655async function handleGetRequest(656 request: chrome.webAuthenticationProxy.GetRequest657): Promise<void> {658 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;659 if (!vault.isUnlocked()) {660 661 662 663 const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>664 chrome.tabs.query({ active: true, currentWindow: true }, resolve)665 );666 const activeTab = tabs[0];667 if (activeTab?.id) {668 chrome.tabs.sendMessage(activeTab.id, {669 type: 'SUMMON_AUTOFILL',670 source: 'passkey-unlock',671 }).catch(() => { void openApprovalPopup(); });672 } else {673 await openApprovalPopup();674 }675676 677 const unlocked = await new Promise<boolean>((resolve) => {678 const timeoutId = setTimeout(() => {679 unlockWaiters.delete(request.requestId);680 resolve(false);681 }, APPROVAL_TIMEOUT_MS);682 unlockWaiters.set(request.requestId, { resolve, timeoutId });683 });684685 if (!unlocked || !vault.isUnlocked()) {686 await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");687 return;688 }689 690 }691692 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {693 await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");694 broadcastPasskeyProGate();695 return;696 }697698 const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>699 entry.passkey !== undefined && !entry.deletedAt700 );701 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);702703 const match = passkeyBearing.find((entry) => {704 if (allowedCredentialIds.length > 0) {705 return allowedCredentialIds.includes(entry.passkey.credentialId);706 }707 if (!options.rpId) return false;708 return entry.passkey.rpId === options.rpId;709 });710711 if (!match) {712 713 714 715 716 717 718 console.log("[Peach passkey] no matching credential — passing through to platform", { requestId: request.requestId });719 try {720 await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });721 } catch (err) {722 if (isInvalidRequestIdError(err)) return;723 if (isMissingResponseJsonError(err)) return;724 throw err;725 }726 return;727 }728729 const credential = match.passkey;730 731 732 733 if (!credential.privateKeyJwk) {734 console.log("[Peach passkey] no private key (Firefox passkey) — passing through to platform", { requestId: request.requestId });735 try {736 await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });737 } catch (err) {738 if (isInvalidRequestIdError(err)) return;739 if (isMissingResponseJsonError(err)) return;740 throw err;741 }742 return;743 }744745 const approved = await promptViaToast({746 requestId: request.requestId,747 type: "get",748 rpId: credential.rpId,749 rpName: credential.rpName,750 userName: credential.userName,751 userDisplayName: credential.userDisplayName,752 });753 if (!approved) {754 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");755 return;756 }757758 const nextSignCount = credential.signCount + 1;759 const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);760 const authData = await buildAuthenticatorData({761 rpId: credential.rpId,762 flags,763 signCount: nextSignCount,764 });765 const clientDataJSON = buildClientDataJSON(766 "webauthn.get",767 options.challenge,768 await resolvePageOrigin(credential.rpId)769 );770 const clientDataHash = new Uint8Array(771 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))772 );773 const signature = await signPasskeyAssertion(774 credential.privateKeyJwk,775 credential.algorithm,776 authData,777 clientDataHash778 );779780 await vault.updateEntry(match.id, {781 passkey: {782 ...credential,783 signCount: nextSignCount,784 lastUsedAt: Date.now(),785 },786 });787788 const responseJson = JSON.stringify({789 id: credential.credentialId,790 rawId: credential.credentialId,791 type: "public-key",792 response: {793 clientDataJSON: base64urlEncode(clientDataJSON),794 authenticatorData: base64urlEncode(authData),795 signature: base64urlEncode(signature),796 userHandle: credential.userHandle,797 },798 authenticatorAttachment: "platform",799 clientExtensionResults: {},800 });801802 try {803 await chrome.webAuthenticationProxy.completeGetRequest({804 requestId: request.requestId,805 responseJson,806 });807 console.log("[Peach passkey] completeGetRequest success", { requestId: request.requestId });808 } catch (err) {809 if (isInvalidRequestIdError(err)) {810 console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId: request.requestId });811 return;812 }813 throw err;814 }815}816817818819820821822823824825826const FIREFOX_PENDING_KEY = "passkey_pending_firefox";827const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";828const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; 829830export async function initiateFirefoxPasskeyCreate(args: {831 rpId: string;832 rpName: string;833 userName: string;834 userDisplayName?: string;835}, callerOrigin?: string): Promise<{ ok: boolean; error?: string }> {836 837 838 839 840 841 if (!callerOrigin || !isValidRpId(args.rpId, callerOrigin)) {842 return { ok: false, error: "rpid_origin_mismatch" };843 }844 if (!(await isProUser())) {845 broadcastPasskeyProGate();846 return { ok: false, error: "passkey creation requires Peach Pro" };847 }848 if (!vault.isUnlocked()) {849 await openApprovalPopup();850 return { ok: false, error: "vault_locked" };851 }852 if (!chrome.storage.session) {853 return { ok: false, error: "session storage unavailable" };854 }855856 const requestId = randomRequestId();857 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));858 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));859860 const pending: PendingPasskeyRequest = {861 requestId,862 type: "create",863 rpId: args.rpId,864 rpName: args.rpName,865 userName: args.userName,866 userDisplayName: args.userDisplayName,867 browser: "firefox",868 challenge,869 userHandle,870 };871872 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });873 await openApprovalPopup();874 return { ok: true };875}876877export async function completeFirefoxPasskeyCreate(payload: {878 requestId: number;879 rpId: string;880 rpName: string;881 userName: string;882 userDisplayName?: string;883 userHandle: string;884 credentialId: string;885 publicKeyCose: string;886 algorithm: -7 | -8;887 aaguid?: string;888 transports?: string[];889 backupEligible?: boolean;890 backupState?: boolean;891}): Promise<{ ok: boolean; error?: string }> {892 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };893894 895 896 897 898 899 900 const pending = chrome.storage.session901 ? ((await chrome.storage.session.get(FIREFOX_PENDING_KEY))[FIREFOX_PENDING_KEY] as902 | PendingPasskeyRequest903 | undefined)904 : undefined;905 if (906 !pending ||907 pending.requestId !== payload.requestId ||908 pending.rpId !== payload.rpId ||909 pending.userHandle !== payload.userHandle910 ) {911 return { ok: false, error: "no_pending_request" };912 }913914 const passkey: PasskeyData = {915 rpId: payload.rpId,916 rpName: payload.rpName,917 credentialId: payload.credentialId,918 publicKeyCose: payload.publicKeyCose,919 userHandle: payload.userHandle,920 userName: payload.userName,921 userDisplayName: payload.userDisplayName ?? payload.userName,922 algorithm: payload.algorithm,923 signCount: 0,924 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),925 backupEligible: payload.backupEligible ?? false,926 backupState: payload.backupState ?? false,927 browser: "firefox",928 transports: payload.transports ?? ["internal"],929 createdAt: Date.now(),930 };931932 933 934 935 const target = findAttachTarget(payload.rpId);936 if (target) {937 await vault.updateEntry(target.id, { passkey });938 } else {939 await vault.addEntry({940 type: "login",941 title: payload.rpName || payload.rpId,942 url: `https://${payload.rpId}`,943 username: payload.userName,944 passkey,945 });946 }947 await vault.consolidatePasskeyEntries();948949 if (chrome.storage.session) {950 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);951 }952 return { ok: true };953}954955export async function cancelFirefoxPasskeyCreate(): Promise<void> {956 if (chrome.storage.session) {957 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);958 }959}960961function declineKey(domain: string): string {962 return `${FIREFOX_DECLINE_PREFIX}${domain}`;963}964965export async function recordPasskeyOfferDecline(domain: string): Promise<void> {966 if (!domain) return;967 await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });968}969970export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {971 if (!domain) return false;972 const key = declineKey(domain);973 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;974 const at = stored[key];975 if (typeof at !== "number") return false;976 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {977 await chrome.storage.local.remove(key);978 return false;979 }980 return true;981}982983984985986987988989990991992993994995996interface FirefoxAllowCredential {997 id: string;998 type?: string;999}10001001interface FirefoxPubKeyCredParam {1002 type?: string;1003 alg: number;1004}10051006export interface FirefoxPasskeyHasCredentialsPayload {1007 rpId: string;1008 allowCredentials: FirefoxAllowCredential[];1009}10101011export interface FirefoxPasskeyCreatePayload {1012 rpId: string;1013 rpName?: string;1014 userId: string;1015 userName: string;1016 userDisplayName?: string;1017 challenge: string;1018 clientDataJSON: number[];1019 origin: string;1020 pubKeyCredParams?: FirefoxPubKeyCredParam[];1021 authenticatorSelection?: Record<string, unknown>;1022 timeout?: number;1023 attestation?: string;1024}10251026export interface FirefoxPasskeyGetPayload {1027 rpId: string;1028 challenge: string;1029 clientDataJSON: number[];1030 origin: string;1031 allowCredentials: FirefoxAllowCredential[];1032 userVerification?: string;1033}10341035interface FirefoxCreateCredentialResponse {1036 type: "create";1037 id: string;1038 clientDataJSON: number[];1039 attestationObject: string;1040 authenticatorData: string;1041 publicKey: string;1042 algorithm: -7 | -8;1043}10441045interface FirefoxGetCredentialResponse {1046 type: "get";1047 id: string;1048 clientDataJSON: number[];1049 authenticatorData: string;1050 signature: string;1051 userHandle: string;1052}10531054type FirefoxPasskeyResult<T> =1055 | { ok: true; credential: T }1056 | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };10571058function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {1059 return vault.getEntries().filter(1060 (entry): entry is VaultEntry & { passkey: PasskeyData } =>1061 entry.passkey !== undefined && !entry.deletedAt1062 );1063}106410651066function clientDataJsonOrigin(bytes: number[] | undefined): string | null {1067 if (!Array.isArray(bytes)) return null;1068 try {1069 const obj = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes))) as { origin?: unknown };1070 return typeof obj.origin === "string" ? obj.origin : null;1071 } catch {1072 return null;1073 }1074}107510761077107810791080108110821083108410851086function assertOriginOwnsRpId(1087 rpId: string,1088 callerOrigin: string | undefined,1089 clientDataJSON?: number[],1090): void {1091 if (!callerOrigin || !isValidRpId(rpId, callerOrigin)) {1092 throw new DOMException(1093 `SecurityError: relying party ID "${rpId}" is not a registrable domain ` +1094 `suffix of the calling origin "${callerOrigin ?? "(unknown)"}".`,1095 "SecurityError",1096 );1097 }1098 const cdjOrigin = clientDataJsonOrigin(clientDataJSON);1099 if (cdjOrigin !== null && cdjOrigin !== callerOrigin) {1100 throw new DOMException(1101 `SecurityError: clientDataJSON origin "${cdjOrigin}" does not match the ` +1102 `attested calling origin "${callerOrigin}".`,1103 "SecurityError",1104 );1105 }1106}11071108export async function handlePasskeyHasCredentials(1109 payload: FirefoxPasskeyHasCredentialsPayload,1110 callerOrigin?: string,1111): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {1112 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1113 return { ok: false, error: "not_applicable" };1114 }1115 1116 1117 1118 if (!callerOrigin || !isValidRpId(payload.rpId, callerOrigin)) {1119 return { ok: true, hasCredentials: false };1120 }1121 if (!vault.isUnlocked()) {1122 1123 1124 1125 return { ok: true, hasCredentials: false };1126 }1127 const passkeys = listPasskeyEntries().filter(1128 (entry) => entry.passkey.rpId === payload.rpId1129 );1130 if (passkeys.length === 0) return { ok: true, hasCredentials: false };11311132 const allow = payload.allowCredentials ?? [];1133 if (allow.length === 0) return { ok: true, hasCredentials: true };11341135 const hasMatch = passkeys.some((entry) =>1136 allow.some((ac) => ac.id === entry.passkey.credentialId)1137 );1138 return { ok: true, hasCredentials: hasMatch };1139}114011411142114311441145114611471148async function requestFirefoxApproval(args: {1149 type: "create" | "get";1150 rpId: string;1151 rpName?: string;1152 userName: string;1153 userDisplayName?: string;1154}): Promise<boolean> {1155 1156 1157 const requestId = randomRequestId();1158 return promptUserApproval({1159 requestId,1160 type: args.type,1161 rpId: args.rpId,1162 rpName: args.rpName ?? args.rpId,1163 userName: args.userName,1164 userDisplayName: args.userDisplayName,1165 });1166}11671168export async function handleFirefoxPasskeyCreate(1169 payload: FirefoxPasskeyCreatePayload,1170 callerOrigin?: string,1171): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {1172 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1173 return { ok: false, error: "not_applicable" };1174 }1175 1176 1177 1178 1179 1180 assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1181 if (!(await isProUser())) {1182 broadcastPasskeyProGate();1183 return { ok: false, error: "pro_required" };1184 }1185 if (!vault.isUnlocked()) {1186 await openApprovalPopup();1187 return { ok: false, error: "vault_locked" };1188 }11891190 const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);1191 if (!algorithm) {1192 return { ok: false, error: "internal_error", message: "No supported algorithm offered" };1193 }11941195 const approved = await requestFirefoxApproval({1196 type: "create",1197 rpId: payload.rpId,1198 rpName: payload.rpName,1199 userName: payload.userName,1200 userDisplayName: payload.userDisplayName,1201 });1202 if (!approved) return { ok: false, error: "user_cancelled" };12031204 const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1205 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);12061207 1208 1209 const authenticatorData = await buildAuthenticatorData({1210 rpId: payload.rpId,1211 flags: 0x45,1212 signCount: 0,1213 credentialId: credentialIdBytes,1214 publicKeyCose,1215 });1216 const attestationObject = createAttestationObject(authenticatorData);12171218 const passkey: PasskeyData = {1219 rpId: payload.rpId,1220 rpName: payload.rpName ?? payload.rpId,1221 credentialId,1222 publicKeyCose: base64urlEncode(publicKeyCose),1223 privateKeyJwk,1224 userHandle: payload.userId,1225 userName: payload.userName,1226 userDisplayName: payload.userDisplayName ?? payload.userName,1227 algorithm,1228 signCount: 0,1229 aaguid: base64urlEncode(new Uint8Array(16)),1230 backupEligible: false,1231 backupState: false,1232 browser: "firefox",1233 transports: ["internal"],1234 createdAt: Date.now(),1235 };12361237 const target = findAttachTarget(payload.rpId);1238 if (target) {1239 await vault.updateEntry(target.id, { passkey });1240 } else {1241 await vault.addEntry({1242 type: "login",1243 title: payload.rpName || payload.rpId,1244 url: `https://${payload.rpId}`,1245 username: payload.userName,1246 passkey,1247 });1248 }1249 await vault.consolidatePasskeyEntries();12501251 return {1252 ok: true,1253 credential: {1254 type: "create",1255 id: credentialId,1256 1257 1258 1259 clientDataJSON: payload.clientDataJSON,1260 attestationObject: base64urlEncode(attestationObject),1261 authenticatorData: base64urlEncode(authenticatorData),1262 publicKey: base64urlEncode(publicKeySpki),1263 algorithm,1264 },1265 };1266}12671268export async function handleFirefoxPasskeyGet(1269 payload: FirefoxPasskeyGetPayload,1270 callerOrigin?: string,1271): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1272 if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1273 return { ok: false, error: "not_applicable" };1274 }1275 1276 1277 1278 1279 1280 1281 assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1282 if (!(await isProUser())) {1283 broadcastPasskeyProGate();1284 return { ok: false, error: "pro_required" };1285 }1286 if (!vault.isUnlocked()) {1287 await openApprovalPopup();1288 return { ok: false, error: "vault_locked" };1289 }12901291 const allow = payload.allowCredentials ?? [];1292 const candidates = listPasskeyEntries().filter((entry) => {1293 if (entry.passkey.rpId !== payload.rpId) return false;1294 if (allow.length === 0) return true;1295 return allow.some((ac) => ac.id === entry.passkey.credentialId);1296 });12971298 if (candidates.length === 0) return { ok: false, error: "no_credentials" };12991300 1301 1302 1303 candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1304 const match = candidates[0]!;1305 const credential = match.passkey;13061307 if (!credential.privateKeyJwk) {1308 1309 1310 1311 return { ok: false, error: "no_credentials" };1312 }13131314 const approved = await requestFirefoxApproval({1315 type: "get",1316 rpId: credential.rpId,1317 rpName: credential.rpName,1318 userName: credential.userName,1319 userDisplayName: credential.userDisplayName,1320 });1321 if (!approved) return { ok: false, error: "user_cancelled" };13221323 const nextSignCount = credential.signCount + 1;1324 1325 1326 const flags =1327 0x05 |1328 (credential.backupEligible ? 0x08 : 0) |1329 (credential.backupState ? 0x10 : 0);1330 const authenticatorData = await buildAuthenticatorData({1331 rpId: credential.rpId,1332 flags,1333 signCount: nextSignCount,1334 });13351336 1337 1338 1339 const clientDataBytes = new Uint8Array(payload.clientDataJSON);1340 const clientDataHash = new Uint8Array(1341 await crypto.subtle.digest("SHA-256", clientDataBytes)1342 );1343 const signature = await signPasskeyAssertion(1344 credential.privateKeyJwk,1345 credential.algorithm,1346 authenticatorData,1347 clientDataHash1348 );13491350 await vault.updateEntry(match.id, {1351 passkey: {1352 ...credential,1353 signCount: nextSignCount,1354 lastUsedAt: Date.now(),1355 },1356 });13571358 return {1359 ok: true,1360 credential: {1361 type: "get",1362 id: credential.credentialId,1363 clientDataJSON: payload.clientDataJSON,1364 authenticatorData: base64urlEncode(authenticatorData),1365 signature: base64urlEncode(signature),1366 userHandle: credential.userHandle,1367 },1368 };1369}13701371export async function setupPasskeyProxy(): Promise<void> {1372 if (!isPasskeySupported()) {1373 console.log("[Peach passkey] setupPasskeyProxy: not supported on this browser");1374 return;1375 }13761377 1378 1379 if (detectSharedBrowser() === "firefox") {1380 await refreshPasskeyProxy();1381 return;1382 }13831384 if (!listenersRegistered) {1385 console.log("[Peach passkey] registering webAuthenticationProxy listeners");1386 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1387 1388 1389 1390 1391 1392 1393 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1394 requestId: requestInfo.requestId,1395 isUvpaa: true,1396 });1397 });1398 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1399 console.log("[Peach passkey] onCreateRequest fired", { requestId: requestInfo.requestId });1400 void handleCreateRequest(requestInfo).catch(async (error) => {1401 1402 1403 1404 if (isInvalidRequestIdError(error)) {1405 console.log("[Peach passkey] create handler resolved after cancel", { requestId: requestInfo.requestId });1406 return;1407 }1408 console.error("Peach passkey create failed", error);1409 await rejectCreate(1410 requestInfo.requestId,1411 "UnknownError",1412 error instanceof Error ? error.message : "Passkey creation failed."1413 );1414 });1415 });1416 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1417 console.log("[Peach passkey] onGetRequest fired", { requestId: requestInfo.requestId });1418 void handleGetRequest(requestInfo).catch(async (error) => {1419 if (isInvalidRequestIdError(error)) {1420 console.log("[Peach passkey] get handler resolved after cancel", { requestId: requestInfo.requestId });1421 return;1422 }1423 console.error("Peach passkey get failed", error);1424 await rejectGet(1425 requestInfo.requestId,1426 "UnknownError",1427 error instanceof Error ? error.message : "Passkey authentication failed."1428 );1429 });1430 });1431 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1432 console.log("[Peach passkey] onRequestCanceled", { requestId });1433 markCancelled(requestId);1434 1435 const waiter = unlockWaiters.get(requestId);1436 if (waiter) {1437 clearTimeout(waiter.timeoutId);1438 waiter.resolve(false);1439 unlockWaiters.delete(requestId);1440 }1441 void resolvePendingPasskeyRequest(requestId, false);1442 });1443 listenersRegistered = true;1444 }14451446 await refreshPasskeyProxy();1447}