From d7d12c3b744b48016578730014c7fd63f105eb53 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 21:56:18 -0400 Subject: [PATCH] Support nip44 subscription list decryption --- js/subscriptions.js | 139 +++++++++++++++++++++++++-- tests/subscriptions-manager.test.mjs | 85 ++++++++++++++++ 2 files changed, 217 insertions(+), 7 deletions(-) diff --git a/js/subscriptions.js b/js/subscriptions.js index 0ac83a2b..7d2f092c 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -28,6 +28,70 @@ function normalizeHexPubkey(value) { return trimmed ? trimmed.toLowerCase() : ""; } +function extractEncryptionHints(event) { + if (!event || typeof event !== "object") { + return []; + } + + const tags = Array.isArray(event.tags) ? event.tags : []; + const hints = []; + + const pushUnique = (scheme) => { + if (!scheme || hints.includes(scheme)) { + return; + } + hints.push(scheme); + }; + + for (const tag of tags) { + if (!Array.isArray(tag) || tag.length < 2) { + continue; + } + const label = typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : ""; + if (label !== "encrypted" && label !== "encryption") { + continue; + } + const rawValue = typeof tag[1] === "string" ? tag[1] : ""; + if (!rawValue) { + continue; + } + const parts = rawValue + .split(/[\s,]+/) + .map((part) => part.trim().toLowerCase()) + .filter(Boolean); + for (const part of parts) { + if (part.startsWith("nip44")) { + pushUnique("nip44"); + } else if (part === "nip04" || part === "nip-04") { + pushUnique("nip04"); + } + } + } + + return hints; +} + +function determineDecryptionOrder(event, availableSchemes) { + const available = Array.isArray(availableSchemes) ? availableSchemes : []; + const availableSet = new Set(available); + const prioritized = []; + + const hints = extractEncryptionHints(event); + for (const hint of hints) { + if (availableSet.has(hint) && !prioritized.includes(hint)) { + prioritized.push(hint); + } + } + + for (const fallback of ["nip04", "nip44"]) { + if (availableSet.has(fallback) && !prioritized.includes(fallback)) { + prioritized.push(fallback); + } + } + + return prioritized.length ? prioritized : available; +} + const getApp = () => getApplication(); /** @@ -112,20 +176,20 @@ class SubscriptionsManager { return; } - let decryptedStr = ""; - try { - decryptedStr = await window.nostr.nip04.decrypt( - userPubkey, - newest.content + const decryptResult = await this.decryptSubscriptionEvent(newest, userPubkey); + if (!decryptResult.ok) { + userLogger.error( + "[SubscriptionsManager] Failed to decrypt subscription list:", + decryptResult.error, ); - } catch (errDecrypt) { - userLogger.error("[SubscriptionsManager] Decryption failed:", errDecrypt); this.subscribedPubkeys.clear(); this.subsEventId = null; this.loaded = true; return; } + const decryptedStr = decryptResult.plaintext; + const parsed = JSON.parse(decryptedStr); const subArray = Array.isArray(parsed.subPubkeys) ? parsed.subPubkeys @@ -153,6 +217,67 @@ class SubscriptionsManager { return Array.from(this.subscribedPubkeys); } + async decryptSubscriptionEvent(event, userPubkey) { + const ciphertext = typeof event?.content === "string" ? event.content : ""; + if (!ciphertext) { + const error = new Error("Subscription event is missing ciphertext content."); + error.code = "subscriptions-empty-ciphertext"; + return { ok: false, error }; + } + + const nostrApi = + typeof window !== "undefined" && window && window.nostr ? window.nostr : null; + if (!nostrApi) { + const error = new Error("Nostr extension is unavailable for decrypting subscriptions."); + error.code = "nostr-extension-missing"; + return { ok: false, error }; + } + + const decryptors = new Map(); + if (nostrApi.nip04 && typeof nostrApi.nip04.decrypt === "function") { + decryptors.set("nip04", (payload) => nostrApi.nip04.decrypt(userPubkey, payload)); + } + if (nostrApi.nip44 && typeof nostrApi.nip44.decrypt === "function") { + decryptors.set("nip44", (payload) => nostrApi.nip44.decrypt(userPubkey, payload)); + } + + if (!decryptors.size) { + const error = new Error("No compatible decryption helpers are available."); + error.code = "subscriptions-no-decryptors"; + return { ok: false, error }; + } + + const availableSchemes = Array.from(decryptors.keys()); + const order = determineDecryptionOrder(event, availableSchemes); + const attemptErrors = []; + + for (const scheme of order) { + const decryptFn = decryptors.get(scheme); + if (!decryptFn) { + continue; + } + try { + const plaintext = await decryptFn(ciphertext); + if (typeof plaintext !== "string") { + const error = new Error("Decryption returned a non-string payload."); + error.code = "subscriptions-invalid-plaintext"; + attemptErrors.push({ scheme, error }); + continue; + } + return { ok: true, plaintext, scheme }; + } catch (error) { + attemptErrors.push({ scheme, error }); + } + } + + const error = new Error("Failed to decrypt subscription list with available schemes."); + error.code = "subscriptions-decrypt-failed"; + if (attemptErrors.length) { + error.cause = attemptErrors; + } + return { ok: false, error, errors: attemptErrors }; + } + async addChannel(channelHex, userPubkey) { const normalizedChannel = normalizeHexPubkey(channelHex); if (!userPubkey) { diff --git a/tests/subscriptions-manager.test.mjs b/tests/subscriptions-manager.test.mjs index 5de69640..1751f1d3 100644 --- a/tests/subscriptions-manager.test.mjs +++ b/tests/subscriptions-manager.test.mjs @@ -125,6 +125,91 @@ test("loadSubscriptions aggregates relay results when one rejects", async () => } }); +test("loadSubscriptions falls back to nip44 when hinted", async () => { + const SubscriptionsManager = subscriptions.constructor; + const manager = new SubscriptionsManager(); + + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalPool = nostrClient.pool; + const originalEnsurePermissions = nostrClient.ensureExtensionPermissions; + + const hadWindow = typeof globalThis.window !== "undefined"; + if (!hadWindow) { + globalThis.window = {}; + } + const originalWindowNostr = globalThis.window.nostr; + + const relayUrls = ["wss://relay-nip44.example"]; + nostrClient.relays = relayUrls; + + const event = { + id: "event-nip44", + created_at: 400, + content: "cipher-nip44", + tags: [["encrypted", "nip44_v2"]], + }; + + nostrClient.pool = { + list() { + return Promise.resolve([event]); + }, + }; + + nostrClient.ensureExtensionPermissions = async () => ({ ok: true }); + + const decryptCalls = { nip04: 0, nip44: 0 }; + globalThis.window.nostr = { + nip04: { + async decrypt() { + decryptCalls.nip04 += 1; + throw new Error("nip04 unavailable"); + }, + }, + nip44: { + async decrypt(_pubkey, ciphertext) { + decryptCalls.nip44 += 1; + return JSON.stringify({ subPubkeys: ["pub-nip44"], hint: ciphertext }); + }, + }, + }; + + try { + await manager.loadSubscriptions("user-pubkey-123"); + + assert.deepEqual( + Array.from(manager.subscribedPubkeys), + ["pub-nip44"], + "nip44 decrypted subscriptions should populate the set", + ); + assert.equal(manager.subsEventId, "event-nip44"); + assert.equal(manager.loaded, true); + assert.equal( + decryptCalls.nip44, + 1, + "nip44 decrypt should be invoked once when hinted", + ); + assert.equal( + decryptCalls.nip04, + 0, + "nip04 decrypt should be skipped when nip44 hint is present", + ); + } finally { + nostrClient.relays = originalRelays; + nostrClient.pool = originalPool; + nostrClient.ensureExtensionPermissions = originalEnsurePermissions; + if (typeof originalWindowNostr === "undefined") { + delete globalThis.window.nostr; + } else { + globalThis.window.nostr = originalWindowNostr; + } + if (!hadWindow) { + delete globalThis.window; + } + } +}); + test( "showSubscriptionVideos waits for nostrService warm-up and refreshes after updates", async () => {