From 001bbaa3bf545876e90b54dcaad323a57ab2e3f7 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 21 Oct 2025 21:34:24 -0400 Subject: [PATCH] Handle direct signer decryptors for user blocks --- js/userBlocks.js | 93 ++++++++++++++------- tests/user-blocks.test.mjs | 165 ++++++++++++++++++++++++++++++++++++- 2 files changed, 226 insertions(+), 32 deletions(-) diff --git a/js/userBlocks.js b/js/userBlocks.js index 80bf0aaa..1bfebf4e 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -339,33 +339,53 @@ class UserBlockListManager { }); }; - const permissionResult = await requestDefaultExtensionPermissions(); - if (!permissionResult.ok) { - userLogger.warn( - "[UserBlockList] Unable to load block list without extension permissions.", - permissionResult.error, - ); - this.reset(); - this.loaded = true; - const error = - permissionResult.error instanceof Error - ? permissionResult.error - : new Error("Extension permissions required to load block list."); - emitStatus({ status: "error", error }); - emitStatus({ status: "settled" }); - return; + const activeSigner = getActiveSigner(); + const signerDecryptor = + activeSigner && typeof activeSigner.nip04Decrypt === "function" + ? activeSigner.nip04Decrypt + : null; + + let extensionDecryptor = + typeof window?.nostr?.nip04?.decrypt === "function" + ? (pubkey, payload) => window.nostr.nip04.decrypt(pubkey, payload) + : null; + + if (!signerDecryptor && !extensionDecryptor) { + const permissionResult = await requestDefaultExtensionPermissions(); + if (!permissionResult.ok) { + userLogger.warn( + "[UserBlockList] Unable to load block list via extension decryptor; permissions denied.", + permissionResult.error, + ); + this.reset(); + this.loaded = true; + const error = + permissionResult.error instanceof Error + ? permissionResult.error + : new Error( + "Extension permissions are required to use the browser decryptor.", + ); + emitStatus({ status: "error", error, decryptor: "extension" }); + emitStatus({ status: "settled" }); + return; + } + + extensionDecryptor = + typeof window?.nostr?.nip04?.decrypt === "function" + ? (pubkey, payload) => window.nostr.nip04.decrypt(pubkey, payload) + : null; } - if (!window?.nostr?.nip04?.decrypt) { + if (!signerDecryptor && !extensionDecryptor) { userLogger.warn( - "[UserBlockList] nip04.decrypt is unavailable; treating block list as empty." + "[UserBlockList] No nip04 decryptor available; treating block list as empty.", ); this.reset(); this.loaded = true; - emitStatus({ - status: "error", - error: new Error("nip04.decrypt is unavailable"), - }); + const error = new Error( + "No NIP-04 decryptor is available to load the block list.", + ); + emitStatus({ status: "error", error, decryptor: "unavailable" }); emitStatus({ status: "settled" }); return; } @@ -549,28 +569,39 @@ class UserBlockListManager { return; } - const activeSigner = getActiveSigner(); - const decryptWithActiveSigner = - activeSigner && typeof activeSigner.nip04Decrypt === "function"; - let decrypted = ""; + const decryptPath = signerDecryptor + ? "active-signer" + : extensionDecryptor + ? "extension" + : "unavailable"; try { - if (decryptWithActiveSigner) { - decrypted = await activeSigner.nip04Decrypt(normalized, newest.content); - } else if (window?.nostr?.nip04?.decrypt) { - decrypted = await window.nostr.nip04.decrypt(normalized, newest.content); + if (signerDecryptor) { + decrypted = await signerDecryptor(normalized, newest.content); + } else if (extensionDecryptor) { + decrypted = await extensionDecryptor(normalized, newest.content); } else { throw new Error("nip04-unavailable"); } } catch (err) { - userLogger.error("[UserBlockList] Failed to decrypt block list:", err); + userLogger.error( + `[UserBlockList] Failed to decrypt block list via ${decryptPath}:`, + err, + ); applyBlockedPubkeys([], { source, reason: "decrypt-error", event: newest, error: err, + decryptor: decryptPath, + }); + emitStatus({ + status: "error", + event: newest, + error: err, + source, + decryptor: decryptPath, }); - emitStatus({ status: "error", event: newest, error: err, source }); return; } diff --git a/tests/user-blocks.test.mjs b/tests/user-blocks.test.mjs index 65ddefbd..bca89d04 100644 --- a/tests/user-blocks.test.mjs +++ b/tests/user-blocks.test.mjs @@ -2,7 +2,12 @@ import "./test-helpers/setup-localstorage.mjs"; import assert from "node:assert/strict"; const { userBlocks, USER_BLOCK_EVENTS } = await import("../js/userBlocks.js"); -const { nostrClient } = await import("../js/nostr.js"); +const { + nostrClient, + setActiveSigner, + clearActiveSigner, + getActiveSigner, +} = await import("../js/nostr.js"); if (typeof globalThis.window === "undefined") { globalThis.window = {}; @@ -436,3 +441,161 @@ await (async () => { localStorage.clear(); } })(); + +await (async () => { + const actor = "4".repeat(64); + const target = "5".repeat(64); + + const originalBlocked = new Set(userBlocks.blockedPubkeys); + const originalBlockEventId = userBlocks.blockEventId; + const originalBlockEventCreatedAt = userBlocks.blockEventCreatedAt; + const originalLastPublishedCreatedAt = userBlocks.lastPublishedCreatedAt; + const originalLoaded = userBlocks.loaded; + const originalPool = nostrClient.pool; + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalWriteRelays = Array.isArray(nostrClient.writeRelays) + ? [...nostrClient.writeRelays] + : nostrClient.writeRelays; + const originalEnsurePermissions = nostrClient.ensureExtensionPermissions; + const originalSignerState = getActiveSigner(); + clearActiveSigner(); + const originalNostr = window.nostr; + + let permissionRequests = 0; + nostrClient.ensureExtensionPermissions = async () => { + permissionRequests += 1; + return { ok: true }; + }; + + let eventCounter = 0; + let latestCiphertext = "cipher:[]"; + let latestEventId = "event-initial"; + let latestCreatedAt = 1_000; + + const decryptPayloads = new Map([ + [latestCiphertext, JSON.stringify({ blockedPubkeys: [] })], + ]); + + nostrClient.relays = ["wss://direct-signer.example"]; + nostrClient.writeRelays = nostrClient.relays; + nostrClient.pool = { + publish: (_targets, event) => { + latestCiphertext = event.content; + latestEventId = event.id || `event-${Date.now()}`; + latestCreatedAt = event.created_at || Math.floor(Date.now() / 1000); + return { + on(eventName, handler) { + if (eventName === "ok") { + setImmediate(handler); + return true; + } + return false; + }, + }; + }, + list: async () => [ + { + id: latestEventId, + created_at: latestCreatedAt, + pubkey: actor, + content: latestCiphertext, + }, + ], + }; + + setActiveSigner({ + type: "private-key", + pubkey: actor, + nip04Decrypt: async (pubkey, ciphertext) => { + assert.equal(pubkey, actor, "decrypt should target the actor pubkey"); + if (!decryptPayloads.has(ciphertext)) { + throw new Error(`unexpected ciphertext ${ciphertext}`); + } + return decryptPayloads.get(ciphertext); + }, + nip04Encrypt: async (pubkey, plaintext) => { + assert.equal(pubkey, actor, "encrypt should target the actor pubkey"); + let parsed; + try { + parsed = JSON.parse(plaintext); + } catch (error) { + throw new Error(`unexpected payload ${plaintext}`); + } + const blocked = Array.isArray(parsed?.blockedPubkeys) + ? [...parsed.blockedPubkeys].sort() + : []; + const cipher = `cipher:${JSON.stringify(blocked)}`; + decryptPayloads.set(cipher, plaintext); + return cipher; + }, + signEvent: async (event) => { + eventCounter += 1; + return { + ...event, + id: `event-${eventCounter}`, + created_at: event.created_at ?? Math.floor(Date.now() / 1000), + }; + }, + }); + + userBlocks.blockedPubkeys = new Set(); + userBlocks.blockEventId = null; + userBlocks.blockEventCreatedAt = null; + userBlocks.lastPublishedCreatedAt = null; + userBlocks.loaded = false; + + try { + window.nostr = undefined; + + await userBlocks.loadBlocks(actor); + assert.deepEqual( + userBlocks.getBlockedPubkeys(), + [], + "direct signer should load block list without extension decryptor", + ); + assert.equal( + permissionRequests, + 0, + "loadBlocks should not request extension permissions when signer decryptor exists", + ); + + const blockResult = await userBlocks.addBlock(target, actor); + assert.equal(blockResult?.ok, true, "addBlock should succeed with direct signer"); + assert(userBlocks.blockedPubkeys.has(target), "target should be in the block list"); + + const unblockResult = await userBlocks.removeBlock(target, actor); + assert.equal( + unblockResult?.ok, + true, + "removeBlock should succeed with direct signer", + ); + assert.equal( + userBlocks.blockedPubkeys.has(target), + false, + "target should be removed from the block list", + ); + + assert.equal( + permissionRequests, + 0, + "direct signer flows should never trigger extension permission prompts", + ); + } finally { + userBlocks.blockedPubkeys = originalBlocked; + userBlocks.blockEventId = originalBlockEventId; + userBlocks.blockEventCreatedAt = originalBlockEventCreatedAt; + userBlocks.lastPublishedCreatedAt = originalLastPublishedCreatedAt; + userBlocks.loaded = originalLoaded; + nostrClient.pool = originalPool; + nostrClient.relays = originalRelays; + nostrClient.writeRelays = originalWriteRelays; + nostrClient.ensureExtensionPermissions = originalEnsurePermissions; + window.nostr = originalNostr; + clearActiveSigner(); + if (originalSignerState) { + setActiveSigner(originalSignerState); + } + } +})();