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 type {14 PasskeyEntry,15 PasskeyProviderState,16 PendingPasskeyRequest,17 VaultEntry,18} from "../shared/types";1920declare const __PEACH_E2E_PASSKEYS__: boolean;2122const PENDING_PREFIX = "passkey_pending_";23const APPROVAL_TIMEOUT_MS = 60_000;2425let listenersRegistered = false;26let attached = false;27let conflict: string | undefined;28const PASSKEY_CREATE_E2E_BYPASS = __PEACH_E2E_PASSKEYS__ === true;2930const approvals = new Map<31 number,32 { resolve: (approved: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }33>();3435function isPasskeySupported(): boolean {36 return typeof chrome.webAuthenticationProxy?.attach === "function";37}3839export function detectBrowser(): "chrome" | "firefox" | "unknown" {40 if (typeof chrome !== "undefined" && chrome.webAuthenticationProxy) {41 return "chrome";42 }43 try {44 const url = chrome.runtime?.getURL?.("") ?? "";45 if (url.startsWith("moz-extension://")) return "firefox";46 } catch { }47 return "unknown";48}4950function getBrowserLabel(): string {51 const ua = navigator.userAgent;52 if (/Firefox\/(\d+)/.test(ua)) {53 const version = ua.match(/Firefox\/(\d+)/)?.[1];54 return version ? `Firefox ${version}` : "Firefox";55 }56 const version = ua.match(/Chrome\/(\d+)/)?.[1];57 return version ? `Chrome ${version}` : "Chromium";58}5960function pendingStorageKey(requestId: number): string {61 return `${PENDING_PREFIX}${requestId}`;62}6364function originFromRpId(rpId: string): string {65 return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)66 ? `http://${rpId}`67 : `https://${rpId}`;68}6970async function setBadge(text: string): Promise<void> {71 if (!chrome.action?.setBadgeText) return;72 await chrome.action.setBadgeText({ text });73 if (text) {74 await chrome.action.setBadgeBackgroundColor({ color: "#D96830" });75 }76}7778async function clearPendingRequest(requestId: number): Promise<void> {79 if (chrome.storage.session) {80 await chrome.storage.session.remove(pendingStorageKey(requestId));81 }82 await setBadge("");83}8485async function openApprovalPopup(): Promise<void> {86 try {87 await chrome.action.openPopup();88 } catch {89 await setBadge("PK");90 }91}9293async function rejectCreate(94 requestId: number,95 name: string,96 message = ""97): Promise<void> {98 await chrome.webAuthenticationProxy.completeCreateRequest({99 requestId,100 error: { name, message },101 });102}103104async function rejectGet(105 requestId: number,106 name: string,107 message = ""108): Promise<void> {109 await chrome.webAuthenticationProxy.completeGetRequest({110 requestId,111 error: { name, message },112 });113}114115export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {116 if (!chrome.storage.session) return null; 117 const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;118 const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));119 if (!key) return null;120 const pending = stored[key];121 return pending && typeof pending === "object"122 ? (pending as PendingPasskeyRequest)123 : null;124}125126export async function resolvePendingPasskeyRequest(127 requestId: number,128 approved: boolean129): Promise<void> {130 const pending = approvals.get(requestId);131 if (!pending) {132 await clearPendingRequest(requestId);133 return;134 }135136 clearTimeout(pending.timeoutId);137 approvals.delete(requestId);138 await clearPendingRequest(requestId);139 pending.resolve(approved);140}141142async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {143 if (chrome.storage.session) {144 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });145 }146 await openApprovalPopup();147148 return new Promise<boolean>((resolve) => {149 const timeoutId = setTimeout(async () => {150 approvals.delete(request.requestId);151 await clearPendingRequest(request.requestId);152 resolve(false);153 }, APPROVAL_TIMEOUT_MS);154155 approvals.set(request.requestId, { resolve, timeoutId });156 });157}158159export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {160 const settings = await vault.ensureSettingsLoaded();161 const supported = isPasskeySupported();162 const enabled = settings.passkeyProviderEnabled === true;163 return {164 supported,165 enabled,166 attached: supported && attached,167 browserLabel: getBrowserLabel(),168 statusLabel: !supported169 ? "Interception unavailable"170 : attached171 ? "Active"172 : conflict173 ? "Another provider is active"174 : enabled175 ? "Inactive"176 : "Disabled",177 conflict,178 };179}180181async function attachPasskeyProxy(): Promise<void> {182 if (!isPasskeySupported()) {183 attached = false;184 conflict = "This browser does not expose chrome.webAuthenticationProxy.";185 return;186 }187 if (attached) return;188189 try {190 await chrome.webAuthenticationProxy.attach();191 attached = true;192 conflict = undefined;193 } catch (error) {194 attached = false;195 conflict = error instanceof Error ? error.message : "Another provider is active.";196 }197}198199async function detachPasskeyProxy(): Promise<void> {200 if (!isPasskeySupported()) {201 attached = false;202 return;203 }204205 try {206 await chrome.webAuthenticationProxy.detach();207 } catch {208 209 }210211 attached = false;212 conflict = undefined;213}214215export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {216 await vault.updateSettings({ passkeyProviderEnabled: enabled });217 if (enabled) {218 await attachPasskeyProxy();219 } else {220 await detachPasskeyProxy();221 }222 return getPasskeyProviderState();223}224225async function refreshPasskeyProxy(): Promise<void> {226 const settings = await vault.ensureSettingsLoaded();227 if (settings.passkeyProviderEnabled === true) {228 await attachPasskeyProxy();229 return;230 }231 await detachPasskeyProxy();232}233234function passkeyEntries(entries: VaultEntry[]): PasskeyEntry[] {235 return entries.filter((entry): entry is PasskeyEntry => entry.type === "passkey");236}237238async function handleCreateRequest(239 request: chrome.webAuthenticationProxy.CreateRequest240): Promise<void> {241 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;242 const rpId = options.rp.id;243 const rpName = options.rp.name ?? options.rp.id;244 const userName = options.user.name;245 const userDisplayName = options.user.displayName ?? options.user.name;246247 if (!rpId) {248 await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");249 return;250 }251252 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {253 await rejectCreate(254 request.requestId,255 "NotAllowedError",256 "Passkey creation requires Peach Pro."257 );258 259 broadcastPasskeyProGate();260 return;261 }262263 if (!vault.isUnlocked()) {264 await openApprovalPopup();265 await rejectCreate(request.requestId, "NotAllowedError", "Unlock Peach first.");266 return;267 }268269 const approved = await promptUserApproval({270 requestId: request.requestId,271 type: "create",272 rpId,273 rpName,274 userName,275 userDisplayName,276 });277 if (!approved) {278 await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");279 return;280 }281282 const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);283 if (!algorithm) {284 await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");285 return;286 }287288 const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));289 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(290 algorithm291 );292 const authData = await buildAuthenticatorData({293 rpId,294 flags: 0x4d,295 signCount: 0,296 credentialId: credentialIdBytes,297 publicKeyCose,298 });299 const attestationObject = createAttestationObject(authData);300 const clientDataJSON = buildClientDataJSON("webauthn.create", options.challenge, originFromRpId(rpId));301302 const entry: PasskeyEntry = {303 id: crypto.randomUUID(),304 type: "passkey",305 title: rpName,306 rpId,307 rpName,308 credentialId: base64urlEncode(credentialIdBytes),309 publicKeyCose: base64urlEncode(publicKeyCose),310 privateKeyJwk,311 userHandle: options.user.id,312 userName,313 userDisplayName,314 algorithm,315 signCount: 0,316 aaguid: base64urlEncode(new Uint8Array(16)),317 backupEligible: true,318 backupState: false,319 browser: "chrome",320 transports: ["internal"],321 createdAt: Date.now(),322 modifiedAt: Date.now(),323 };324325 await vault.addEntry(entry);326327 await chrome.webAuthenticationProxy.completeCreateRequest({328 requestId: request.requestId,329 responseJson: JSON.stringify({330 id: entry.credentialId,331 rawId: entry.credentialId,332 type: "public-key",333 response: {334 clientDataJSON: base64urlEncode(clientDataJSON),335 authenticatorData: base64urlEncode(authData),336 attestationObject: base64urlEncode(attestationObject),337 transports: ["internal"],338 publicKey: base64urlEncode(publicKeySpki),339 publicKeyAlgorithm: algorithm,340 },341 authenticatorAttachment: "platform",342 clientExtensionResults: {},343 }),344 });345}346347function broadcastPasskeyProGate(): void {348 chrome.tabs.query({}, (tabs) => {349 for (const tab of tabs) {350 if (tab.id !== undefined) {351 chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});352 }353 }354 });355}356357async function handleGetRequest(358 request: chrome.webAuthenticationProxy.GetRequest359): Promise<void> {360 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;361 if (!vault.isUnlocked()) {362 await openApprovalPopup();363 await rejectGet(request.requestId, "NotAllowedError", "Unlock Peach first.");364 return;365 }366367 if (!(await isProUser())) {368 await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");369 broadcastPasskeyProGate();370 return;371 }372373 const entries = passkeyEntries(vault.getEntries());374 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);375376 const match = entries.find((entry) => {377 if (allowedCredentialIds.length > 0) {378 return allowedCredentialIds.includes(entry.credentialId);379 }380 if (!options.rpId) return false;381 return entry.rpId === options.rpId;382 });383384 if (!match) {385 await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");386 return;387 }388389 const approved = await promptUserApproval({390 requestId: request.requestId,391 type: "get",392 rpId: match.rpId,393 rpName: match.rpName,394 userName: match.userName,395 userDisplayName: match.userDisplayName,396 });397 if (!approved) {398 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");399 return;400 }401402 const nextSignCount = (match.signCount ?? 0) + 1;403 const flags = 0x05 | (match.backupEligible ? 0x08 : 0) | (match.backupState ? 0x10 : 0);404 const authData = await buildAuthenticatorData({405 rpId: match.rpId,406 flags,407 signCount: nextSignCount,408 });409 const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(match.rpId));410 const clientDataHash = new Uint8Array(411 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))412 );413 const signature = await signPasskeyAssertion(414 match.privateKeyJwk,415 match.algorithm,416 authData,417 clientDataHash418 );419420 await vault.updateEntry(match.id, {421 signCount: nextSignCount,422 lastUsedAt: Date.now(),423 });424425 await chrome.webAuthenticationProxy.completeGetRequest({426 requestId: request.requestId,427 responseJson: JSON.stringify({428 id: match.credentialId,429 rawId: match.credentialId,430 type: "public-key",431 response: {432 clientDataJSON: base64urlEncode(clientDataJSON),433 authenticatorData: base64urlEncode(authData),434 signature: base64urlEncode(signature),435 userHandle: match.userHandle,436 },437 authenticatorAttachment: "platform",438 clientExtensionResults: {},439 }),440 });441}442443export async function setupPasskeyProxy(): Promise<void> {444 if (!isPasskeySupported()) return;445446 if (!listenersRegistered) {447 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {448 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({449 requestId: requestInfo.requestId,450 isUvpaa: vault.isUnlocked(),451 });452 });453 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {454 void handleCreateRequest(requestInfo).catch(async (error) => {455 console.error("Peach passkey create failed", error);456 await rejectCreate(457 requestInfo.requestId,458 "UnknownError",459 error instanceof Error ? error.message : "Passkey creation failed."460 );461 });462 });463 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {464 void handleGetRequest(requestInfo).catch(async (error) => {465 console.error("Peach passkey get failed", error);466 await rejectGet(467 requestInfo.requestId,468 "UnknownError",469 error instanceof Error ? error.message : "Passkey authentication failed."470 );471 });472 });473 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {474 void resolvePendingPasskeyRequest(requestId, false);475 });476 listenersRegistered = true;477 }478479 await refreshPasskeyProxy();480}