1import { isProUser } from "./license";2import * as vault from "./vault";3import {4 base64urlDecode,5 base64urlEncode,6 buildAuthenticatorData,7 buildClientDataJSON,8 createAttestationObject,9 generatePasskeyKeypair,10 pickPasskeyAlgorithm,11 signPasskeyAssertion,12} from "./passkey-crypto";13import { detectBrowser as detectSharedBrowser, firefoxVersion } from "../shared/browser";14import type {15 PasskeyEntry,16 PasskeyProviderState,17 PendingPasskeyRequest,18 VaultEntry,19} from "../shared/types";2021declare const __PEACH_E2E_PASSKEYS__: boolean;2223const PENDING_PREFIX = "passkey_pending_";24const APPROVAL_TIMEOUT_MS = 60_000;2526let listenersRegistered = false;27let attached = false;28let conflict: string | undefined;29const PASSKEY_CREATE_E2E_BYPASS = __PEACH_E2E_PASSKEYS__ === true;3031const approvals = new Map<32 number,33 { resolve: (approved: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }34>();3536function isPasskeySupported(): boolean {37 if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;38 39 40 41 42 if (detectSharedBrowser() === "firefox") {43 const ver = firefoxVersion();44 if (Number.isFinite(ver) && ver >= 150) return true;45 }46 return false;47}4849export const detectBrowser = detectSharedBrowser;5051function getBrowserLabel(): string {52 const ua = navigator.userAgent;53 if (/Firefox\/(\d+)/.test(ua)) {54 const version = ua.match(/Firefox\/(\d+)/)?.[1];55 return version ? `Firefox ${version}` : "Firefox";56 }57 const version = ua.match(/Chrome\/(\d+)/)?.[1];58 return version ? `Chrome ${version}` : "Chromium";59}6061function pendingStorageKey(requestId: number): string {62 return `${PENDING_PREFIX}${requestId}`;63}6465function originFromRpId(rpId: string): string {66 return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)67 ? `http://${rpId}`68 : `https://${rpId}`;69}7071async function setBadge(text: string): Promise<void> {72 if (!chrome.action?.setBadgeText) return;73 await chrome.action.setBadgeText({ text });74 if (text) {75 await chrome.action.setBadgeBackgroundColor({ color: "#D96830" });76 }77}7879async function clearPendingRequest(requestId: number): Promise<void> {80 if (chrome.storage.session) {81 await chrome.storage.session.remove(pendingStorageKey(requestId));82 }83 await setBadge("");84}8586async function openApprovalPopup(): Promise<void> {87 try {88 await chrome.action.openPopup();89 } catch {90 await setBadge("PK");91 }92}9394async function rejectCreate(95 requestId: number,96 name: string,97 message = ""98): Promise<void> {99 await chrome.webAuthenticationProxy.completeCreateRequest({100 requestId,101 error: { name, message },102 });103}104105async function rejectGet(106 requestId: number,107 name: string,108 message = ""109): Promise<void> {110 await chrome.webAuthenticationProxy.completeGetRequest({111 requestId,112 error: { name, message },113 });114}115116export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {117 if (!chrome.storage.session) return null; 118 const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;119 const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));120 if (!key) return null;121 const pending = stored[key];122 return pending && typeof pending === "object"123 ? (pending as PendingPasskeyRequest)124 : null;125}126127export async function resolvePendingPasskeyRequest(128 requestId: number,129 approved: boolean130): Promise<void> {131 const pending = approvals.get(requestId);132 if (!pending) {133 134 135 136 137 await clearPendingRequest(requestId);138 if (chrome.storage.session) {139 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);140 }141 return;142 }143144 clearTimeout(pending.timeoutId);145 approvals.delete(requestId);146 await clearPendingRequest(requestId);147 pending.resolve(approved);148}149150async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {151 if (chrome.storage.session) {152 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });153 }154 await openApprovalPopup();155156 return new Promise<boolean>((resolve) => {157 const timeoutId = setTimeout(async () => {158 approvals.delete(request.requestId);159 await clearPendingRequest(request.requestId);160 resolve(false);161 }, APPROVAL_TIMEOUT_MS);162163 approvals.set(request.requestId, { resolve, timeoutId });164 });165}166167export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {168 const settings = await vault.ensureSettingsLoaded();169 const supported = isPasskeySupported();170 const enabled = settings.passkeyProviderEnabled === true;171 return {172 supported,173 enabled,174 attached: supported && attached,175 browserLabel: getBrowserLabel(),176 statusLabel: !supported177 ? "Interception unavailable"178 : attached179 ? "Active"180 : conflict181 ? "Another provider is active"182 : enabled183 ? "Inactive"184 : "Disabled",185 conflict,186 };187}188189async function attachPasskeyProxy(): Promise<void> {190 if (!isPasskeySupported()) {191 attached = false;192 conflict = "This browser does not expose chrome.webAuthenticationProxy.";193 return;194 }195196 197 198 199 if (detectSharedBrowser() === "firefox") {200 attached = true;201 conflict = undefined;202 return;203 }204205 if (attached) return;206207 try {208 await chrome.webAuthenticationProxy.attach();209 attached = true;210 conflict = undefined;211 } catch (error) {212 attached = false;213 conflict = error instanceof Error ? error.message : "Another provider is active.";214 }215}216217async function detachPasskeyProxy(): Promise<void> {218 if (!isPasskeySupported()) {219 attached = false;220 return;221 }222223 if (detectSharedBrowser() === "firefox") {224 attached = false;225 conflict = undefined;226 return;227 }228229 try {230 await chrome.webAuthenticationProxy.detach();231 } catch {232 233 }234235 attached = false;236 conflict = undefined;237}238239export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {240 await vault.updateSettings({ passkeyProviderEnabled: enabled });241 if (enabled) {242 await attachPasskeyProxy();243 } else {244 await detachPasskeyProxy();245 }246 return getPasskeyProviderState();247}248249async function refreshPasskeyProxy(): Promise<void> {250 const settings = await vault.ensureSettingsLoaded();251 if (settings.passkeyProviderEnabled === true) {252 await attachPasskeyProxy();253 return;254 }255 await detachPasskeyProxy();256}257258function passkeyEntries(entries: VaultEntry[]): PasskeyEntry[] {259 return entries.filter((entry): entry is PasskeyEntry => entry.type === "passkey");260}261262async function handleCreateRequest(263 request: chrome.webAuthenticationProxy.CreateRequest264): Promise<void> {265 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;266 const rpId = options.rp.id;267 const rpName = options.rp.name ?? options.rp.id;268 const userName = options.user.name;269 const userDisplayName = options.user.displayName ?? options.user.name;270271 if (!rpId) {272 await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");273 return;274 }275276 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {277 await rejectCreate(278 request.requestId,279 "NotAllowedError",280 "Passkey creation requires Peach Pro."281 );282 283 broadcastPasskeyProGate();284 return;285 }286287 if (!vault.isUnlocked()) {288 await openApprovalPopup();289 await rejectCreate(request.requestId, "NotAllowedError", "Unlock Peach first.");290 return;291 }292293 const approved = await promptUserApproval({294 requestId: request.requestId,295 type: "create",296 rpId,297 rpName,298 userName,299 userDisplayName,300 });301 if (!approved) {302 await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");303 return;304 }305306 const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);307 if (!algorithm) {308 await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");309 return;310 }311312 const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));313 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(314 algorithm315 );316 const authData = await buildAuthenticatorData({317 rpId,318 flags: 0x4d,319 signCount: 0,320 credentialId: credentialIdBytes,321 publicKeyCose,322 });323 const attestationObject = createAttestationObject(authData);324 const clientDataJSON = buildClientDataJSON("webauthn.create", options.challenge, originFromRpId(rpId));325326 const entry: PasskeyEntry = {327 id: crypto.randomUUID(),328 type: "passkey",329 title: rpName,330 rpId,331 rpName,332 credentialId: base64urlEncode(credentialIdBytes),333 publicKeyCose: base64urlEncode(publicKeyCose),334 privateKeyJwk,335 userHandle: options.user.id,336 userName,337 userDisplayName,338 algorithm,339 signCount: 0,340 aaguid: base64urlEncode(new Uint8Array(16)),341 backupEligible: true,342 backupState: false,343 browser: "chrome",344 transports: ["internal"],345 createdAt: Date.now(),346 modifiedAt: Date.now(),347 };348349 await vault.addEntry(entry);350351 await chrome.webAuthenticationProxy.completeCreateRequest({352 requestId: request.requestId,353 responseJson: JSON.stringify({354 id: entry.credentialId,355 rawId: entry.credentialId,356 type: "public-key",357 response: {358 clientDataJSON: base64urlEncode(clientDataJSON),359 authenticatorData: base64urlEncode(authData),360 attestationObject: base64urlEncode(attestationObject),361 transports: ["internal"],362 publicKey: base64urlEncode(publicKeySpki),363 publicKeyAlgorithm: algorithm,364 },365 authenticatorAttachment: "platform",366 clientExtensionResults: {},367 }),368 });369}370371function broadcastPasskeyProGate(): void {372 chrome.tabs.query({}, (tabs) => {373 for (const tab of tabs) {374 if (tab.id !== undefined) {375 chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});376 }377 }378 });379}380381async function handleGetRequest(382 request: chrome.webAuthenticationProxy.GetRequest383): Promise<void> {384 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;385 if (!vault.isUnlocked()) {386 await openApprovalPopup();387 await rejectGet(request.requestId, "NotAllowedError", "Unlock Peach first.");388 return;389 }390391 if (!(await isProUser())) {392 await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");393 broadcastPasskeyProGate();394 return;395 }396397 const entries = passkeyEntries(vault.getEntries());398 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);399400 const match = entries.find((entry) => {401 if (allowedCredentialIds.length > 0) {402 return allowedCredentialIds.includes(entry.credentialId);403 }404 if (!options.rpId) return false;405 return entry.rpId === options.rpId;406 });407408 if (!match) {409 await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");410 return;411 }412413 414 415 416 417 if (!match.privateKeyJwk) {418 await rejectGet(419 request.requestId,420 "NotAllowedError",421 "This passkey was created on Firefox; sign in via the platform authenticator."422 );423 return;424 }425426 const approved = await promptUserApproval({427 requestId: request.requestId,428 type: "get",429 rpId: match.rpId,430 rpName: match.rpName,431 userName: match.userName,432 userDisplayName: match.userDisplayName,433 });434 if (!approved) {435 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");436 return;437 }438439 const nextSignCount = (match.signCount ?? 0) + 1;440 const flags = 0x05 | (match.backupEligible ? 0x08 : 0) | (match.backupState ? 0x10 : 0);441 const authData = await buildAuthenticatorData({442 rpId: match.rpId,443 flags,444 signCount: nextSignCount,445 });446 const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(match.rpId));447 const clientDataHash = new Uint8Array(448 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))449 );450 const signature = await signPasskeyAssertion(451 match.privateKeyJwk,452 match.algorithm,453 authData,454 clientDataHash455 );456457 await vault.updateEntry(match.id, {458 signCount: nextSignCount,459 lastUsedAt: Date.now(),460 });461462 await chrome.webAuthenticationProxy.completeGetRequest({463 requestId: request.requestId,464 responseJson: JSON.stringify({465 id: match.credentialId,466 rawId: match.credentialId,467 type: "public-key",468 response: {469 clientDataJSON: base64urlEncode(clientDataJSON),470 authenticatorData: base64urlEncode(authData),471 signature: base64urlEncode(signature),472 userHandle: match.userHandle,473 },474 authenticatorAttachment: "platform",475 clientExtensionResults: {},476 }),477 });478}479480481482483484485486487488489const FIREFOX_PENDING_KEY = "passkey_pending_firefox";490const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";491const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; 492493export async function initiateFirefoxPasskeyCreate(args: {494 rpId: string;495 rpName: string;496 userName: string;497 userDisplayName?: string;498}): Promise<{ ok: boolean; error?: string }> {499 if (!(await isProUser())) {500 broadcastPasskeyProGate();501 return { ok: false, error: "passkey creation requires Peach Pro" };502 }503 if (!vault.isUnlocked()) {504 await openApprovalPopup();505 return { ok: false, error: "vault locked" };506 }507 if (!chrome.storage.session) {508 return { ok: false, error: "session storage unavailable" };509 }510511 const requestId = Date.now();512 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));513 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));514515 const pending: PendingPasskeyRequest = {516 requestId,517 type: "create",518 rpId: args.rpId,519 rpName: args.rpName,520 userName: args.userName,521 userDisplayName: args.userDisplayName,522 browser: "firefox",523 challenge,524 userHandle,525 };526527 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });528 await openApprovalPopup();529 return { ok: true };530}531532export async function completeFirefoxPasskeyCreate(payload: {533 requestId: number;534 rpId: string;535 rpName: string;536 userName: string;537 userDisplayName?: string;538 userHandle: string;539 credentialId: string;540 publicKeyCose: string;541 algorithm: -7 | -8;542 aaguid?: string;543 transports?: string[];544 backupEligible?: boolean;545 backupState?: boolean;546}): Promise<{ ok: boolean; error?: string }> {547 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };548549 const entry: PasskeyEntry = {550 id: crypto.randomUUID(),551 type: "passkey",552 title: payload.rpName || payload.rpId,553 rpId: payload.rpId,554 rpName: payload.rpName,555 credentialId: payload.credentialId,556 publicKeyCose: payload.publicKeyCose,557 userHandle: payload.userHandle,558 userName: payload.userName,559 userDisplayName: payload.userDisplayName ?? payload.userName,560 algorithm: payload.algorithm,561 signCount: 0,562 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),563 backupEligible: payload.backupEligible ?? false,564 backupState: payload.backupState ?? false,565 browser: "firefox",566 transports: payload.transports ?? ["internal"],567 createdAt: Date.now(),568 modifiedAt: Date.now(),569 };570571 await vault.addEntry(entry);572 if (chrome.storage.session) {573 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);574 }575 return { ok: true };576}577578export async function cancelFirefoxPasskeyCreate(): Promise<void> {579 if (chrome.storage.session) {580 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);581 }582}583584function declineKey(domain: string): string {585 return `${FIREFOX_DECLINE_PREFIX}${domain}`;586}587588export async function recordPasskeyOfferDecline(domain: string): Promise<void> {589 if (!domain) return;590 await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });591}592593export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {594 if (!domain) return false;595 const key = declineKey(domain);596 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;597 const at = stored[key];598 if (typeof at !== "number") return false;599 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {600 await chrome.storage.local.remove(key);601 return false;602 }603 return true;604}605606export async function setupPasskeyProxy(): Promise<void> {607 if (!isPasskeySupported()) return;608609 610 611 if (detectSharedBrowser() === "firefox") {612 await refreshPasskeyProxy();613 return;614 }615616 if (!listenersRegistered) {617 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {618 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({619 requestId: requestInfo.requestId,620 isUvpaa: vault.isUnlocked(),621 });622 });623 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {624 void handleCreateRequest(requestInfo).catch(async (error) => {625 console.error("Peach passkey create failed", error);626 await rejectCreate(627 requestInfo.requestId,628 "UnknownError",629 error instanceof Error ? error.message : "Passkey creation failed."630 );631 });632 });633 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {634 void handleGetRequest(requestInfo).catch(async (error) => {635 console.error("Peach passkey get failed", error);636 await rejectGet(637 requestInfo.requestId,638 "UnknownError",639 error instanceof Error ? error.message : "Passkey authentication failed."640 );641 });642 });643 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {644 void resolvePendingPasskeyRequest(requestId, false);645 });646 listenersRegistered = true;647 }648649 await refreshPasskeyProxy();650}