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 const responseJson = JSON.stringify({463 id: match.credentialId,464 rawId: match.credentialId,465 type: "public-key",466 response: {467 clientDataJSON: base64urlEncode(clientDataJSON),468 authenticatorData: base64urlEncode(authData),469 signature: base64urlEncode(signature),470 userHandle: match.userHandle,471 },472 authenticatorAttachment: "platform",473 clientExtensionResults: {},474 });475476 console.log("[Peach passkey] completing get assertion", {477 rpId: match.rpId,478 credentialId: match.credentialId,479 algorithm: match.algorithm,480 signCount: nextSignCount,481 flags: flags.toString(16),482 signatureBytes: signature.length,483 userHandle: match.userHandle,484 });485486 await chrome.webAuthenticationProxy.completeGetRequest({487 requestId: request.requestId,488 responseJson,489 });490}491492493494495496497498499500501const FIREFOX_PENDING_KEY = "passkey_pending_firefox";502const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";503const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; 504505export async function initiateFirefoxPasskeyCreate(args: {506 rpId: string;507 rpName: string;508 userName: string;509 userDisplayName?: string;510}): Promise<{ ok: boolean; error?: string }> {511 if (!(await isProUser())) {512 broadcastPasskeyProGate();513 return { ok: false, error: "passkey creation requires Peach Pro" };514 }515 if (!vault.isUnlocked()) {516 await openApprovalPopup();517 return { ok: false, error: "vault locked" };518 }519 if (!chrome.storage.session) {520 return { ok: false, error: "session storage unavailable" };521 }522523 const requestId = Date.now();524 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));525 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));526527 const pending: PendingPasskeyRequest = {528 requestId,529 type: "create",530 rpId: args.rpId,531 rpName: args.rpName,532 userName: args.userName,533 userDisplayName: args.userDisplayName,534 browser: "firefox",535 challenge,536 userHandle,537 };538539 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });540 await openApprovalPopup();541 return { ok: true };542}543544export async function completeFirefoxPasskeyCreate(payload: {545 requestId: number;546 rpId: string;547 rpName: string;548 userName: string;549 userDisplayName?: string;550 userHandle: string;551 credentialId: string;552 publicKeyCose: string;553 algorithm: -7 | -8;554 aaguid?: string;555 transports?: string[];556 backupEligible?: boolean;557 backupState?: boolean;558}): Promise<{ ok: boolean; error?: string }> {559 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };560561 const entry: PasskeyEntry = {562 id: crypto.randomUUID(),563 type: "passkey",564 title: payload.rpName || payload.rpId,565 rpId: payload.rpId,566 rpName: payload.rpName,567 credentialId: payload.credentialId,568 publicKeyCose: payload.publicKeyCose,569 userHandle: payload.userHandle,570 userName: payload.userName,571 userDisplayName: payload.userDisplayName ?? payload.userName,572 algorithm: payload.algorithm,573 signCount: 0,574 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),575 backupEligible: payload.backupEligible ?? false,576 backupState: payload.backupState ?? false,577 browser: "firefox",578 transports: payload.transports ?? ["internal"],579 createdAt: Date.now(),580 modifiedAt: Date.now(),581 };582583 await vault.addEntry(entry);584 if (chrome.storage.session) {585 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);586 }587 return { ok: true };588}589590export async function cancelFirefoxPasskeyCreate(): Promise<void> {591 if (chrome.storage.session) {592 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);593 }594}595596function declineKey(domain: string): string {597 return `${FIREFOX_DECLINE_PREFIX}${domain}`;598}599600export async function recordPasskeyOfferDecline(domain: string): Promise<void> {601 if (!domain) return;602 await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });603}604605export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {606 if (!domain) return false;607 const key = declineKey(domain);608 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;609 const at = stored[key];610 if (typeof at !== "number") return false;611 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {612 await chrome.storage.local.remove(key);613 return false;614 }615 return true;616}617618export async function setupPasskeyProxy(): Promise<void> {619 if (!isPasskeySupported()) return;620621 622 623 if (detectSharedBrowser() === "firefox") {624 await refreshPasskeyProxy();625 return;626 }627628 if (!listenersRegistered) {629 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {630 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({631 requestId: requestInfo.requestId,632 isUvpaa: vault.isUnlocked(),633 });634 });635 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {636 void handleCreateRequest(requestInfo).catch(async (error) => {637 console.error("Peach passkey create failed", error);638 await rejectCreate(639 requestInfo.requestId,640 "UnknownError",641 error instanceof Error ? error.message : "Passkey creation failed."642 );643 });644 });645 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {646 void handleGetRequest(requestInfo).catch(async (error) => {647 console.error("Peach passkey get failed", error);648 await rejectGet(649 requestInfo.requestId,650 "UnknownError",651 error instanceof Error ? error.message : "Passkey authentication failed."652 );653 });654 });655 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {656 void resolvePendingPasskeyRequest(requestId, false);657 });658 listenersRegistered = true;659 }660661 await refreshPasskeyProxy();662}