/* eslint-disable @typescript-eslint/no-explicit-any */ import { backgroundLogNip07Action, backgroundLogPermissionStored, NostrHelper, NwcClient, NwcConnection_DECRYPTED, WeblnMethod, Nip07Method, GetInfoResponse, SendPaymentResponse, RequestInvoiceResponse, } from '@common'; import { BackgroundRequestMessage, checkPermissions, checkWeblnPermissions, debug, getBrowserSessionData, getPosition, handleUnlockRequest, isWeblnMethod, nip04Decrypt, nip04Encrypt, nip44Decrypt, nip44Encrypt, openUnlockPopup, PromptResponse, PromptResponseMessage, shouldRecklessModeApprove, signEvent, storePermission, UnlockRequestMessage, UnlockResponseMessage, } from './background-common'; import { isMlsMethod, mlsInit, mlsSendDM, mlsSubscribe, mlsPublishKP, mlsListGroups, mlsDeliverEvent, mlsHandleEvent, mlsSetTab, mlsBackupGroups, mlsRestoreGroups, mlsRatchetGroup, } from './mls-engine'; import browser from 'webextension-polyfill'; import { Buffer } from 'buffer'; // Clear stale session data on extension install/update/reload. // Session storage can survive reloads in Firefox, causing the vault // to appear unlocked without the user entering their password. browser.runtime.onInstalled.addListener(async () => { debug('Extension installed/updated — clearing session storage'); await browser.storage.session.clear(); }); // Cache for NWC clients to avoid reconnecting for each request const nwcClientCache = new Map(); /** * Get or create an NWC client for a connection */ async function getNwcClient(connection: NwcConnection_DECRYPTED): Promise { const cached = nwcClientCache.get(connection.id); if (cached && cached.isConnected()) { return cached; } const client = new NwcClient({ walletPubkey: connection.walletPubkey, relayUrl: connection.relayUrl, secret: connection.secret, }); await client.connect(); nwcClientCache.set(connection.id, client); return client; } /** * Parse invoice amount from a BOLT11 invoice string * Returns amount in satoshis, or undefined if no amount specified */ function parseInvoiceAmount(invoice: string): number | undefined { try { // BOLT11 invoices start with 'ln' followed by network prefix and amount // Format: ln[network][amount][multiplier]1[data] // Examples: lnbc1500n1... (1500 sat), lnbc1m1... (0.001 BTC = 100000 sat) const match = invoice.toLowerCase().match(/^ln(bc|tb|tbs|bcrt)(\d+)([munp])?1/); if (!match) { return undefined; } const amountStr = match[2]; const multiplier = match[3]; let amount = parseInt(amountStr, 10); // Apply multiplier (amount is in BTC by default) switch (multiplier) { case 'm': // milli-bitcoin (0.001 BTC) amount = amount * 100000; break; case 'u': // micro-bitcoin (0.000001 BTC) amount = amount * 100; break; case 'n': // nano-bitcoin (0.000000001 BTC) = 0.1 sat amount = Math.floor(amount / 10); break; case 'p': // pico-bitcoin (0.000000000001 BTC) = 0.0001 sat amount = Math.floor(amount / 10000); break; default: // No multiplier means BTC amount = amount * 100000000; } return amount; } catch { return undefined; } } type Relays = Record; // ========================================== // Permission Prompt Queue System (P0) // ========================================== // Timeout for permission prompts (30 seconds) const PROMPT_TIMEOUT_MS = 30000; // Maximum number of queued permission requests (prevent DoS) const MAX_PERMISSION_QUEUE_SIZE = 100; // Track open prompts with metadata for cleanup const openPrompts = new Map< string, { resolve: (response: PromptResponse) => void; reject: (reason?: any) => void; windowId?: number; timeoutId?: ReturnType; } >(); // Track if unlock popup is already open let unlockPopupOpen = false; let unlockPopupWindowId: number | undefined; // Queue of pending NIP-07 requests waiting for unlock const pendingRequests: { request: BackgroundRequestMessage; resolve: (result: any) => void; reject: (error: any) => void; }[] = []; // Queue for permission requests (only one prompt shown at a time) interface PermissionQueueItem { id: string; url: string; width: number; height: number; resolve: (response: PromptResponse) => void; reject: (reason?: any) => void; } const permissionQueue: PermissionQueueItem[] = []; let activePromptId: string | null = null; /** * Show the next permission prompt from the queue. * Re-checks permissions before opening — if a prior "always" response * already covers this request, auto-resolve it and skip to the next. */ async function showNextPermissionPrompt(): Promise { while (!activePromptId && permissionQueue.length > 0) { const next = permissionQueue[0]; // Re-check: a prior "always" may already cover this queued request. const covered = await isQueuedRequestCovered(next); if (covered !== undefined) { // Auto-resolve without opening a window. permissionQueue.shift(); const promptData = openPrompts.get(next.id); if (promptData) { if (promptData.timeoutId) clearTimeout(promptData.timeoutId); promptData.resolve(covered ? 'approve-once' : 'reject-once'); openPrompts.delete(next.id); } debug(`Auto-resolved queued prompt ${next.id} (permission already ${covered ? 'allowed' : 'denied'})`); continue; // check next item } // No stored permission — show the prompt window. activePromptId = next.id; const { top, left } = await getPosition(next.width, next.height); try { const window = await browser.windows.create({ type: 'popup', url: next.url, height: next.height, width: next.width, top, left, }); const promptData = openPrompts.get(next.id); if (promptData && window.id) { promptData.windowId = window.id; promptData.timeoutId = setTimeout(() => { debug(`Prompt ${next.id} timed out after ${PROMPT_TIMEOUT_MS}ms`); cleanupPrompt(next.id, 'timeout'); }, PROMPT_TIMEOUT_MS); } } catch (error) { debug(`Failed to create prompt window: ${error}`); cleanupPrompt(next.id, 'error'); } break; // only open one prompt at a time } } /** * Check if a queued prompt's request is already covered by a stored permission. * Returns true (allowed), false (denied), or undefined (no stored permission). */ async function isQueuedRequestCovered(item: PermissionQueueItem): Promise { const browserSessionData = await getBrowserSessionData(); if (!browserSessionData) return undefined; const currentIdentity = browserSessionData.identities.find( (x) => x.id === browserSessionData.selectedIdentityId ); if (!currentIdentity) return undefined; // Parse host and method from the prompt URL query params. try { const url = new URL(item.url, 'http://ext'); const host = url.searchParams.get('host'); const method = url.searchParams.get('method') as Nip07Method; if (!host || !method) return undefined; return checkPermissions(browserSessionData, currentIdentity, host, method, {}); } catch { return undefined; } } /** * Clean up a prompt and process the next one in queue */ function cleanupPrompt(promptId: string, reason: 'response' | 'timeout' | 'closed' | 'error'): void { const promptData = openPrompts.get(promptId); if (promptData) { if (promptData.timeoutId) { clearTimeout(promptData.timeoutId); } if (reason !== 'response') { promptData.reject(new Error(`Permission prompt ${reason}`)); } openPrompts.delete(promptId); } const queueIndex = permissionQueue.findIndex(item => item.id === promptId); if (queueIndex !== -1) { permissionQueue.splice(queueIndex, 1); } if (activePromptId === promptId) { activePromptId = null; } showNextPermissionPrompt(); } /** * Queue a permission prompt request */ function queuePermissionPrompt( urlWithoutId: string, width: number, height: number ): Promise { return new Promise((resolve, reject) => { if (permissionQueue.length >= MAX_PERMISSION_QUEUE_SIZE) { reject(new Error('Too many pending permission requests. Please try again later.')); return; } const id = crypto.randomUUID(); const separator = urlWithoutId.includes('?') ? '&' : '?'; const url = `${urlWithoutId}${separator}id=${id}`; openPrompts.set(id, { resolve, reject }); permissionQueue.push({ id, url, width, height, resolve, reject }); debug(`Queued permission prompt ${id}. Queue size: ${permissionQueue.length}`); showNextPermissionPrompt(); }); } // Listen for window close events to clean up orphaned prompts and unlock popup browser.windows.onRemoved.addListener((windowId: number) => { // Handle unlock popup closed without successful unlock if (unlockPopupWindowId === windowId) { debug('Unlock popup closed without successful unlock'); unlockPopupOpen = false; unlockPopupWindowId = undefined; // Reject all pending requests — vault is still locked while (pendingRequests.length > 0) { const pending = pendingRequests.shift()!; pending.reject(new Error('Vault unlock cancelled')); } } for (const [promptId, promptData] of openPrompts.entries()) { if (promptData.windowId === windowId) { debug(`Prompt window ${windowId} closed without response`); cleanupPrompt(promptId, 'closed'); break; } } }); // ========================================== // Request Deduplication (P1) // ========================================== const pendingRequestPromises = new Map>(); /** * Generate a hash key for request deduplication */ function getRequestHash(host: string, method: string, params: any): string { if (method === 'signEvent' && params?.kind !== undefined) { return `${host}:${method}:kind${params.kind}`; } // encrypt/decrypt permissions are blanket per host+method (no peerPubkey), // so dedup must match that granularity — one prompt covers all peers. return `${host}:${method}`; } /** * Queue a permission prompt with deduplication */ function queuePermissionPromptDeduped( host: string, method: string, params: any, urlWithoutId: string, width: number, height: number ): Promise { const hash = getRequestHash(host, method, params); const existingPromise = pendingRequestPromises.get(hash); if (existingPromise) { debug(`Deduplicating request: ${hash}`); return existingPromise; } const promise = queuePermissionPrompt(urlWithoutId, width, height) .finally(() => { pendingRequestPromises.delete(hash); }); pendingRequestPromises.set(hash, promise); debug(`New permission request: ${hash}`); return promise; } browser.runtime.onMessage.addListener(async (message, sender) => { debug('Message received'); // Handle unlock request from unlock popup if ((message as UnlockRequestMessage)?.type === 'unlock-request') { const unlockReq = message as UnlockRequestMessage; debug('Processing unlock request'); const result = await handleUnlockRequest(unlockReq.password); const response: UnlockResponseMessage = { type: 'unlock-response', id: unlockReq.id, success: result.success, error: result.error, }; if (result.success) { unlockPopupOpen = false; unlockPopupWindowId = undefined; // Process pending requests asynchronously — don't block the response // to the unlock popup (Firefox may timeout the sendMessage otherwise). const queued = [...pendingRequests]; pendingRequests.length = 0; if (queued.length > 0) { debug(`Scheduling ${queued.length} pending requests`); setTimeout(async () => { for (const pending of queued) { try { const pendingResult = await processNip07Request(pending.request); pending.resolve(pendingResult); } catch (error) { pending.reject(error); } } }, 0); } } return response; } const request = message as BackgroundRequestMessage | PromptResponseMessage; debug(request); if ((request as PromptResponseMessage)?.id) { // Handle prompt response const promptResponse = request as PromptResponseMessage; const openPrompt = openPrompts.get(promptResponse.id); if (!openPrompt) { debug('Prompt response could not be matched (may have timed out)'); return; } openPrompt.resolve(promptResponse.response); // If "always" (approve/reject/approve-all/reject-all), auto-resolve all // queued prompts for the same host:method so they never open a window. if (['approve', 'reject', 'approve-all', 'reject-all'].includes(promptResponse.response)) { const answeredItem = permissionQueue.find(item => item.id === promptResponse.id); if (answeredItem) { try { const answeredUrl = new URL(answeredItem.url, 'http://ext'); const answeredHost = answeredUrl.searchParams.get('host'); const answeredMethod = answeredUrl.searchParams.get('method'); if (answeredHost && answeredMethod) { const autoResponse: PromptResponse = ['approve', 'approve-all'].includes(promptResponse.response) ? 'approve-once' : 'reject-once'; // Drain matching items from the queue (iterate in reverse to safely splice). for (let i = permissionQueue.length - 1; i >= 0; i--) { const item = permissionQueue[i]; if (item.id === promptResponse.id) continue; try { const itemUrl = new URL(item.url, 'http://ext'); if (itemUrl.searchParams.get('host') === answeredHost && itemUrl.searchParams.get('method') === answeredMethod) { const pd = openPrompts.get(item.id); if (pd) { if (pd.timeoutId) clearTimeout(pd.timeoutId); pd.resolve(autoResponse); openPrompts.delete(item.id); } permissionQueue.splice(i, 1); debug(`Auto-resolved queued prompt ${item.id} via ${promptResponse.response}`); } } catch { /* skip malformed */ } } } } catch { /* skip malformed */ } } } cleanupPrompt(promptResponse.id, 'response'); return; } const browserSessionData = await getBrowserSessionData(); if (!browserSessionData) { // Vault is locked - open unlock popup and queue the request const req = request as BackgroundRequestMessage; debug('Vault locked, opening unlock popup'); if (!unlockPopupOpen) { unlockPopupOpen = true; unlockPopupWindowId = await openUnlockPopup(req.host); } // Queue this request to be processed after unlock return new Promise((resolve, reject) => { pendingRequests.push({ request: req, resolve, reject }); }); } // Process the request (NIP-07 or WebLN) const req = request as BackgroundRequestMessage; if (isWeblnMethod(req.method)) { return processWeblnRequest(req); } const tabId = sender?.tab?.id; if (isMlsMethod(req.method) && tabId !== undefined) { mlsSetTab(tabId); } return processNip07Request(req, tabId); }); /** * Process a NIP-07 request after vault is unlocked */ async function processNip07Request(req: BackgroundRequestMessage, tabId?: number): Promise { const browserSessionData = await getBrowserSessionData(); if (!browserSessionData) { throw new Error('Smesh Signer vault not unlocked by the user.'); } const currentIdentity = browserSessionData.identities.find( (x) => x.id === browserSessionData.selectedIdentityId ); if (!currentIdentity) { throw new Error('No Nostr identity available at endpoint.'); } // Check reckless mode first const recklessApprove = await shouldRecklessModeApprove(req.host); debug(`recklessApprove result: ${recklessApprove}`); if (recklessApprove) { debug('Request auto-approved via reckless mode.'); } else { // Normal permission flow const permissionState = checkPermissions( browserSessionData, currentIdentity, req.host, req.method as Nip07Method, req.params ); debug(`permissionState result: ${permissionState}`); if (permissionState === false) { throw new Error('Permission denied'); } if (permissionState === undefined) { // Ask user for permission (queued + deduplicated) const width = 375; const height = 600; // MLS methods are a single permission group — prompt and store as 'mls.*' const isMls = (req.method as string).startsWith('mls.'); const promptMethod = isMls ? 'mls.*' : req.method; const base64Event = Buffer.from( JSON.stringify(req.params ?? {}, undefined, 2) ).toString('base64'); // Include queue info for user awareness const queueSize = permissionQueue.length; const promptUrl = `prompt.html?method=${promptMethod}&host=${req.host}&nick=${encodeURIComponent(currentIdentity.nick)}&event=${base64Event}&queue=${queueSize}`; const response = await queuePermissionPromptDeduped(req.host, promptMethod, req.params, promptUrl, width, height); debug(response); // Handle permission storage based on response type if (response === 'approve' || response === 'reject') { const policy = response === 'approve' ? 'allow' : 'deny'; await storePermission( browserSessionData, currentIdentity, req.host, promptMethod, policy, req.params?.kind ); await backgroundLogPermissionStored(req.host, promptMethod, policy, req.params?.kind); } else if (response === 'approve-all') { await storePermission( browserSessionData, currentIdentity, req.host, promptMethod, 'allow', undefined ); await backgroundLogPermissionStored(req.host, promptMethod, 'allow', undefined); debug(`Stored approve-all permission for ${promptMethod} from ${req.host}`); } else if (response === 'reject-all') { await storePermission( browserSessionData, currentIdentity, req.host, promptMethod, 'deny', undefined ); await backgroundLogPermissionStored(req.host, promptMethod, 'deny', undefined); debug(`Stored reject-all permission for ${promptMethod} from ${req.host}`); } if (['reject', 'reject-once', 'reject-all'].includes(response)) { await backgroundLogNip07Action(req.method, req.host, false, false, { kind: req.params?.kind, peerPubkey: req.params?.peerPubkey, }); throw new Error('Permission denied'); } } else { debug('Request allowed (via saved permission).'); } } const relays: Relays = {}; let result: any; switch (req.method) { case 'getPublicKey': result = NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey); await backgroundLogNip07Action(req.method, req.host, true, recklessApprove); return result; case 'signEvent': result = signEvent(req.params, currentIdentity.privkey); await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, { kind: req.params?.kind, }); return result; case 'getRelays': browserSessionData.relays.forEach((x) => { relays[x.url] = { read: x.read, write: x.write }; }); await backgroundLogNip07Action(req.method, req.host, true, recklessApprove); return relays; case 'nip04.encrypt': result = await nip04Encrypt( currentIdentity.privkey, req.params.peerPubkey, req.params.plaintext ); await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, { peerPubkey: req.params.peerPubkey, }); return result; case 'nip44.encrypt': result = await nip44Encrypt( currentIdentity.privkey, req.params.peerPubkey, req.params.plaintext ); await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, { peerPubkey: req.params.peerPubkey, }); return result; case 'nip04.decrypt': result = await nip04Decrypt( currentIdentity.privkey, req.params.peerPubkey, req.params.ciphertext ); await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, { peerPubkey: req.params.peerPubkey, }); return result; case 'nip44.decrypt': result = await nip44Decrypt( currentIdentity.privkey, req.params.peerPubkey, req.params.ciphertext ); await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, { peerPubkey: req.params.peerPubkey, }); return result; // MLS operations — all crypto happens locally in the extension case 'mls.init': if (tabId === undefined) throw new Error('MLS requires tab context'); result = await mlsInit( currentIdentity.privkey, NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey), req.params.relayURLs || [], tabId, req.params.lastEventTS || 0 ); return result; case 'mls.sendDM': return mlsSendDM(req.params.recipient, req.params.content); case 'mls.subscribe': return mlsSubscribe(); case 'mls.publishKP': return mlsPublishKP(); case 'mls.listGroups': return JSON.parse(await mlsListGroups()); case 'mls.deliverEvent': mlsDeliverEvent(req.params.subId, req.params.eventJSON); return 'ok'; case 'mls.backupGroups': await mlsBackupGroups(); return 'ok'; case 'mls.restoreGroups': await mlsRestoreGroups(); return 'ok'; case 'mls.ratchetGroup': await mlsRatchetGroup(req.params.peerHex); return 'ok'; default: throw new Error(`Not supported request method '${req.method}'.`); } } /** * Process a WebLN request after vault is unlocked */ async function processWeblnRequest(req: BackgroundRequestMessage): Promise { const browserSessionData = await getBrowserSessionData(); if (!browserSessionData) { throw new Error('Smesh Signer vault not unlocked by the user.'); } const nwcConnections = browserSessionData.nwcConnections ?? []; const method = req.method as WeblnMethod; // webln.enable just checks if NWC is configured if (method === 'webln.enable') { if (nwcConnections.length === 0) { throw new Error('No wallet configured. Please add an NWC connection in Smesh Signer settings.'); } debug('WebLN enabled'); return { enabled: true }; // Return explicit value (undefined gets filtered by content script) } // All other methods require an NWC connection const defaultConnection = nwcConnections[0]; if (!defaultConnection) { throw new Error('No wallet configured. Please add an NWC connection in Smesh Signer settings.'); } // Check reckless mode (but still prompt for payments) const recklessApprove = await shouldRecklessModeApprove(req.host); // Check WebLN permissions const permissionState = recklessApprove && method !== 'webln.sendPayment' && method !== 'webln.keysend' ? true : checkWeblnPermissions(browserSessionData, req.host, method); if (permissionState === false) { throw new Error('Permission denied'); } if (permissionState === undefined) { // Ask user for permission (queued + deduplicated) const width = 375; const height = 600; // For sendPayment, include the invoice amount in the prompt data let promptParams = req.params ?? {}; if (method === 'webln.sendPayment' && req.params?.paymentRequest) { const amountSats = parseInvoiceAmount(req.params.paymentRequest); promptParams = { ...promptParams, amountSats }; } const base64Event = Buffer.from( JSON.stringify(promptParams, undefined, 2) ).toString('base64'); // Include queue info for user awareness const queueSize = permissionQueue.length; const promptUrl = `prompt.html?method=${method}&host=${req.host}&nick=WebLN&event=${base64Event}&queue=${queueSize}`; const response = await queuePermissionPromptDeduped(req.host, method, req.params, promptUrl, width, height); debug(response); // Store permission for non-payment methods if ((response === 'approve' || response === 'reject') && method !== 'webln.sendPayment' && method !== 'webln.keysend') { const policy = response === 'approve' ? 'allow' : 'deny'; await storePermission( browserSessionData, null, // WebLN has no identity req.host, method, policy ); await backgroundLogPermissionStored(req.host, method, policy); } else if (response === 'approve-all' && method !== 'webln.sendPayment' && method !== 'webln.keysend') { // P2: Store permission for all uses of this WebLN method await storePermission( browserSessionData, null, req.host, method, 'allow' ); await backgroundLogPermissionStored(req.host, method, 'allow'); debug(`Stored approve-all permission for ${method} from ${req.host}`); } if (['reject', 'reject-once', 'reject-all'].includes(response)) { throw new Error('Permission denied'); } } // Execute the WebLN method let result: any; const client = await getNwcClient(defaultConnection); switch (method) { case 'webln.getInfo': { const info = await client.getInfo(); result = { node: { alias: info.alias, pubkey: info.pubkey, color: info.color, }, } as GetInfoResponse; debug('webln.getInfo result:'); debug(result); return result; } case 'webln.sendPayment': { const invoice = req.params.paymentRequest; const payResult = await client.payInvoice({ invoice }); result = { preimage: payResult.preimage } as SendPaymentResponse; debug('webln.sendPayment result:'); debug(result); return result; } case 'webln.makeInvoice': { // Convert sats to millisats (NWC uses millisats) const amountSats = typeof req.params.amount === 'string' ? parseInt(req.params.amount, 10) : req.params.amount ?? req.params.defaultAmount ?? 0; const amountMsat = amountSats * 1000; const invoiceResult = await client.makeInvoice({ amount: amountMsat, description: req.params.defaultMemo, }); result = { paymentRequest: invoiceResult.invoice } as RequestInvoiceResponse; debug('webln.makeInvoice result:'); debug(result); return result; } case 'webln.keysend': throw new Error('keysend is not yet supported'); default: throw new Error(`Not supported WebLN method '${method}'.`); } }