diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 3ccfaba9..9f46e137 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -1361,6 +1361,7 @@ export function buildSubscriptionListEvent({ pubkey, created_at, content, + encryption, }) { const schema = getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST); const tags = []; @@ -1369,6 +1370,9 @@ export function buildSubscriptionListEvent({ if (identifierName && identifierValue) { tags.push([identifierName, identifierValue]); } + if (typeof encryption === "string" && encryption) { + tags.push(["encrypted", encryption]); + } appendSchemaTags(tags, schema); return { kind: schema?.kind ?? 30002, @@ -1383,6 +1387,7 @@ export function buildBlockListEvent({ pubkey, created_at, content, + encryption, }) { const schema = getNostrEventSchema(NOTE_TYPES.USER_BLOCK_LIST); const tags = []; @@ -1391,6 +1396,9 @@ export function buildBlockListEvent({ if (identifierName && identifierValue) { tags.push([identifierName, identifierValue]); } + if (typeof encryption === "string" && encryption) { + tags.push(["encrypted", encryption]); + } appendSchemaTags(tags, schema); return { kind: schema?.kind ?? 30002, diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index ee474b3d..6cf488d3 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -102,6 +102,16 @@ function normalizeTag(input) { return trimmed.toLowerCase(); } +function normalizeEncryptionToken(value) { + if (typeof value !== "string") { + return ""; + } + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]/g, ""); +} + function extractEncryptionHints(event) { if (!event || typeof event !== "object") { return []; @@ -131,16 +141,18 @@ function extractEncryptionHints(event) { } const parts = rawValue .split(/[\s,]+/) - .map((part) => part.trim().toLowerCase()) + .map((part) => normalizeEncryptionToken(part)) .filter(Boolean); for (const part of parts) { - if (part.startsWith("nip44")) { - if (/^nip44(?:[_-]?v2|v2)/.test(part)) { - pushUnique("nip44_v2"); - } else { - pushUnique("nip44"); - } - } else if (part === "nip04" || part === "nip-04") { + if (part === "nip44v2" || part === "nip44v02") { + pushUnique("nip44_v2"); + continue; + } + if (part === "nip44") { + pushUnique("nip44"); + continue; + } + if (part === "nip04" || part === "nip4") { pushUnique("nip04"); } } @@ -171,7 +183,7 @@ function determineDecryptionOrder(event, availableSchemes) { } } - for (const fallback of ["nip04", "nip44", "nip44_v2"]) { + for (const fallback of ["nip44_v2", "nip44", "nip04"]) { if (availableSet.has(fallback) && !prioritized.includes(fallback)) { prioritized.push(fallback); } diff --git a/js/subscriptions.js b/js/subscriptions.js index 92a96f92..1352c4a0 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -36,6 +36,16 @@ function normalizeHexPubkey(value) { return trimmed ? trimmed.toLowerCase() : ""; } +function normalizeEncryptionToken(value) { + if (typeof value !== "string") { + return ""; + } + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]/g, ""); +} + function extractEncryptionHints(event) { if (!event || typeof event !== "object") { return []; @@ -65,16 +75,18 @@ function extractEncryptionHints(event) { } const parts = rawValue .split(/[\s,]+/) - .map((part) => part.trim().toLowerCase()) + .map((part) => normalizeEncryptionToken(part)) .filter(Boolean); for (const part of parts) { - if (part.startsWith("nip44")) { - if (/^nip44(?:[_-]?v2|v2)/.test(part)) { - pushUnique("nip44_v2"); - } else { - pushUnique("nip44"); - } - } else if (part === "nip04" || part === "nip-04") { + if (part === "nip44v2" || part === "nip44v02") { + pushUnique("nip44_v2"); + continue; + } + if (part === "nip44") { + pushUnique("nip44"); + continue; + } + if (part === "nip04" || part === "nip4") { pushUnique("nip04"); } } @@ -105,7 +117,7 @@ function determineDecryptionOrder(event, availableSchemes) { } } - for (const fallback of ["nip04", "nip44", "nip44_v2"]) { + for (const fallback of ["nip44_v2", "nip44", "nip04"]) { if (availableSet.has(fallback) && !prioritized.includes(fallback)) { prioritized.push(fallback); } @@ -516,12 +528,6 @@ class SubscriptionsManager { } } - if (typeof signer.nip04Encrypt !== "function") { - const error = new Error("NIP-04 encryption is required to update subscriptions."); - error.code = "nip04-missing"; - throw error; - } - if (typeof signer.signEvent !== "function") { const error = new Error("Active signer missing signEvent support."); error.code = "sign-event-missing"; @@ -531,8 +537,33 @@ class SubscriptionsManager { const plainObj = { subPubkeys: Array.from(this.subscribedPubkeys) }; const plainStr = JSON.stringify(plainObj); + const encryptors = []; + const registerEncryptor = (scheme, handler) => { + if (!scheme || typeof handler !== "function") { + return; + } + encryptors.push({ scheme, handler }); + }; + + if (typeof signer.nip44Encrypt === "function") { + registerEncryptor("nip44_v2", (value) => signer.nip44Encrypt(userPubkey, value)); + registerEncryptor("nip44", (value) => signer.nip44Encrypt(userPubkey, value)); + } + + if (typeof signer.nip04Encrypt === "function") { + registerEncryptor("nip04", (value) => signer.nip04Encrypt(userPubkey, value)); + } + + if (!encryptors.length) { + const error = new Error( + "An encryption-capable signer is required to update subscriptions.", + ); + error.code = "subscriptions-missing-encryptor"; + throw error; + } + /* - * The subscription list is stored as a NIP-04 message to self, so both + * The subscription list is stored as an encrypted message to self, so both * encryption and decryption intentionally use the user's own pubkey. * Extensions are expected to support this encrypt-to-self flow; altering * the target would break loadSubscriptions, which decrypts with the same @@ -540,17 +571,46 @@ class SubscriptionsManager { * need a parallel read path and should not overwrite this behavior. */ let cipherText = ""; - try { - cipherText = await signer.nip04Encrypt(userPubkey, plainStr); - } catch (err) { - userLogger.error("Encryption failed:", err); - throw err; + let encryptionScheme = ""; + const seenSchemes = new Set(); + const encryptionErrors = []; + + for (const candidate of encryptors) { + if (seenSchemes.has(candidate.scheme)) { + continue; + } + seenSchemes.add(candidate.scheme); + try { + const encrypted = await candidate.handler(plainStr); + if (typeof encrypted === "string" && encrypted) { + cipherText = encrypted; + encryptionScheme = candidate.scheme; + break; + } + } catch (error) { + encryptionErrors.push({ scheme: candidate.scheme, error }); + } } + if (!cipherText) { + const error = new Error("Failed to encrypt subscription list payload."); + error.code = "subscriptions-encrypt-failed"; + error.cause = encryptionErrors; + throw error; + } + + const encryptionTagValue = + encryptionScheme === "nip44_v2" + ? "nip44_v2" + : encryptionScheme === "nip44" + ? "nip44" + : undefined; + const evt = buildSubscriptionListEvent({ pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), - content: cipherText + content: cipherText, + encryption: encryptionTagValue, }); let signedEvent; diff --git a/js/userBlocks.js b/js/userBlocks.js index 6dbb0ab1..fb95fc14 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -68,6 +68,97 @@ const BLOCKLIST_STORAGE_PREFIX = "bitvid:user-blocks"; const BLOCKLIST_SEEDED_KEY_PREFIX = `${BLOCKLIST_STORAGE_PREFIX}:seeded:v1`; const BLOCKLIST_REMOVALS_KEY_PREFIX = `${BLOCKLIST_STORAGE_PREFIX}:removals:v1`; +function normalizeEncryptionToken(value) { + if (typeof value !== "string") { + return ""; + } + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]/g, ""); +} + +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 name = typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : ""; + if (name !== "encrypted" && name !== "encryption") { + continue; + } + const rawValue = typeof tag[1] === "string" ? tag[1] : ""; + if (!rawValue) { + continue; + } + const parts = rawValue + .split(/[\s,]+/) + .map((part) => normalizeEncryptionToken(part)) + .filter(Boolean); + + for (const part of parts) { + if (part === "nip44v2" || part === "nip44v02") { + pushUnique("nip44_v2"); + continue; + } + if (part === "nip44") { + pushUnique("nip44"); + continue; + } + if (part === "nip04" || part === "nip4") { + 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); + const aliasMap = { + nip04: ["nip04"], + nip44: ["nip44", "nip44_v2"], + nip44_v2: ["nip44_v2", "nip44"], + }; + + for (const hint of hints) { + const candidates = Array.isArray(aliasMap[hint]) ? aliasMap[hint] : [hint]; + for (const candidate of candidates) { + if (availableSet.has(candidate) && !prioritized.includes(candidate)) { + prioritized.push(candidate); + break; + } + } + } + + for (const fallback of ["nip44_v2", "nip44", "nip04"]) { + if (availableSet.has(fallback) && !prioritized.includes(fallback)) { + prioritized.push(fallback); + } + } + + return prioritized.length ? prioritized : available; +} + function decodeNpubToHex(npub) { if (typeof npub !== "string") { return null; @@ -343,56 +434,109 @@ class UserBlockListManager { }); }; - const activeSigner = getActiveSigner(); - const signerDecryptor = - activeSigner && typeof activeSigner.nip04Decrypt === "function" - ? activeSigner.nip04Decrypt - : null; + const resolveDecryptors = async (event) => { + const signer = getActiveSigner(); + const signerHasNip04 = typeof signer?.nip04Decrypt === "function"; + const signerHasNip44 = typeof signer?.nip44Decrypt === "function"; - let extensionDecryptor = - typeof window?.nostr?.nip04?.decrypt === "function" - ? (pubkey, payload) => window.nostr.nip04.decrypt(pubkey, payload) - : null; + const hints = extractEncryptionHints(event); + const requiresNip44 = hints.includes("nip44") || hints.includes("nip44_v2"); + const requiresNip04 = !hints.length || hints.includes("nip04"); - 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; + let permissionError = null; + if ((!signerHasNip44 && requiresNip44) || (!signerHasNip04 && requiresNip04)) { + try { + const permissionResult = await requestDefaultExtensionPermissions(); + if (!permissionResult?.ok) { + permissionError = + permissionResult?.error instanceof Error + ? permissionResult.error + : new Error( + "Extension permissions are required to use the browser decryptor.", + ); + } + } catch (error) { + permissionError = error instanceof Error ? error : new Error(String(error)); + } } - extensionDecryptor = - typeof window?.nostr?.nip04?.decrypt === "function" - ? (pubkey, payload) => window.nostr.nip04.decrypt(pubkey, payload) - : null; - } + const decryptors = new Map(); + const sources = new Map(); + const registerDecryptor = (scheme, handler, source) => { + if (!scheme || typeof handler !== "function" || decryptors.has(scheme)) { + return; + } + decryptors.set(scheme, handler); + sources.set(scheme, source || "unknown"); + }; - if (!signerDecryptor && !extensionDecryptor) { - userLogger.warn( - "[UserBlockList] No nip04 decryptor available; treating block list as empty.", - ); - this.reset(); - this.loaded = true; - 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; - } + if (signerHasNip44) { + registerDecryptor( + "nip44", + (payload) => signer.nip44Decrypt(normalized, payload), + "active-signer", + ); + registerDecryptor( + "nip44_v2", + (payload) => signer.nip44Decrypt(normalized, payload), + "active-signer", + ); + } + + if (signerHasNip04) { + registerDecryptor( + "nip04", + (payload) => signer.nip04Decrypt(normalized, payload), + "active-signer", + ); + } + + const nostrApi = typeof window !== "undefined" ? window?.nostr : null; + if (nostrApi) { + if (typeof nostrApi.nip04?.decrypt === "function") { + registerDecryptor( + "nip04", + (payload) => nostrApi.nip04.decrypt(normalized, payload), + "extension", + ); + } + + const nip44 = + nostrApi.nip44 && typeof nostrApi.nip44 === "object" ? nostrApi.nip44 : null; + if (nip44) { + if (typeof nip44.decrypt === "function") { + registerDecryptor( + "nip44", + (payload) => nip44.decrypt(normalized, payload), + "extension", + ); + } + + const nip44v2 = nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null; + if (nip44v2 && typeof nip44v2.decrypt === "function") { + registerDecryptor( + "nip44_v2", + (payload) => nip44v2.decrypt(normalized, payload), + "extension", + ); + if (!decryptors.has("nip44")) { + registerDecryptor( + "nip44", + (payload) => nip44v2.decrypt(normalized, payload), + "extension", + ); + } + } + } + } + + return { + decryptors, + sources, + permissionError, + order: determineDecryptionOrder(event, Array.from(decryptors.keys())), + }; + }; try { const filter = { @@ -573,36 +717,92 @@ class UserBlockListManager { return; } - let decrypted = ""; - const decryptPath = signerDecryptor - ? "active-signer" - : extensionDecryptor - ? "extension" - : "unavailable"; - try { - if (signerDecryptor) { - decrypted = await signerDecryptor(normalized, newest.content); - } else if (extensionDecryptor) { - decrypted = await extensionDecryptor(normalized, newest.content); - } else { - throw new Error("nip04-unavailable"); + const { + decryptors, + order, + sources, + permissionError, + } = await resolveDecryptors(newest); + + if (!decryptors.size) { + if (permissionError) { + userLogger.warn( + "[UserBlockList] Unable to load block list via extension decryptor; permissions denied.", + permissionError, + ); + this.reset(); + this.loaded = true; + emitStatus({ status: "error", error: permissionError, decryptor: "extension" }); + emitStatus({ status: "settled" }); + return; } - } catch (err) { + + userLogger.warn( + "[UserBlockList] No decryptors available; treating block list as empty.", + ); + this.reset(); + this.loaded = true; + const error = new Error( + "No decryptors are available to load the block list.", + ); + emitStatus({ status: "error", error, decryptor: "unavailable" }); + emitStatus({ status: "settled" }); + return; + } + + let decrypted = ""; + let schemeUsed = ""; + let schemeSource = "unavailable"; + const attemptErrors = []; + + for (const scheme of order) { + const handler = decryptors.get(scheme); + if (typeof handler !== "function") { + continue; + } + const sourceLabel = sources.get(scheme) || "unknown"; + try { + const plaintext = await handler(newest.content); + if (typeof plaintext !== "string") { + throw new Error("Decryption returned a non-string payload."); + } + decrypted = plaintext; + schemeUsed = scheme; + schemeSource = sourceLabel; + break; + } catch (error) { + attemptErrors.push({ scheme, source: sourceLabel, error }); + } + } + + if (!decrypted) { + const error = new Error( + "Failed to decrypt block list with available schemes.", + ); + error.code = "block-list-decrypt-failed"; + if (attemptErrors.length) { + error.cause = attemptErrors; + } + const lastAttempt = attemptErrors.length + ? attemptErrors[attemptErrors.length - 1] + : null; + const decryptPath = lastAttempt?.source || "unavailable"; userLogger.error( - `[UserBlockList] Failed to decrypt block list via ${decryptPath}:`, - err, + `[UserBlockList] Failed to decrypt block list via available schemes (last: ${decryptPath}).`, + error, ); applyBlockedPubkeys([], { source, reason: "decrypt-error", event: newest, - error: err, + error, decryptor: decryptPath, + errors: attemptErrors, }); emitStatus({ status: "error", event: newest, - error: err, + error, source, decryptor: decryptPath, }); @@ -629,12 +829,16 @@ class UserBlockListManager { source, reason: "applied", event: newest, + scheme: schemeUsed, + decryptor: schemeSource, }); emitStatus({ status: "applied", event: newest, blockedPubkeys: Array.from(this.blockedPubkeys), source, + scheme: schemeUsed, + decryptor: schemeSource, }); } catch (err) { userLogger.error("[UserBlockList] Failed to parse block list:", err); @@ -643,8 +847,17 @@ class UserBlockListManager { reason: "parse-error", event: newest, error: err, + decryptor: schemeSource, + scheme: schemeUsed, + }); + emitStatus({ + status: "error", + event: newest, + error: err, + source, + decryptor: schemeSource, + scheme: schemeUsed, }); - emitStatus({ status: "error", event: newest, error: err, source }); } }; @@ -1107,14 +1320,6 @@ class UserBlockListManager { } } - if (typeof signer.nip04Encrypt !== "function") { - const err = new Error( - "NIP-04 encryption is required to update the block list." - ); - err.code = "nip04-missing"; - throw err; - } - if (typeof signer.signEvent !== "function") { const err = new Error("Active signer missing signEvent support."); err.code = "sign-event-missing"; @@ -1133,19 +1338,72 @@ class UserBlockListManager { }; const plaintext = JSON.stringify(payload); - let cipherText = ""; - try { - cipherText = await signer.nip04Encrypt(normalized, plaintext); - } catch (error) { - const err = new Error("Failed to encrypt block list."); - err.code = "nip04-missing"; + const encryptors = []; + const registerEncryptor = (scheme, handler) => { + if (!scheme || typeof handler !== "function") { + return; + } + encryptors.push({ scheme, handler }); + }; + + if (typeof signer.nip44Encrypt === "function") { + registerEncryptor("nip44_v2", (value) => signer.nip44Encrypt(normalized, value)); + registerEncryptor("nip44", (value) => signer.nip44Encrypt(normalized, value)); + } + + if (typeof signer.nip04Encrypt === "function") { + registerEncryptor("nip04", (value) => signer.nip04Encrypt(normalized, value)); + } + + if (!encryptors.length) { + const err = new Error( + "An encryption-capable signer is required to update the block list." + ); + err.code = "block-list-missing-encryptor"; throw err; } + let cipherText = ""; + let encryptionScheme = ""; + const seenSchemes = new Set(); + const encryptionErrors = []; + + for (const candidate of encryptors) { + if (seenSchemes.has(candidate.scheme)) { + continue; + } + seenSchemes.add(candidate.scheme); + try { + const encrypted = await candidate.handler(plaintext); + if (typeof encrypted === "string" && encrypted) { + cipherText = encrypted; + encryptionScheme = candidate.scheme; + break; + } + } catch (error) { + encryptionErrors.push({ scheme: candidate.scheme, error }); + } + } + + if (!cipherText) { + const err = new Error("Failed to encrypt block list."); + err.code = "block-list-encrypt-failed"; + err.cause = encryptionErrors; + throw err; + } + + const encryptionTagValue = + encryptionScheme === "nip44_v2" + ? "nip44_v2" + : encryptionScheme === "nip44" + ? "nip44" + : undefined; + const event = buildBlockListEvent({ pubkey: normalized, created_at: Math.floor(Date.now() / 1000), content: cipherText, + encryption: encryptionTagValue, }); const signedEvent = await signer.signEvent(event); diff --git a/tests/subscriptions-manager.test.mjs b/tests/subscriptions-manager.test.mjs index 6a751196..441178c5 100644 --- a/tests/subscriptions-manager.test.mjs +++ b/tests/subscriptions-manager.test.mjs @@ -311,6 +311,88 @@ test("loadSubscriptions handles nip44.v2 decryptors", async () => { } }); +test("loadSubscriptions prefers nip44 decryptors when both are available", async () => { + const SubscriptionsManager = subscriptions.constructor; + const manager = new SubscriptionsManager(); + + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalWriteRelays = Array.isArray(nostrClient.writeRelays) + ? [...nostrClient.writeRelays] + : nostrClient.writeRelays; + 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-pref.example"]; + nostrClient.relays = relayUrls; + nostrClient.writeRelays = relayUrls; + + const event = { + id: "event-nip44-pref", + created_at: 700, + content: "cipher-nip44-pref", + 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; + return JSON.stringify({ subPubkeys: ["pub-nip04"] }); + }, + }, + nip44: { + v2: { + async decrypt(_pubkey, ciphertext) { + decryptCalls.nip44 += 1; + return JSON.stringify({ subPubkeys: ["pub-nip44-pref"], hint: ciphertext }); + }, + }, + }, + }; + + try { + await manager.loadSubscriptions("user-pubkey-123"); + + assert.deepEqual( + Array.from(manager.subscribedPubkeys), + ["pub-nip44-pref"], + "nip44 decrypted subscriptions should populate the set", + ); + assert.equal(decryptCalls.nip44, 1, "nip44 decryptor should be used"); + assert.equal(decryptCalls.nip04, 0, "nip04 decryptor should be skipped"); + } finally { + nostrClient.relays = originalRelays; + nostrClient.writeRelays = originalWriteRelays; + 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( "loadSubscriptions uses active signer decryptors without requesting extension permissions", async () => { @@ -651,6 +733,183 @@ test( }, ); +test("publishSubscriptionList prefers nip44 encryption when available", async () => { + const SubscriptionsManager = subscriptions.constructor; + const manager = new SubscriptionsManager(); + + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalWriteRelays = Array.isArray(nostrClient.writeRelays) + ? [...nostrClient.writeRelays] + : nostrClient.writeRelays; + const originalPool = nostrClient.pool; + const originalEnsurePermissions = nostrClient.ensureExtensionPermissions; + const originalSigner = getActiveSigner(); + + const relayUrls = ["wss://relay-prefer-nip44.example"]; + nostrClient.relays = relayUrls; + nostrClient.writeRelays = relayUrls; + + let permissionCalls = 0; + nostrClient.ensureExtensionPermissions = async () => { + permissionCalls += 1; + return { ok: true }; + }; + + const publishCalls = []; + nostrClient.pool = { + publish(urls, event) { + publishCalls.push({ urls, event }); + return { + on(eventName, handler) { + if (eventName === "ok") { + handler(); + } + return true; + }, + }; + }, + }; + + const encryptCalls = { nip44: 0, nip04: 0 }; + const signedEvents = []; + setActiveSigner({ + type: "nsec", + pubkey: "user-pubkey-123", + async nip44Encrypt(pubkey, plaintext) { + encryptCalls.nip44 += 1; + assert.equal(pubkey, "user-pubkey-123"); + assert.equal( + plaintext, + JSON.stringify({ subPubkeys: ["pub-one", "pub-two"] }), + ); + return "cipher-nip44"; + }, + async nip04Encrypt() { + encryptCalls.nip04 += 1; + throw new Error("nip04 should not be used when nip44 is available"); + }, + async signEvent(event) { + signedEvents.push(event); + return { ...event, id: "signed-nip44-event" }; + }, + }); + + manager.subscribedPubkeys = new Set(["pub-one", "pub-two"]); + + try { + await manager.publishSubscriptionList("user-pubkey-123"); + + assert.equal(permissionCalls, 0, "should not request extension permissions"); + assert.equal(encryptCalls.nip44, 1, "nip44Encrypt should be used once"); + assert.equal(encryptCalls.nip04, 0, "nip04Encrypt should not be invoked"); + assert.equal(signedEvents.length, 1, "signEvent should be called once"); + const signedEvent = signedEvents[0]; + const encryptedTags = signedEvent.tags.filter((tag) => tag[0] === "encrypted"); + assert.deepEqual( + encryptedTags, + [["encrypted", "nip44_v2"]], + "signed event should advertise nip44_v2 encryption", + ); + assert.equal(publishCalls.length, relayUrls.length, "event should publish to relays"); + assert.equal(manager.subsEventId, "signed-nip44-event"); + } finally { + nostrClient.relays = originalRelays; + nostrClient.writeRelays = originalWriteRelays; + nostrClient.pool = originalPool; + nostrClient.ensureExtensionPermissions = originalEnsurePermissions; + setActiveSigner(originalSigner); + } +}); + +test("publishSubscriptionList falls back to nip04 when nip44 fails", async () => { + const SubscriptionsManager = subscriptions.constructor; + const manager = new SubscriptionsManager(); + + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalWriteRelays = Array.isArray(nostrClient.writeRelays) + ? [...nostrClient.writeRelays] + : nostrClient.writeRelays; + const originalPool = nostrClient.pool; + const originalEnsurePermissions = nostrClient.ensureExtensionPermissions; + const originalSigner = getActiveSigner(); + + const relayUrls = ["wss://relay-fallback-nip04.example"]; + nostrClient.relays = relayUrls; + nostrClient.writeRelays = relayUrls; + + nostrClient.ensureExtensionPermissions = async () => ({ ok: true }); + + const publishCalls = []; + nostrClient.pool = { + publish(urls, event) { + publishCalls.push({ urls, event }); + return { + on(eventName, handler) { + if (eventName === "ok") { + handler(); + } + return true; + }, + }; + }, + }; + + const encryptCalls = { nip44: 0, nip04: 0 }; + const signedEvents = []; + setActiveSigner({ + type: "nsec", + pubkey: "user-pubkey-123", + async nip44Encrypt() { + encryptCalls.nip44 += 1; + throw new Error("simulated nip44 failure"); + }, + async nip04Encrypt(pubkey, plaintext) { + encryptCalls.nip04 += 1; + assert.equal(pubkey, "user-pubkey-123"); + assert.equal( + plaintext, + JSON.stringify({ subPubkeys: ["pub-three"] }), + ); + return "cipher-nip04"; + }, + async signEvent(event) { + signedEvents.push(event); + return { ...event, id: "signed-nip04-event" }; + }, + }); + + manager.subscribedPubkeys = new Set(["pub-three"]); + + try { + await manager.publishSubscriptionList("user-pubkey-123"); + + assert.ok( + encryptCalls.nip44 >= 1, + "nip44Encrypt should be attempted before falling back", + ); + assert.equal(encryptCalls.nip04, 1, "nip04Encrypt should handle fallback"); + const signedEvent = signedEvents[0]; + const encryptedTags = signedEvent.tags.filter((tag) => tag[0] === "encrypted"); + assert.equal( + encryptedTags.length, + 0, + "fallback nip04 encryption should not advertise a nip44 tag", + ); + assert.equal(publishCalls.length, relayUrls.length, "event should publish to relays"); + assert.equal(manager.subsEventId, "signed-nip04-event"); + } finally { + nostrClient.relays = originalRelays; + nostrClient.writeRelays = originalWriteRelays; + nostrClient.pool = originalPool; + nostrClient.ensureExtensionPermissions = originalEnsurePermissions; + setActiveSigner(originalSigner); + } +}); + test("renderSameGridStyle shows empty state message", async () => { const SubscriptionsManager = subscriptions.constructor; diff --git a/tests/user-blocks.test.mjs b/tests/user-blocks.test.mjs index 30f71fee..baeef990 100644 --- a/tests/user-blocks.test.mjs +++ b/tests/user-blocks.test.mjs @@ -633,3 +633,241 @@ await (async () => { } } })(); + +await (async () => { + const actor = "1".repeat(64); + const blocked = "2".repeat(64); + + const UserBlockListManager = userBlocks.constructor; + const manager = new UserBlockListManager(); + + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalPool = nostrClient.pool; + const originalEnsurePermissions = nostrClient.ensureExtensionPermissions; + const originalSigner = getActiveSigner(); + + const relayUrls = ["wss://relay-nip44-blocklist.example"]; + nostrClient.relays = relayUrls; + nostrClient.ensureExtensionPermissions = async () => ({ ok: true }); + + const publishCalls = []; + nostrClient.pool = { + publish(urls, event) { + publishCalls.push({ urls, event }); + return { + on(eventName, handler) { + if (eventName === "ok") { + handler(); + } + return true; + }, + }; + }, + }; + + const encryptCalls = { nip44: 0, nip04: 0 }; + const signedEvents = []; + setActiveSigner({ + type: "nsec", + pubkey: actor, + async nip44Encrypt(pubkey, plaintext) { + encryptCalls.nip44 += 1; + assert.equal(pubkey, actor); + assert.equal( + plaintext, + JSON.stringify({ blockedPubkeys: [blocked] }), + ); + return "cipher-nip44"; + }, + async nip04Encrypt() { + encryptCalls.nip04 += 1; + throw new Error("nip04 should not be used when nip44 is available"); + }, + async signEvent(event) { + signedEvents.push(event); + return { ...event, id: "signed-nip44-block-event" }; + }, + }); + + manager.blockedPubkeys = new Set([blocked]); + manager.publishMuteListSnapshot = async () => null; + manager.loadBlocks = async () => {}; + + try { + const result = await manager.publishBlockList(actor); + + assert.equal(result.id, "signed-nip44-block-event"); + assert.equal(encryptCalls.nip44, 1, "nip44Encrypt should be used once"); + assert.equal(encryptCalls.nip04, 0, "nip04Encrypt should not be invoked"); + assert.equal(signedEvents.length, 1, "signEvent should be called once"); + const encryptedTags = signedEvents[0].tags.filter((tag) => tag[0] === "encrypted"); + assert.deepEqual( + encryptedTags, + [["encrypted", "nip44_v2"]], + "block list event should advertise nip44_v2 encryption", + ); + assert.equal(publishCalls.length, relayUrls.length, "event should publish to relays"); + } finally { + nostrClient.relays = originalRelays; + nostrClient.pool = originalPool; + nostrClient.ensureExtensionPermissions = originalEnsurePermissions; + setActiveSigner(originalSigner); + } +})(); + +await (async () => { + const actor = "3".repeat(64); + const blocked = "4".repeat(64); + + const UserBlockListManager = userBlocks.constructor; + const manager = new UserBlockListManager(); + + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalPool = nostrClient.pool; + const originalEnsurePermissions = nostrClient.ensureExtensionPermissions; + const originalSigner = getActiveSigner(); + + nostrClient.relays = ["wss://relay-nip04-fallback.example"]; + nostrClient.ensureExtensionPermissions = async () => ({ ok: true }); + + const publishCalls = []; + nostrClient.pool = { + publish(urls, event) { + publishCalls.push({ urls, event }); + return { + on(eventName, handler) { + if (eventName === "ok") { + handler(); + } + return true; + }, + }; + }, + }; + + const encryptCalls = { nip44: 0, nip04: 0 }; + const signedEvents = []; + setActiveSigner({ + type: "nsec", + pubkey: actor, + async nip44Encrypt() { + encryptCalls.nip44 += 1; + throw new Error("simulated nip44 failure"); + }, + async nip04Encrypt(pubkey, plaintext) { + encryptCalls.nip04 += 1; + assert.equal(pubkey, actor); + assert.equal( + plaintext, + JSON.stringify({ blockedPubkeys: [blocked] }), + ); + return "cipher-nip04"; + }, + async signEvent(event) { + signedEvents.push(event); + return { ...event, id: "signed-nip04-block-event" }; + }, + }); + + manager.blockedPubkeys = new Set([blocked]); + manager.publishMuteListSnapshot = async () => null; + manager.loadBlocks = async () => {}; + + try { + const result = await manager.publishBlockList(actor); + + assert.equal(result.id, "signed-nip04-block-event"); + assert.ok( + encryptCalls.nip44 >= 1, + "nip44Encrypt should be attempted before falling back", + ); + assert.equal(encryptCalls.nip04, 1, "nip04Encrypt should handle fallback"); + const encryptedTags = signedEvents[0].tags.filter((tag) => tag[0] === "encrypted"); + assert.equal( + encryptedTags.length, + 0, + "fallback nip04 encryption should not advertise a nip44 tag", + ); + assert.equal(publishCalls.length, 1, "event should publish to the available relay"); + } finally { + nostrClient.relays = originalRelays; + nostrClient.pool = originalPool; + nostrClient.ensureExtensionPermissions = originalEnsurePermissions; + setActiveSigner(originalSigner); + } +})(); + +await (async () => { + const actor = "5".repeat(64); + const blocked = "6".repeat(64); + + const UserBlockListManager = userBlocks.constructor; + const manager = new UserBlockListManager(); + + const originalNostr = window.nostr; + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalPool = nostrClient.pool; + const originalEnsurePermissions = nostrClient.ensureExtensionPermissions; + const originalSigner = getActiveSigner(); + + clearActiveSigner(); + + const relayUrls = ["wss://relay-nip44-read.example"]; + nostrClient.relays = relayUrls; + nostrClient.ensureExtensionPermissions = async () => ({ ok: true }); + + const decryptCalls = { nip44: 0, nip04: 0 }; + window.nostr = { + nip04: { + async decrypt() { + decryptCalls.nip04 += 1; + return JSON.stringify({ blockedPubkeys: [blocked] }); + }, + }, + nip44: { + v2: { + async decrypt(_pubkey, ciphertext) { + decryptCalls.nip44 += 1; + return JSON.stringify({ blockedPubkeys: [blocked], hint: ciphertext }); + }, + }, + }, + }; + + nostrClient.pool = { + list() { + return Promise.resolve([ + { + id: "event-nip44-read", + created_at: 9_000, + pubkey: actor, + content: "cipher-nip44-read", + tags: [["encrypted", "nip44_v2"]], + }, + ]); + }, + }; + + try { + await manager.loadBlocks(actor); + + assert.equal(decryptCalls.nip44, 1, "nip44 decryptor should be preferred"); + assert.equal(decryptCalls.nip04, 0, "nip04 decryptor should not be used"); + } finally { + window.nostr = originalNostr; + nostrClient.relays = originalRelays; + nostrClient.pool = originalPool; + nostrClient.ensureExtensionPermissions = originalEnsurePermissions; + if (originalSigner) { + setActiveSigner(originalSigner); + } else { + clearActiveSigner(); + } + } +})();