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 248 broadcastPasskeyProGate();249 return;250 }251252 if (!vault.isUnlocked()) {253 await openApprovalPopup();254 await rejectCreate(request.requestId, "NotAllowedError", "Unlock Peach first.");255 return;256 }257258 const approved = await promptUserApproval({259 requestId: request.requestId,260 type: "create",261 rpId,262 rpName,263 userName,264 userDisplayName,265 });266 if (!approved) {267 await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");268 return;269 }270271 const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);272 if (!algorithm) {273 await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");274 return;275 }276277 const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));278 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(279 algorithm280 );281 const authData = await buildAuthenticatorData({282 rpId,283 flags: 0x4d,284 signCount: 0,285 credentialId: credentialIdBytes,286 publicKeyCose,287 });288 const attestationObject = createAttestationObject(authData);289 const clientDataJSON = buildClientDataJSON("webauthn.create", options.challenge, originFromRpId(rpId));290291 const entry: PasskeyEntry = {292 id: crypto.randomUUID(),293 type: "passkey",294 title: rpName,295 rpId,296 rpName,297 credentialId: base64urlEncode(credentialIdBytes),298 publicKeyCose: base64urlEncode(publicKeyCose),299 privateKeyJwk,300 userHandle: options.user.id,301 userName,302 userDisplayName,303 algorithm,304 signCount: 0,305 aaguid: base64urlEncode(new Uint8Array(16)),306 backupEligible: true,307 backupState: false,308 createdAt: Date.now(),309 modifiedAt: Date.now(),310 };311312 await vault.addEntry(entry);313314 await chrome.webAuthenticationProxy.completeCreateRequest({315 requestId: request.requestId,316 responseJson: JSON.stringify({317 id: entry.credentialId,318 rawId: entry.credentialId,319 type: "public-key",320 response: {321 clientDataJSON: base64urlEncode(clientDataJSON),322 authenticatorData: base64urlEncode(authData),323 attestationObject: base64urlEncode(attestationObject),324 transports: ["internal"],325 publicKey: base64urlEncode(publicKeySpki),326 publicKeyAlgorithm: algorithm,327 },328 authenticatorAttachment: "platform",329 clientExtensionResults: {},330 }),331 });332}333334function broadcastPasskeyProGate(): void {335 chrome.tabs.query({}, (tabs) => {336 for (const tab of tabs) {337 if (tab.id !== undefined) {338 chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});339 }340 }341 });342}343344async function handleGetRequest(345 request: chrome.webAuthenticationProxy.GetRequest346): Promise<void> {347 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;348 if (!vault.isUnlocked()) {349 await openApprovalPopup();350 await rejectGet(request.requestId, "NotAllowedError", "Unlock Peach first.");351 return;352 }353354 if (!(await isProUser())) {355 await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");356 broadcastPasskeyProGate();357 return;358 }359360 const entries = passkeyEntries(vault.getEntries());361 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);362363 const match = entries.find((entry) => {364 if (allowedCredentialIds.length > 0) {365 return allowedCredentialIds.includes(entry.credentialId);366 }367 if (!options.rpId) return false;368 return entry.rpId === options.rpId;369 });370371 if (!match) {372 await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");373 return;374 }375376 const approved = await promptUserApproval({377 requestId: request.requestId,378 type: "get",379 rpId: match.rpId,380 rpName: match.rpName,381 userName: match.userName,382 userDisplayName: match.userDisplayName,383 });384 if (!approved) {385 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");386 return;387 }388389 const nextSignCount = (match.signCount ?? 0) + 1;390 const flags = 0x05 | (match.backupEligible ? 0x08 : 0) | (match.backupState ? 0x10 : 0);391 const authData = await buildAuthenticatorData({392 rpId: match.rpId,393 flags,394 signCount: nextSignCount,395 });396 const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(match.rpId));397 const clientDataHash = new Uint8Array(398 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))399 );400 const signature = await signPasskeyAssertion(401 match.privateKeyJwk,402 match.algorithm,403 authData,404 clientDataHash405 );406407 await vault.updateEntry(match.id, {408 signCount: nextSignCount,409 lastUsedAt: Date.now(),410 });411412 await chrome.webAuthenticationProxy.completeGetRequest({413 requestId: request.requestId,414 responseJson: JSON.stringify({415 id: match.credentialId,416 rawId: match.credentialId,417 type: "public-key",418 response: {419 clientDataJSON: base64urlEncode(clientDataJSON),420 authenticatorData: base64urlEncode(authData),421 signature: base64urlEncode(signature),422 userHandle: match.userHandle,423 },424 authenticatorAttachment: "platform",425 clientExtensionResults: {},426 }),427 });428}429430export async function setupPasskeyProxy(): Promise<void> {431 if (!isPasskeySupported()) return;432433 if (!listenersRegistered) {434 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {435 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({436 requestId: requestInfo.requestId,437 isUvpaa: vault.isUnlocked(),438 });439 });440 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {441 void handleCreateRequest(requestInfo).catch(async (error) => {442 console.error("Peach passkey create failed", error);443 await rejectCreate(444 requestInfo.requestId,445 "UnknownError",446 error instanceof Error ? error.message : "Passkey creation failed."447 );448 });449 });450 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {451 void handleGetRequest(requestInfo).catch(async (error) => {452 console.error("Peach passkey get failed", error);453 await rejectGet(454 requestInfo.requestId,455 "UnknownError",456 error instanceof Error ? error.message : "Passkey authentication failed."457 );458 });459 });460 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {461 void resolvePendingPasskeyRequest(requestId, false);462 });463 listenersRegistered = true;464 }465466 await refreshPasskeyProxy();467}