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}3839function getBrowserLabel(): string {40 const ua = navigator.userAgent;41 if (/Firefox\/(\d+)/.test(ua)) {42 const version = ua.match(/Firefox\/(\d+)/)?.[1];43 return version ? `Firefox ${version}` : "Firefox";44 }45 const version = ua.match(/Chrome\/(\d+)/)?.[1];46 return version ? `Chrome ${version}` : "Chromium";47}4849function pendingStorageKey(requestId: number): string {50 return `${PENDING_PREFIX}${requestId}`;51}5253function originFromRpId(rpId: string): string {54 return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)55 ? `http://${rpId}`56 : `https://${rpId}`;57}5859async function setBadge(text: string): Promise<void> {60 if (!chrome.action?.setBadgeText) return;61 await chrome.action.setBadgeText({ text });62 if (text) {63 await chrome.action.setBadgeBackgroundColor({ color: "#D96830" });64 }65}6667async function clearPendingRequest(requestId: number): Promise<void> {68 if (chrome.storage.session) {69 await chrome.storage.session.remove(pendingStorageKey(requestId));70 }71 await setBadge("");72}7374async function openApprovalPopup(): Promise<void> {75 try {76 await chrome.action.openPopup();77 } catch {78 await setBadge("PK");79 }80}8182async function rejectCreate(83 requestId: number,84 name: string,85 message = ""86): Promise<void> {87 await chrome.webAuthenticationProxy.completeCreateRequest({88 requestId,89 error: { name, message },90 });91}9293async function rejectGet(94 requestId: number,95 name: string,96 message = ""97): Promise<void> {98 await chrome.webAuthenticationProxy.completeGetRequest({99 requestId,100 error: { name, message },101 });102}103104export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {105 if (!chrome.storage.session) return null; 106 const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;107 const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));108 if (!key) return null;109 const pending = stored[key];110 return pending && typeof pending === "object"111 ? (pending as PendingPasskeyRequest)112 : null;113}114115export async function resolvePendingPasskeyRequest(116 requestId: number,117 approved: boolean118): Promise<void> {119 const pending = approvals.get(requestId);120 if (!pending) {121 await clearPendingRequest(requestId);122 return;123 }124125 clearTimeout(pending.timeoutId);126 approvals.delete(requestId);127 await clearPendingRequest(requestId);128 pending.resolve(approved);129}130131async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {132 if (chrome.storage.session) {133 await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });134 }135 await openApprovalPopup();136137 return new Promise<boolean>((resolve) => {138 const timeoutId = setTimeout(async () => {139 approvals.delete(request.requestId);140 await clearPendingRequest(request.requestId);141 resolve(false);142 }, APPROVAL_TIMEOUT_MS);143144 approvals.set(request.requestId, { resolve, timeoutId });145 });146}147148export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {149 const settings = await vault.ensureSettingsLoaded();150 const supported = isPasskeySupported();151 const enabled = settings.passkeyProviderEnabled === true;152 return {153 supported,154 enabled,155 attached: supported && attached,156 browserLabel: getBrowserLabel(),157 statusLabel: !supported158 ? "Interception unavailable"159 : attached160 ? "Active"161 : conflict162 ? "Another provider is active"163 : enabled164 ? "Inactive"165 : "Disabled",166 conflict,167 };168}169170async function attachPasskeyProxy(): Promise<void> {171 if (!isPasskeySupported()) {172 attached = false;173 conflict = "This browser does not expose chrome.webAuthenticationProxy.";174 return;175 }176 if (attached) return;177178 try {179 await chrome.webAuthenticationProxy.attach();180 attached = true;181 conflict = undefined;182 } catch (error) {183 attached = false;184 conflict = error instanceof Error ? error.message : "Another provider is active.";185 }186}187188async function detachPasskeyProxy(): Promise<void> {189 if (!isPasskeySupported()) {190 attached = false;191 return;192 }193194 try {195 await chrome.webAuthenticationProxy.detach();196 } catch {197 198 }199200 attached = false;201 conflict = undefined;202}203204export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {205 await vault.updateSettings({ passkeyProviderEnabled: enabled });206 if (enabled) {207 await attachPasskeyProxy();208 } else {209 await detachPasskeyProxy();210 }211 return getPasskeyProviderState();212}213214async function refreshPasskeyProxy(): Promise<void> {215 const settings = await vault.ensureSettingsLoaded();216 if (settings.passkeyProviderEnabled === true) {217 await attachPasskeyProxy();218 return;219 }220 await detachPasskeyProxy();221}222223function passkeyEntries(entries: VaultEntry[]): PasskeyEntry[] {224 return entries.filter((entry): entry is PasskeyEntry => entry.type === "passkey");225}226227async function handleCreateRequest(228 request: chrome.webAuthenticationProxy.CreateRequest229): Promise<void> {230 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;231 const rpId = options.rp.id;232 const rpName = options.rp.name ?? options.rp.id;233 const userName = options.user.name;234 const userDisplayName = options.user.displayName ?? options.user.name;235236 if (!rpId) {237 await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");238 return;239 }240241 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {242 await rejectCreate(243 request.requestId,244 "NotAllowedError",245 "Passkey creation requires Peach Pro."246 );247 return;248 }249250 if (!vault.isUnlocked()) {251 await openApprovalPopup();252 await rejectCreate(request.requestId, "NotAllowedError", "Unlock Peach first.");253 return;254 }255256 const approved = await promptUserApproval({257 requestId: request.requestId,258 type: "create",259 rpId,260 rpName,261 userName,262 userDisplayName,263 });264 if (!approved) {265 await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");266 return;267 }268269 const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);270 if (!algorithm) {271 await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");272 return;273 }274275 const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));276 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(277 algorithm278 );279 const authData = await buildAuthenticatorData({280 rpId,281 flags: 0x4d,282 signCount: 0,283 credentialId: credentialIdBytes,284 publicKeyCose,285 });286 const attestationObject = createAttestationObject(authData);287 const clientDataJSON = buildClientDataJSON("webauthn.create", options.challenge, originFromRpId(rpId));288289 const entry: PasskeyEntry = {290 id: crypto.randomUUID(),291 type: "passkey",292 title: rpName,293 rpId,294 rpName,295 credentialId: base64urlEncode(credentialIdBytes),296 publicKeyCose: base64urlEncode(publicKeyCose),297 privateKeyJwk,298 userHandle: options.user.id,299 userName,300 userDisplayName,301 algorithm,302 signCount: 0,303 aaguid: base64urlEncode(new Uint8Array(16)),304 backupEligible: true,305 backupState: false,306 createdAt: Date.now(),307 modifiedAt: Date.now(),308 };309310 await vault.addEntry(entry);311312 await chrome.webAuthenticationProxy.completeCreateRequest({313 requestId: request.requestId,314 responseJson: JSON.stringify({315 id: entry.credentialId,316 rawId: entry.credentialId,317 type: "public-key",318 response: {319 clientDataJSON: base64urlEncode(clientDataJSON),320 authenticatorData: base64urlEncode(authData),321 attestationObject: base64urlEncode(attestationObject),322 transports: ["internal"],323 publicKey: base64urlEncode(publicKeySpki),324 publicKeyAlgorithm: algorithm,325 },326 authenticatorAttachment: "platform",327 clientExtensionResults: {},328 }),329 });330}331332async function handleGetRequest(333 request: chrome.webAuthenticationProxy.GetRequest334): Promise<void> {335 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;336 if (!vault.isUnlocked()) {337 await openApprovalPopup();338 await rejectGet(request.requestId, "NotAllowedError", "Unlock Peach first.");339 return;340 }341342 const entries = passkeyEntries(vault.getEntries());343 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);344345 const match = entries.find((entry) => {346 if (allowedCredentialIds.length > 0) {347 return allowedCredentialIds.includes(entry.credentialId);348 }349 if (!options.rpId) return false;350 return entry.rpId === options.rpId;351 });352353 if (!match) {354 await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");355 return;356 }357358 const approved = await promptUserApproval({359 requestId: request.requestId,360 type: "get",361 rpId: match.rpId,362 rpName: match.rpName,363 userName: match.userName,364 userDisplayName: match.userDisplayName,365 });366 if (!approved) {367 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");368 return;369 }370371 const nextSignCount = (match.signCount ?? 0) + 1;372 const flags = 0x05 | (match.backupEligible ? 0x08 : 0) | (match.backupState ? 0x10 : 0);373 const authData = await buildAuthenticatorData({374 rpId: match.rpId,375 flags,376 signCount: nextSignCount,377 });378 const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(match.rpId));379 const clientDataHash = new Uint8Array(380 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))381 );382 const signature = await signPasskeyAssertion(383 match.privateKeyJwk,384 match.algorithm,385 authData,386 clientDataHash387 );388389 await vault.updateEntry(match.id, {390 signCount: nextSignCount,391 lastUsedAt: Date.now(),392 });393394 await chrome.webAuthenticationProxy.completeGetRequest({395 requestId: request.requestId,396 responseJson: JSON.stringify({397 id: match.credentialId,398 rawId: match.credentialId,399 type: "public-key",400 response: {401 clientDataJSON: base64urlEncode(clientDataJSON),402 authenticatorData: base64urlEncode(authData),403 signature: base64urlEncode(signature),404 userHandle: match.userHandle,405 },406 authenticatorAttachment: "platform",407 clientExtensionResults: {},408 }),409 });410}411412export async function setupPasskeyProxy(): Promise<void> {413 if (!isPasskeySupported()) return;414415 if (!listenersRegistered) {416 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {417 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({418 requestId: requestInfo.requestId,419 isUvpaa: vault.isUnlocked(),420 });421 });422 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {423 void handleCreateRequest(requestInfo).catch(async (error) => {424 console.error("Peach passkey create failed", error);425 await rejectCreate(426 requestInfo.requestId,427 "UnknownError",428 error instanceof Error ? error.message : "Passkey creation failed."429 );430 });431 });432 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {433 void handleGetRequest(requestInfo).catch(async (error) => {434 console.error("Peach passkey get failed", error);435 await rejectGet(436 requestInfo.requestId,437 "UnknownError",438 error instanceof Error ? error.message : "Passkey authentication failed."439 );440 });441 });442 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {443 void resolvePendingPasskeyRequest(requestId, false);444 });445 listenersRegistered = true;446 }447448 await refreshPasskeyProxy();449}