From ffc4fc6458c7e19a39f2e96dc7816288c31340a3 Mon Sep 17 00:00:00 2001 From: Oren Date: Fri, 14 Aug 2026 00:49:33 +0300 Subject: [PATCH] allow platform-specific NIP-44 decryption Users may be reluctant to grant a website full NIP-44 decryption access. This lets a site request limited permission: plaintext is returned only if it is JSON whose `tags` include a `platforms` tag matching one of the permitted platforms. For example, a game named "Foo" can wrap player messages as: ``` {"content":"...","tags":[["platforms","Foo"]]} ``` stringify that object, then encrypt it with NIP-44. The JSON may be a fully signed Nostr event, but it does not have to be. On decrypt, the site calls `nip44.decrypt` with `{platforms: ["Foo"]}`. That means it is asking only to read messages tagged for "Foo", not to inspect encrypted events from other Nostr apps (such as general-purpose DMs). --- README.md | 2 +- extension/background.js | 86 +++++++++++++++++++++++++++++++++++-- extension/common.js | 45 ++++++++++++++++++- extension/nostr-provider.js | 4 +- extension/options.jsx | 4 +- extension/prompt.jsx | 32 ++++++++++++-- 6 files changed, 162 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 78fcaf2..683ee70 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ async window.nostr.signEvent(event): Event // returns the full event object sign async window.nostr.nip04.encrypt(pubkey, plaintext): string // returns ciphertext+iv as specified in nip04 async window.nostr.nip04.decrypt(pubkey, ciphertext): string // takes ciphertext+iv as specified in nip04 async window.nostr.nip44.encrypt(pubkey, plaintext): string // takes pubkey, plaintext, returns ciphertext as specified in nip-44 -async window.nostr.nip44.decrypt(pubkey, ciphertext): string // takes pubkey, ciphertext, returns plaintext as specified in nip-44 +async window.nostr.nip44.decrypt(pubkey, ciphertext, options?: {platforms?: string[]}): string // takes pubkey, ciphertext, and optional platforms; returns plaintext as specified in nip-44 ``` This extension is Chromium-only. For a maintained Firefox fork, see [nos2x-fox](https://diegogurpegui.com/nos2x-fox/). diff --git a/extension/background.js b/extension/background.js index 330ce3f..33d92d5 100644 --- a/extension/background.js +++ b/extension/background.js @@ -125,10 +125,16 @@ async function handleContentScriptMessage({type, params, host}) { // do the operation before asking (because we'll show the encryption/decryption results in the popup const finalResult = await performOperation(type, params) + if (finalResult?.error?.code === 'nip44-decrypt-guard') { + releasePromptMutex() + return {error: {message: finalResult.error.message}} + } + let allowed = await getPermissionStatus( host, type, - type === 'signEvent' ? params.event : undefined + type === 'signEvent' ? params.event : undefined, + type === 'nip44.decrypt' ? params.platforms : undefined ) if (allowed === true) { @@ -182,11 +188,57 @@ async function handleContentScriptMessage({type, params, host}) { } } + if ( + type === 'nip44.decrypt' && + params.platforms && + typeof finalResult === 'string' + ) { + try { + assertDecryptedMatchesPlatforms(finalResult, params.platforms) + } catch (err) { + return {error: {message: err.message}} + } + } + // the call was authorized, so we just return the result we had from before return finalResult } } +function assertDecryptedMatchesPlatforms(plaintext, platforms) { + const mismatch = new Error( + 'website is trying to decrypt a message from a different platform' + ) + let parsed + try { + parsed = JSON.parse(plaintext) + } catch (_) { + throw mismatch + } + if ( + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) || + !Object.prototype.hasOwnProperty.call(parsed, 'tags') || + !Array.isArray(parsed.tags) + ) { + throw mismatch + } + if ( + !parsed.tags.every( + tag => Array.isArray(tag) && tag.every(item => typeof item === 'string') + ) + ) { + throw mismatch + } + let matches = parsed.tags.some( + tag => + tag[0] === 'platforms' && + tag.slice(1).some(item => platforms.includes(item)) + ) + if (!matches) throw mismatch +} + async function performOperation(type, params) { let results = await browser.storage.local.get('private_key') if (!results || !results.private_key) { @@ -220,9 +272,37 @@ async function performOperation(type, params) { return nip44.v2.encrypt(plaintext, key) } case 'nip44.decrypt': { - const {peer, ciphertext} = params + const {peer, ciphertext, platforms} = params + if (platforms !== undefined) { + if ( + !Array.isArray(platforms) || + !platforms.every(p => typeof p === 'string') + ) { + return { + error: { + message: 'platforms must be an array of strings', + code: 'nip44-decrypt-guard' + } + } + } + if (platforms.length === 0) { + return { + error: { + message: 'platforms must not be empty', + code: 'nip44-decrypt-guard' + } + } + } + if (platforms.length > 20 || platforms.reduce((acc, p) => acc + p.length, 0) > 1000) { + return { + error: { + message: 'platforms must be less than 20 and the total length of the platforms must be less than 1000', + code: 'nip44-decrypt-guard' + } + } + } + } const key = getSharedSecret(sk, peer) - return nip44.v2.decrypt(ciphertext, key) } } diff --git a/extension/common.js b/extension/common.js index 1674124..f1fbbaa 100644 --- a/extension/common.js +++ b/extension/common.js @@ -23,7 +23,32 @@ function matchConditions(conditions, event) { return true } -export async function getPermissionStatus(host, type, event) { +function matchDecryptPlatforms(conditions, platforms) { + if (conditions?.platforms) { + if (!platforms || !platforms.length) return false + return platforms.every(platform => + conditions.platforms.includes(platform) + ) + } + + return true +} + +export function getPermissionName(type, params) { + if (type === 'nip44.decrypt') { + let platforms = params?.platforms + if (Array.isArray(platforms) && platforms.length > 0) { + return `decrypt events for ${ + platforms.length === 1 ? 'platform' : 'platforms' + }: ${platforms.map(platform => JSON.stringify(platform)).join(', ')}` + } + return 'decrypt all of your messages' + } + + return PERMISSION_NAMES[type] +} + +export async function getPermissionStatus(host, type, event, platforms) { let {policies} = await browser.storage.local.get('policies') let answers = [true, false] @@ -40,6 +65,12 @@ export async function getPermissionStatus(host, type, event) { // or it will end up returning undefined at the end continue } + } else if (type === 'nip44.decrypt') { + if (matchDecryptPlatforms(conditions, platforms)) { + return accept + } else { + continue + } } else { return accept // may be true or false } @@ -64,6 +95,18 @@ export async function updatePermission(host, type, accept, conditions) { conditions.kinds[kind] = true }) } + if (existingConditions.platforms && conditions.platforms) { + existingConditions.platforms.forEach(platform => { + if (!conditions.platforms.includes(platform)) { + conditions.platforms.push(platform) + } + }) + if (conditions.platforms.length > 50) { + // storing up to 50 platforms is more than enough + console.warn('platforms list is too long, truncating to 50') + conditions.platforms = conditions.platforms.slice(-50) + } + } } } diff --git a/extension/nostr-provider.js b/extension/nostr-provider.js index da33b93..af9d560 100644 --- a/extension/nostr-provider.js +++ b/extension/nostr-provider.js @@ -35,8 +35,8 @@ window.nostr = { return window.nostr._call('nip44.encrypt', {peer, plaintext}) }, - async decrypt(peer, ciphertext) { - return window.nostr._call('nip44.decrypt', {peer, ciphertext}) + async decrypt(peer, ciphertext, options) { + return window.nostr._call('nip44.decrypt', {peer, ciphertext, platforms: options?.platforms}) } }, diff --git a/extension/options.jsx b/extension/options.jsx index b219575..5397f81 100644 --- a/extension/options.jsx +++ b/extension/options.jsx @@ -329,7 +329,9 @@ function Options() { {conditions.kinds ? `kinds: ${Object.keys(conditions.kinds).join(', ')}` - : 'always'} + : conditions.platforms + ? `platforms: ${conditions.platforms.map(platform => JSON.stringify(platform)).join(', ')}` + : 'always'} {new Date(created_at * 1000) diff --git a/extension/prompt.jsx b/extension/prompt.jsx index b01069e..c25f9c8 100644 --- a/extension/prompt.jsx +++ b/extension/prompt.jsx @@ -2,7 +2,7 @@ import browser from 'webextension-polyfill' import {createRoot} from 'react-dom/client' import React from 'react' -import {PERMISSION_NAMES} from './common' +import {getPermissionName} from './common' function Prompt() { let qs = new URLSearchParams(location.search) @@ -19,13 +19,22 @@ function Prompt() { params = null } + let platforms = + type === 'nip44.decrypt' && Array.isArray(params?.platforms) + ? params.platforms + : null + let platformConditions = platforms?.length + ? {platforms} + : null + return ( <> {host} {' '}

- is requesting your permission to {PERMISSION_NAMES[type]}: + is requesting your permission to{' '} + {getPermissionName(type, params)}:

{params && (
@@ -63,7 +72,7 @@ function Prompt() { gap: '0.5rem' }} > - {event?.kind === undefined && ( + {event?.kind === undefined && !platformConditions && ( + )} @@ -107,6 +125,14 @@ function Prompt() { > reject kind {event.kind} forever + ) : platformConditions ? ( + ) : (