import browser from 'webextension-polyfill' import { validateEvent, finalizeEvent, getEventHash, getPublicKey, nip19, utils } from 'nostr-tools' import { nip04 } from 'nostr-tools' import * as nip44 from 'nostr-tools/nip44' import { Mutex } from 'async-mutex' import { LRUCache } from './utils' const { hexToBytes } = utils import { NO_PERMISSIONS_REQUIRED, getPermissionStatus, updatePermission, showNotification, getPosition } from './common' const { encrypt, decrypt } = nip04 let openPrompt = null const promptMutex = new Mutex() let releasePromptMutex = () => {} const secretsCache = new LRUCache(100) const previousSk = null function getSharedSecret(sk, peer) { if (previousSk !== sk) { secretsCache.clear() } let key = secretsCache.get(peer) if (!key) { key = nip44.v2.utils.getConversationKey(sk, peer) secretsCache.set(peer, key) } return key } const width = 440 const height = 420 browser.runtime.onInstalled.addListener((_, __, reason) => { if (reason === 'install') browser.runtime.openOptionsPage() }) browser.runtime.onMessage.addListener(async (req, sender) => { const { prompt } = req if (prompt) { handlePromptMessage(req, sender) } else { return handleContentScriptMessage(req) } }) browser.runtime.onMessageExternal.addListener( async ({ type, params }, sender) => { const extensionId = new URL(sender.url).host return handleContentScriptMessage({ type, params, host: extensionId }) } ) browser.windows.onRemoved.addListener((_windowId) => { if (openPrompt) { // calling this with a simple "no" response will not store anything, so it's fine // it will just return a failure handlePromptMessage({ accept: false }, null) } }) async function handleContentScriptMessage({ type, params, host }) { if (NO_PERMISSIONS_REQUIRED[type]) { switch (type) { case 'peekPublicKey': { const allowed = await getPermissionStatus(host, 'getPublicKey') if (allowed === true) return performOperation('getPublicKey', params) return '' } case 'replaceURL': { const { protocol_handler: ph } = await browser.storage.local.get([ 'protocol_handler' ]) if (!ph) return false const { url } = params const raw = url.split('nostr:')[1] const { type, data } = nip19.decode(raw) const replacements = { raw, hrp: type, hex: type === 'npub' || type === 'note' ? data : type === 'nprofile' ? data.pubkey : type === 'nevent' ? data.id : null, p_or_e: { npub: 'p', note: 'e', nprofile: 'p', nevent: 'e' }[type], u_or_n: { npub: 'u', note: 'n', nprofile: 'u', nevent: 'n' }[type], relay0: type === 'nprofile' ? data.relays[0] : null, relay1: type === 'nprofile' ? data.relays[1] : null, relay2: type === 'nprofile' ? data.relays[2] : null } let result = ph Object.entries(replacements).forEach(([pattern, value]) => { result = result.replace(new RegExp(`{ *${pattern} *}`, 'g'), value) }) return result } } return } else { // acquire mutex here before reading policies releasePromptMutex = await promptMutex.acquire() const allowed = await getPermissionStatus( host, type, type === 'signEvent' ? params.event : undefined ) if (allowed === true) { // authorized, proceed releasePromptMutex() showNotification(host, allowed, type, params) } else if (allowed === false) { // denied, just refuse immediately releasePromptMutex() showNotification(host, allowed, type, params) return { error: 'denied' } } else { // ask for authorization try { const id = Math.random().toString().slice(4) const qs = new URLSearchParams({ host, id, params: JSON.stringify(params), type }) // prompt will be resolved with true or false const accept = await new Promise((resolve, reject) => { openPrompt = { resolve, reject } const url = `${browser.runtime.getURL( 'prompt.html' )}?${qs.toString()}` // center prompt const { top, left } = getPosition(width, height) if (browser.windows) { browser.windows.create({ url, type: 'popup', width: width, height: height, top: top, left: left }) } else { browser.tabs.create({ url, active: true }) } }) // denied, stop here if (!accept) return { error: { message: 'denied' } } } catch (err) { // errored, stop here releasePromptMutex() return { error: { message: err.message, stack: err.stack } } } } } // if we're here this means it was accepted const results = await browser.storage.local.get('private_key') if (!results?.private_key) { return { error: 'no private key found' } } const sk = results.private_key try { switch (type) { case 'getPublicKey': { return getPublicKey(hexToBytes(sk)) } case 'getRelays': { const results = await browser.storage.local.get('relays') return results.relays || {} } case 'signEvent': { const { event } = params if (!event.pubkey) event.pubkey = getPublicKey(hexToBytes(sk)) if (!event.id) event.id = getEventHash(event) if (!validateEvent(event)) return { error: { message: 'invalid event' } } const signedEvent = finalizeEvent(event, hexToBytes(sk)) return signedEvent } case 'nip04.encrypt': { const { peer, plaintext } = params return encrypt(sk, peer, plaintext) } case 'nip04.decrypt': { const { peer, ciphertext } = params return decrypt(sk, peer, ciphertext) } case 'nip44.encrypt': { const { peer, plaintext } = params const key = getSharedSecret(sk, peer) return nip44.v2.encrypt(plaintext, key) } case 'nip44.decrypt': { const { peer, ciphertext } = params const key = getSharedSecret(sk, peer) return nip44.v2.decrypt(ciphertext, key) } } } catch (error) { return { error: { message: error.message, stack: error.stack } } } } async function handlePromptMessage({ host, type, accept, conditions }, sender) { // return response openPrompt?.resolve?.(accept) // update policies if (conditions) { await updatePermission(host, type, accept, conditions) } // cleanup this openPrompt = null // release mutex here after updating policies releasePromptMutex() // close prompt if (sender) { if (browser.windows) { browser.windows.remove(sender.tab.windowId) } else { // Android Firefox browser.tabs.remove(sender.tab.id) } } }