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 PasskeyData,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}257258259260261262263264265function findAttachTarget(rpId: string): VaultEntry | null {266 const all = vault.getEntries();267 const passkeyMatch = all.find(268 (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt269 );270 if (passkeyMatch) return passkeyMatch;271 const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);272 if (loginMatches.length === 0) return null;273 274 return loginMatches.reduce((best, e) =>275 (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best276 );277}278279async function handleCreateRequest(280 request: chrome.webAuthenticationProxy.CreateRequest281): Promise<void> {282 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;283 const rpId = options.rp.id;284 const rpName = options.rp.name ?? options.rp.id;285 const userName = options.user.name;286 const userDisplayName = options.user.displayName ?? options.user.name;287288 if (!rpId) {289 await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");290 return;291 }292293 if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {294 await rejectCreate(295 request.requestId,296 "NotAllowedError",297 "Passkey creation requires Peach Pro."298 );299 300 broadcastPasskeyProGate();301 return;302 }303304 if (!vault.isUnlocked()) {305 await openApprovalPopup();306 await rejectCreate(request.requestId, "NotAllowedError", "Unlock Peach first.");307 return;308 }309310 const approved = await promptUserApproval({311 requestId: request.requestId,312 type: "create",313 rpId,314 rpName,315 userName,316 userDisplayName,317 });318 if (!approved) {319 await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");320 return;321 }322323 const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);324 if (!algorithm) {325 await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");326 return;327 }328329 const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));330 const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(331 algorithm332 );333 const authData = await buildAuthenticatorData({334 rpId,335 flags: 0x4d,336 signCount: 0,337 credentialId: credentialIdBytes,338 publicKeyCose,339 });340 const attestationObject = createAttestationObject(authData);341 const clientDataJSON = buildClientDataJSON("webauthn.create", options.challenge, originFromRpId(rpId));342343 const passkey: PasskeyData = {344 rpId,345 rpName,346 credentialId: base64urlEncode(credentialIdBytes),347 publicKeyCose: base64urlEncode(publicKeyCose),348 privateKeyJwk,349 userHandle: options.user.id,350 userName,351 userDisplayName,352 algorithm,353 signCount: 0,354 aaguid: base64urlEncode(new Uint8Array(16)),355 backupEligible: false,356 backupState: false,357 browser: "chrome",358 transports: ["internal"],359 createdAt: Date.now(),360 };361362 363 364 const target = findAttachTarget(rpId);365 if (target) {366 await vault.updateEntry(target.id, { passkey });367 } else {368 await vault.addEntry({369 type: "login",370 title: rpName,371 url: `https://${rpId}`,372 username: userName,373 passkey,374 });375 }376377 await chrome.webAuthenticationProxy.completeCreateRequest({378 requestId: request.requestId,379 responseJson: JSON.stringify({380 id: passkey.credentialId,381 rawId: passkey.credentialId,382 type: "public-key",383 response: {384 clientDataJSON: base64urlEncode(clientDataJSON),385 authenticatorData: base64urlEncode(authData),386 attestationObject: base64urlEncode(attestationObject),387 transports: ["internal"],388 publicKey: base64urlEncode(publicKeySpki),389 publicKeyAlgorithm: algorithm,390 },391 authenticatorAttachment: "platform",392 clientExtensionResults: {},393 }),394 });395}396397function broadcastPasskeyProGate(): void {398 chrome.tabs.query({}, (tabs) => {399 for (const tab of tabs) {400 if (tab.id !== undefined) {401 chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});402 }403 }404 });405}406407async function handleGetRequest(408 request: chrome.webAuthenticationProxy.GetRequest409): Promise<void> {410 const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;411 if (!vault.isUnlocked()) {412 await openApprovalPopup();413 await rejectGet(request.requestId, "NotAllowedError", "Unlock Peach first.");414 return;415 }416417 if (!(await isProUser())) {418 await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");419 broadcastPasskeyProGate();420 return;421 }422423 const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>424 entry.passkey !== undefined && !entry.deletedAt425 );426 const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);427428 const match = passkeyBearing.find((entry) => {429 if (allowedCredentialIds.length > 0) {430 return allowedCredentialIds.includes(entry.passkey.credentialId);431 }432 if (!options.rpId) return false;433 return entry.passkey.rpId === options.rpId;434 });435436 if (!match) {437 await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");438 return;439 }440441 const credential = match.passkey;442 443 444 445 446 if (!credential.privateKeyJwk) {447 await rejectGet(448 request.requestId,449 "NotAllowedError",450 "This passkey was created on Firefox; sign in via the platform authenticator."451 );452 return;453 }454455 const approved = await promptUserApproval({456 requestId: request.requestId,457 type: "get",458 rpId: credential.rpId,459 rpName: credential.rpName,460 userName: credential.userName,461 userDisplayName: credential.userDisplayName,462 });463 if (!approved) {464 await rejectGet(request.requestId, "NotAllowedError", "User denied request.");465 return;466 }467468 const nextSignCount = credential.signCount + 1;469 const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);470 const authData = await buildAuthenticatorData({471 rpId: credential.rpId,472 flags,473 signCount: nextSignCount,474 });475 const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(credential.rpId));476 const clientDataHash = new Uint8Array(477 await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))478 );479 const signature = await signPasskeyAssertion(480 credential.privateKeyJwk,481 credential.algorithm,482 authData,483 clientDataHash484 );485486 await vault.updateEntry(match.id, {487 passkey: {488 ...credential,489 signCount: nextSignCount,490 lastUsedAt: Date.now(),491 },492 });493494 const responseJson = JSON.stringify({495 id: credential.credentialId,496 rawId: credential.credentialId,497 type: "public-key",498 response: {499 clientDataJSON: base64urlEncode(clientDataJSON),500 authenticatorData: base64urlEncode(authData),501 signature: base64urlEncode(signature),502 userHandle: credential.userHandle,503 },504 authenticatorAttachment: "platform",505 clientExtensionResults: {},506 });507508 console.log("[Peach passkey] completing get assertion", {509 rpId: credential.rpId,510 credentialId: credential.credentialId,511 algorithm: credential.algorithm,512 signCount: nextSignCount,513 flags: flags.toString(16),514 signatureBytes: signature.length,515 userHandle: credential.userHandle,516 });517518 await chrome.webAuthenticationProxy.completeGetRequest({519 requestId: request.requestId,520 responseJson,521 });522}523524525526527528529530531532533const FIREFOX_PENDING_KEY = "passkey_pending_firefox";534const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";535const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; 536537export async function initiateFirefoxPasskeyCreate(args: {538 rpId: string;539 rpName: string;540 userName: string;541 userDisplayName?: string;542}): Promise<{ ok: boolean; error?: string }> {543 if (!(await isProUser())) {544 broadcastPasskeyProGate();545 return { ok: false, error: "passkey creation requires Peach Pro" };546 }547 if (!vault.isUnlocked()) {548 await openApprovalPopup();549 return { ok: false, error: "vault locked" };550 }551 if (!chrome.storage.session) {552 return { ok: false, error: "session storage unavailable" };553 }554555 const requestId = Date.now();556 const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));557 const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));558559 const pending: PendingPasskeyRequest = {560 requestId,561 type: "create",562 rpId: args.rpId,563 rpName: args.rpName,564 userName: args.userName,565 userDisplayName: args.userDisplayName,566 browser: "firefox",567 challenge,568 userHandle,569 };570571 await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });572 await openApprovalPopup();573 return { ok: true };574}575576export async function completeFirefoxPasskeyCreate(payload: {577 requestId: number;578 rpId: string;579 rpName: string;580 userName: string;581 userDisplayName?: string;582 userHandle: string;583 credentialId: string;584 publicKeyCose: string;585 algorithm: -7 | -8;586 aaguid?: string;587 transports?: string[];588 backupEligible?: boolean;589 backupState?: boolean;590}): Promise<{ ok: boolean; error?: string }> {591 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };592593 const passkey: PasskeyData = {594 rpId: payload.rpId,595 rpName: payload.rpName,596 credentialId: payload.credentialId,597 publicKeyCose: payload.publicKeyCose,598 userHandle: payload.userHandle,599 userName: payload.userName,600 userDisplayName: payload.userDisplayName ?? payload.userName,601 algorithm: payload.algorithm,602 signCount: 0,603 aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),604 backupEligible: payload.backupEligible ?? false,605 backupState: payload.backupState ?? false,606 browser: "firefox",607 transports: payload.transports ?? ["internal"],608 createdAt: Date.now(),609 };610611 612 613 614 const target = findAttachTarget(payload.rpId);615 if (target) {616 await vault.updateEntry(target.id, { passkey });617 } else {618 await vault.addEntry({619 type: "login",620 title: payload.rpName || payload.rpId,621 url: `https://${payload.rpId}`,622 username: payload.userName,623 passkey,624 });625 }626627 if (chrome.storage.session) {628 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);629 }630 return { ok: true };631}632633export async function cancelFirefoxPasskeyCreate(): Promise<void> {634 if (chrome.storage.session) {635 await chrome.storage.session.remove(FIREFOX_PENDING_KEY);636 }637}638639function declineKey(domain: string): string {640 return `${FIREFOX_DECLINE_PREFIX}${domain}`;641}642643export async function recordPasskeyOfferDecline(domain: string): Promise<void> {644 if (!domain) return;645 await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });646}647648export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {649 if (!domain) return false;650 const key = declineKey(domain);651 const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;652 const at = stored[key];653 if (typeof at !== "number") return false;654 if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {655 await chrome.storage.local.remove(key);656 return false;657 }658 return true;659}660661export async function setupPasskeyProxy(): Promise<void> {662 if (!isPasskeySupported()) return;663664 665 666 if (detectSharedBrowser() === "firefox") {667 await refreshPasskeyProxy();668 return;669 }670671 if (!listenersRegistered) {672 chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {673 void chrome.webAuthenticationProxy.completeIsUvpaaRequest({674 requestId: requestInfo.requestId,675 isUvpaa: vault.isUnlocked(),676 });677 });678 chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {679 void handleCreateRequest(requestInfo).catch(async (error) => {680 console.error("Peach passkey create failed", error);681 await rejectCreate(682 requestInfo.requestId,683 "UnknownError",684 error instanceof Error ? error.message : "Passkey creation failed."685 );686 });687 });688 chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {689 void handleGetRequest(requestInfo).catch(async (error) => {690 console.error("Peach passkey get failed", error);691 await rejectGet(692 requestInfo.requestId,693 "UnknownError",694 error instanceof Error ? error.message : "Passkey authentication failed."695 );696 });697 });698 chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {699 void resolvePendingPasskeyRequest(requestId, false);700 });701 listenersRegistered = true;702 }703704 await refreshPasskeyProxy();705}