diff --git a/extension/background.js b/extension/background.js index 6eb109c..c7cbb29 100644 --- a/extension/background.js +++ b/extension/background.js @@ -10,9 +10,8 @@ import {nip04} from 'nostr-tools' import {Mutex} from 'async-mutex' import { - PERMISSIONS_REQUIRED, NO_PERMISSIONS_REQUIRED, - readPermissionLevel, + getPermissionStatus, updatePermission } from './common' @@ -90,24 +89,60 @@ async function handleContentScriptMessage({type, params, host}) { return } else { - let level = await readPermissionLevel(host) + // acquire mutex here before reading policies + releasePromptMutex = await promptMutex.acquire() - if (level >= PERMISSIONS_REQUIRED[type]) { + let allowed = await getPermissionStatus( + host, + type, + type === 'signEvent' ? params.event : undefined + ) + + if (allowed === true) { // authorized, proceed + releasePromptMutex() + } else if (allowed === false) { + // denied, just refuse immediately + releasePromptMutex() + return { + error: 'denied' + } } else { // ask for authorization try { - await promptPermission(host, PERMISSIONS_REQUIRED[type], params) - // authorized, proceed - } catch (_) { - // not authorized, stop here + let id = Math.random().toString().slice(4) + let qs = new URLSearchParams({ + host, + id, + params: JSON.stringify(params), + type + }) + + // prompt will be resolved with true or false + let accept = await new Promise((resolve, reject) => { + openPrompt = {resolve, reject} + + browser.windows.create({ + url: `${browser.runtime.getURL('prompt.html')}?${qs.toString()}`, + type: 'popup', + width: 340, + height: 360 + }) + }) + + // denied, stop here + if (!accept) return {error: 'denied'} + } catch (err) { + // errored, stop here + releasePromptMutex() return { - error: `insufficient permissions, required ${PERMISSIONS_REQUIRED[type]}` + error: `error: ${err}` } } } } + // if we're here this means it was accepted let results = await browser.storage.local.get('private_key') if (!results || !results.private_key) { return {error: 'no private key found'} @@ -148,51 +183,23 @@ async function handleContentScriptMessage({type, params, host}) { } } -function handlePromptMessage({id, condition, host, level}, sender) { - switch (condition) { - case 'forever': - case 'expirable': - openPrompt?.resolve?.() - updatePermission(host, { - level, - condition - }) - break - case 'single': - openPrompt?.resolve?.() - break - case 'no': - openPrompt?.reject?.() - break +function handlePromptMessage({id, host, type, accept, conditions}, sender) { + // return response + openPrompt?.resolve?.(accept) + + // update policies + if (conditions) { + updatePermission(host, type, accept, conditions) } + // cleanup this openPrompt = null + + // release mutex here after updating policies releasePromptMutex() + // close prompt if (sender) { browser.windows.remove(sender.tab.windowId) } } - -async function promptPermission(host, level, params) { - releasePromptMutex = await promptMutex.acquire() - - let id = Math.random().toString().slice(4) - let qs = new URLSearchParams({ - host, - level, - id, - params: JSON.stringify(params) - }) - - return new Promise((resolve, reject) => { - openPrompt = {resolve, reject} - - browser.windows.create({ - url: `${browser.runtime.getURL('prompt.html')}?${qs.toString()}`, - type: 'popup', - width: 340, - height: 330 - }) - }) -} diff --git a/extension/common.js b/extension/common.js index ba5ebfd..2af3225 100644 --- a/extension/common.js +++ b/extension/common.js @@ -4,90 +4,90 @@ export const NO_PERMISSIONS_REQUIRED = { replaceURL: true } -export const PERMISSIONS_REQUIRED = { - getPublicKey: 1, - getRelays: 5, - signEvent: 10, - 'nip04.encrypt': 20, - 'nip04.decrypt': 20, -} +export const PERMISSION_NAMES = Object.fromEntries([ + ['getPublicKey', 'read your public key'], + ['getRelays', 'read your list of preferred relays'], + ['signEvent', 'sign events using your private key'], + ['nip04.encrypt', 'encrypt messages to peers'], + ['nip04.decrypt', 'decrypt messages from peers'] +]) -const ORDERED_PERMISSIONS = [ - [1, ['getPublicKey']], - [5, ['getRelays']], - [10, ['signEvent']], - [20, ['nip04.encrypt']], - [20, ['nip04.decrypt']] -] - -const PERMISSION_NAMES = { - getPublicKey: 'read your public key', - getRelays: 'read your list of preferred relays', - signEvent: 'sign events using your private key', - 'nip04.encrypt': 'encrypt messages to peers', - 'nip04.decrypt': 'decrypt messages from peers', -} - -export function getAllowedCapabilities(permission) { - let requestedMethods = [] - for (let i = 0; i < ORDERED_PERMISSIONS.length; i++) { - let [perm, methods] = ORDERED_PERMISSIONS[i] - if (perm > permission) break - requestedMethods = requestedMethods.concat(methods) +function matchConditions(conditions, event) { + if (conditions?.kinds) { + if (event.kind in conditions.kinds) return true + else return false } - if (requestedMethods.length === 0) return 'nothing' - - return requestedMethods.map(method => PERMISSION_NAMES[method]) + return true } -export function getPermissionsString(permission) { - let capabilities = getAllowedCapabilities(permission) +export async function getPermissionStatus(host, type, event) { + let {policies} = await browser.storage.local.get('policies') - if (capabilities.length === 0) return 'none' - if (capabilities.length === 1) return capabilities[0] + let answers = [true, false] + for (let i = 0; i < answers.length; i++) { + let accept = answers[i] + let {conditions} = policies?.[host]?.[accept]?.[type] || {} - return ( - capabilities.slice(0, -1).join(', ') + - ' and ' + - capabilities[capabilities.length - 1] - ) -} - -export async function readPermissions() { - let {permissions = {}} = await browser.storage.local.get('permissions') - - // delete expired - var needsUpdate = false - for (let host in permissions) { - if ( - permissions[host].condition === 'expirable' && - permissions[host].created_at < Date.now() / 1000 - 5 * 60 - ) { - delete permissions[host] - needsUpdate = true + if (conditions) { + if (type === 'signEvent') { + if (matchConditions(conditions, event)) { + return accept // may be true or false + } else { + // if this doesn't match we just continue so it will either match for the opposite answer (reject) + // or it will end up returning undefined at the end + continue + } + } else { + return accept // may be true or false + } } } - if (needsUpdate) browser.storage.local.set({permissions}) - return permissions + return undefined } -export async function readPermissionLevel(host) { - return (await readPermissions())[host]?.level || 0 -} +export async function updatePermission(host, type, accept, conditions) { + let {policies = {}} = await browser.storage.local.get('policies') -export async function updatePermission(host, permission) { - let {permissions = {}} = await browser.storage.local.get('permissions') - permissions[host] = { - ...permission, + // if the new conditions is "match everything", override the previous + if (Object.keys(conditions).length === 0) { + conditions = {} + } else { + // if we already had a policy for this, merge the conditions + let existingConditions = policies[host]?.[accept]?.[type]?.conditions + if (existingConditions) { + if (existingConditions.kinds && conditions.kinds) { + Object.keys(existingConditions.kinds).forEach(kind => { + conditions.kinds[kind] = true + }) + } + } + } + + // if we have a reverse policy (accept / reject) that is exactly equal to this, remove it + let other = !accept + let reverse = policies?.[host]?.[other]?.[type] + if ( + reverse && + JSON.stringify(reverse.conditions) === JSON.stringify(conditions) + ) { + delete policies[host][other][type] + } + + // insert our new policy + policies[host] = policies[host] || {} + policies[host][accept] = policies[host][accept] || {} + policies[host][accept][type] = { + conditions, // filter that must match the event (in case of signEvent) created_at: Math.round(Date.now() / 1000) } - browser.storage.local.set({permissions}) + + browser.storage.local.set({policies}) } -export async function removePermissions(host) { - let {permissions = {}} = await browser.storage.local.get('permissions') - delete permissions[host] - browser.storage.local.set({permissions}) +export async function removePermissions(host, accept, type) { + let {policies = {}} = await browser.storage.local.get('policies') + delete policies[host] + browser.storage.local.set({policies}) } diff --git a/extension/options.jsx b/extension/options.jsx index 09617de..0772ecd 100644 --- a/extension/options.jsx +++ b/extension/options.jsx @@ -4,18 +4,14 @@ import {render} from 'react-dom' import {generatePrivateKey, getPublicKey, nip19} from 'nostr-tools' import QRCode from 'react-qr-code' -import { - getPermissionsString, - readPermissions, - removePermissions -} from './common' +import {removePermissions, PERMISSION_NAMES} from './common' function Options() { let [pubKey, setPubKey] = useState('') let [privKey, setPrivKey] = useState('') let [relays, setRelays] = useState([]) let [newRelayURL, setNewRelayURL] = useState('') - let [permissions, setPermissions] = useState() + let [policies, setPermissions] = useState() let [protocolHandler, setProtocolHandler] = useState(null) let [hidingPrivateKey, hidePrivateKey] = useState(true) let [message, setMessage] = useState('') @@ -28,217 +24,241 @@ function Options() { useEffect(() => { browser.storage.local - .get(['private_key', 'relays', 'protocol_handler']) - .then(results => { - if (results.private_key) { - setPrivKey(nip19.nsecEncode(results.private_key)) + .get(['private_key', 'relays', 'protocol_handler']) + .then(results => { + if (results.private_key) { + setPrivKey(nip19.nsecEncode(results.private_key)) - let hexKey = getPublicKey(results.private_key) - let npubKey = nip19.npubEncode(hexKey) + let hexKey = getPublicKey(results.private_key) + let npubKey = nip19.npubEncode(hexKey) - setPubKey(npubKey) - } - if (results.relays) { - let relaysList = [] - for (let url in results.relays) { - relaysList.push({ - url, - policy: results.relays[url] - }) + setPubKey(npubKey) } - setRelays(relaysList) - } - if (results.protocol_handler) { - setProtocolHandler(results.protocol_handler) - } - }) + if (results.relays) { + let relaysList = [] + for (let url in results.relays) { + relaysList.push({ + url, + policy: results.relays[url] + }) + } + setRelays(relaysList) + } + if (results.protocol_handler) { + setProtocolHandler(results.protocol_handler) + } + }) }, []) useEffect(() => { loadPermissions() }, []) - function loadPermissions() { - readPermissions().then(permissions => { - setPermissions( - Object.entries(permissions).map( - ([host, {level, condition, created_at}]) => ({ - host, - level, - condition, - created_at - }) - ) - ) + async function loadPermissions() { + let {policies = {}} = await browser.storage.local.get('policies') + let list = [] + + Object.entries(policies).forEach(([host, accepts]) => { + Object.entries(accepts).forEach(([accept, types]) => { + Object.entries(types).forEach(([type, {conditions, created_at}]) => { + list.push({ + host, + type, + accept: {true: 'allow', false: 'deny'}[accept], + conditions, + created_at + }) + }) + }) }) + + setPermissions(list) } return ( - <> -
nostr signer extension
-nostr signer extension
+is requesting your permission to
-+ is requesting your permission to {PERMISSION_NAMES[type]}: +
now acting on
--{JSON.stringify(params, null, 2)}++> )} @@ -49,35 +45,65 @@ function Prompt() { > - -{JSON.stringify(event || params, null, 2)}