From 0b1d849f197061165d2442731aa1da869edcf9dd Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sat, 10 Jun 2023 22:26:49 -0300 Subject: [PATCH] rework permissions and popup prompts, make each permission fine grained. --- extension/background.js | 103 +++++----- extension/common.js | 138 ++++++------- extension/options.jsx | 444 +++++++++++++++++++++------------------- extension/prompt.jsx | 78 ++++--- package.json | 2 +- yarn.lock | 68 +++--- 6 files changed, 447 insertions(+), 386 deletions(-) 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 ( - <> -

nos2x

-

nostr signer extension

-

options

-
-
- preferred relays: - -
-
- {relays.map(({url, policy}, i) => ( -
- - - -
- ))} -
+ <> +

nos2x

+

nostr signer extension

+

options

+
+
+ preferred relays: + +
+
+ {relays.map(({url, policy}, i) => ( +
setNewRelayURL(e.target.value)} - onBlur={addNewRelay} + style={{marginRight: '10px', width: '400px'}} + value={url} + onChange={changeRelayURL.bind(null, i)} /> + +
+ ))} +
+ setNewRelayURL(e.target.value)} + onBlur={addNewRelay} + />
-
-
+
{message}
+ ) async function handleKeyChange(e) { @@ -335,10 +355,16 @@ function Options() { } async function handleRevoke(e) { - let host = e.target.dataset.domain - if (window.confirm(`revoke all permissions from ${host}?`)) { - await removePermissions(host) - showMessage(`removed permissions from ${host}`) + let {host, accept, type} = e.target.dataset + if ( + window.confirm( + `revoke all ${ + accept ? 'accept' : 'deny' + } ${type} policies from ${host}?` + ) + ) { + await removePermissions(host, accept, type) + showMessage('removed policies') loadPermissions() } } @@ -346,7 +372,7 @@ function Options() { async function saveRelays() { await browser.storage.local.set({ relays: Object.fromEntries( - relays + relays .filter(({url}) => url.trim() !== '') .map(({url, policy}) => [url.trim(), policy]) ) diff --git a/extension/prompt.jsx b/extension/prompt.jsx index c3739a7..6e110d4 100644 --- a/extension/prompt.jsx +++ b/extension/prompt.jsx @@ -2,17 +2,18 @@ import browser from 'webextension-polyfill' import {render} from 'react-dom' import React from 'react' -import {getAllowedCapabilities} from './common' +import {PERMISSION_NAMES} from './common' function Prompt() { let qs = new URLSearchParams(location.search) let id = qs.get('id') let host = qs.get('host') - let level = parseInt(qs.get('level')) - let params + let type = qs.get('type') + let params, event try { params = JSON.parse(qs.get('params')) if (Object.keys(params).length === 0) params = null + else if (params.event) event = params.event } catch (err) { params = null } @@ -23,20 +24,15 @@ function Prompt() { {host} {' '} -

is requesting your permission to

-
    - {getAllowedCapabilities(level).map(cap => ( -
  • - {cap} -
  • - ))} -
+

+ is requesting your permission to {PERMISSION_NAMES[type]}: +

{params && ( <>

now acting on

-
-            {JSON.stringify(params, null, 2)}
+          
+            {JSON.stringify(event || params, null, 2)}
           
)} @@ -49,35 +45,65 @@ function Prompt() { > - - + )} + - + ) : ( + + )} +
) - function authorizeHandler(condition) { + function authorizeHandler(accept, conditions) { return function (ev) { ev.preventDefault() browser.runtime.sendMessage({ prompt: true, id, host, - level, - condition + type, + accept, + conditions }) } } diff --git a/package.json b/package.json index 2fa2f5a..ca101ed 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "eslint-plugin-babel": "^5.3.1", "eslint-plugin-react": "^7.28.0", "events": "^3.3.0", - "nostr-tools": "^1.1.0", + "nostr-tools": "^1.12.0", "prettier": "^2.5.1", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/yarn.lock b/yarn.lock index db615a9..726baa7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -31,41 +31,43 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== -"@noble/hashes@^0.5.7": - version "0.5.9" - resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-0.5.9.tgz#9f3051a4cc6f7c168022b3b7fbbe9fe2a35cccf0" - integrity sha512-7lN1Qh6d8DUGmfN36XRsbN/WcGIPNtTGhkw26vWId/DlCIGsYJJootTtPGghTLcn/AaXPx2Q0b3cacrwXa7OVw== +"@noble/curves@1.0.0", "@noble/curves@~1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.0.0.tgz#e40be8c7daf088aaf291887cbc73f43464a92932" + integrity sha512-2upgEu0iLiDVDZkNLeFV2+ht0BAVgQnEmCk6JsOch9Rp8xfkMCbvbAZlA2pBHQc73dbl+vFOXfqkf4uemdn0bw== + dependencies: + "@noble/hashes" "1.3.0" -"@noble/hashes@~1.1.1", "@noble/hashes@~1.1.3": - version "1.1.5" - resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.1.5.tgz#1a0377f3b9020efe2fae03290bd2a12140c95c11" - integrity sha512-LTMZiiLc+V4v1Yi16TD6aX2gmtKszNye0pQgbaLqkvhIqP7nVsSaJsWloGQjJfJ8offaoP5GtX3yY5swbcJxxQ== +"@noble/hashes@1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.0.tgz#085fd70f6d7d9d109671090ccae1d3bec62554a1" + integrity sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg== -"@noble/secp256k1@^1.7.0", "@noble/secp256k1@~1.7.0": - version "1.7.0" - resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-1.7.0.tgz#d15357f7c227e751d90aa06b05a0e5cf993ba8c1" - integrity sha512-kbacwGSsH/CTout0ZnZWxnW1B+jH/7r/WAAKLBtrRJ/+CUH7lgmQzl3GTrQua3SGKWNSDsS6lmjnDpIJ5Dxyaw== +"@noble/hashes@~1.3.0": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9" + integrity sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA== -"@scure/base@^1.1.1", "@scure/base@~1.1.0": +"@scure/base@1.1.1", "@scure/base@~1.1.0": version "1.1.1" resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.1.tgz#ebb651ee52ff84f420097055f4bf46cfba403938" integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA== -"@scure/bip32@^1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.1.1.tgz#f62e4a2f13cc3e5e720ad81b7582b8631ae6835a" - integrity sha512-UmI+liY7np2XakaW+6lMB6HZnpczWk1yXZTxvg8TM8MdOcKHCGL1YkraGj8eAjPfMwFNiAyek2hXmS/XFbab8g== +"@scure/bip32@1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.3.0.tgz#6c8d980ef3f290987736acd0ee2e0f0d50068d87" + integrity sha512-bcKpo1oj54hGholplGLpqPHRbIsnbixFtc06nwuNM5/dwSXOq/AAYoIBRsBmnZJSdfeNW5rnff7NTAz3ZCqR9Q== dependencies: - "@noble/hashes" "~1.1.3" - "@noble/secp256k1" "~1.7.0" + "@noble/curves" "~1.0.0" + "@noble/hashes" "~1.3.0" "@scure/base" "~1.1.0" -"@scure/bip39@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.1.0.tgz#92f11d095bae025f166bef3defcc5bf4945d419a" - integrity sha512-pwrPOS16VeTKg98dYXQyIjJEcWfz7/1YJIwxUEPFfQPtc86Ym/1sVgQ2RLoD43AazMk2l/unK4ITySSpW2+82w== +"@scure/bip39@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.2.0.tgz#a207e2ef96de354de7d0002292ba1503538fc77b" + integrity sha512-SX/uKq52cuxm4YFXWFaVByaSHJh2w3BnokVSeUJVCv6K7WulT9u2BuNRBhuFl8vAuYnzx9bEu9WgpcNYTrYieg== dependencies: - "@noble/hashes" "~1.1.1" + "@noble/hashes" "~1.3.0" "@scure/base" "~1.1.0" acorn-jsx@^5.3.1: @@ -932,16 +934,16 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= -nostr-tools@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-1.1.0.tgz#f7c06a1d1a1a71b7b1feb7b0e687cef6a4e24286" - integrity sha512-T+Fj29ff6dn1YMMDrG03OctxrWVKeei/DZatVjgoad0tYUCiBBERk37qkpCqFAoKYVreIPl/Mxrh2DVfMzLA7g== +nostr-tools@^1.12.0: + version "1.12.0" + resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-1.12.0.tgz#ec3618fc2298e029941b7db3bbe95187777c488f" + integrity sha512-fsIXaNJPKaSrO9MxsCEWbhI4tG4pToQK4D4sgLRD0fRDfZ6ocCg8CLlh9lcNx0o8pVErCMLVASxbJ+w4WNK0MA== dependencies: - "@noble/hashes" "^0.5.7" - "@noble/secp256k1" "^1.7.0" - "@scure/base" "^1.1.1" - "@scure/bip32" "^1.1.1" - "@scure/bip39" "^1.1.0" + "@noble/curves" "1.0.0" + "@noble/hashes" "1.3.0" + "@scure/base" "1.1.1" + "@scure/bip32" "1.3.0" + "@scure/bip39" "1.2.0" nth-check@^2.0.1: version "2.1.1"