From cd614876f5ef81657656c82802cd5992d35eb85a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:55:18 -0400 Subject: [PATCH] [watch-history] strictly format-route decrypt to halve NIP-46 RPCs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watch-history decryption previously ordered the matching cipher family first but kept the other family as a *fallback*. The opposite family can never decrypt a given ciphertext (NIP-04 always carries a literal "?iv=" marker; NIP-44 is plain base64), so that fallback was a guaranteed-wasted decrypt attempt. Under a NIP-46 remote signer every decrypt is a *published* relay RPC routed through the serial 250ms request queue, so a transient failure on the correct family triggered a second wasted RPC that burned the queue and pushed cold loads toward the relay rate limit ("noting too much") — the symptom users hit. determineWatchHistoryDecryptionOrder now filters the attempt set to the single family implied by the ciphertext shape (trusting the ciphertext over a possibly-misleading "encrypted" tag), with a safety fallback to the full ordered list when nothing matches so the only available decryptor is never stranded. This mirrors the list-decrypt routing already shipped for userBlocks / subscriptions / hashtags (slice A). Test: a NIP-44 ciphertext mislabeled with an ["encrypted","nip04"] tag, against a signer exposing both decryptors where nip04 returns decoy data. Asserts nip04 is never attempted and the nip44 plaintext is used. Mutation-verified: removing the filter fails the test. Co-Authored-By: Claude Opus 4.8 --- js/nostr/watchHistory.js | 34 +++++++++++++- tests/nostr/watchHistory.test.js | 78 ++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index 158bc890..e9bf1468 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -677,7 +677,39 @@ function determineWatchHistoryDecryptionOrder( } } - return prioritized.length ? prioritized : available; + const ordered = prioritized.length ? prioritized : available; + + // Strictly format-route by the ciphertext shape, which is authoritative. + // NIP-04 ciphertext always carries a literal "?iv=" marker; NIP-44 is plain + // base64. The opposite family can NEVER decrypt a given ciphertext, so trying + // it is a guaranteed-wasted decrypt — and under a NIP-46 remote signer each + // wasted decrypt is a *published* relay RPC that burns the serial request + // queue and risks the relay rate limit ("noting too much"). Dropping the + // non-matching family halves cold-load NIP-46 decrypt RPCs for watch history, + // mirroring the list-decrypt routing in userBlocks/subscriptions/hashtags. + // Trust the ciphertext over a possibly-misleading "encrypted" tag. + const normalizedCiphertext = + typeof ciphertext === "string" ? ciphertext.trim() : ""; + if (normalizedCiphertext) { + const formatFamily = normalizedCiphertext.includes("?iv=") + ? "nip04" + : "nip44"; + const matching = ordered.filter( + (scheme) => watchHistorySchemeFamily(scheme) === formatFamily, + ); + // Safety: never strand the only available decryptor. If nothing matches the + // detected format (e.g. a contrived/legacy payload), fall back to the full + // ordered list rather than returning an empty attempt set. + if (matching.length) { + return matching; + } + } + + return ordered; +} + +function watchHistorySchemeFamily(scheme) { + return scheme === "nip04" ? "nip04" : "nip44"; } async function resolveNostrToolkit(deps = {}, cacheRef = null) { diff --git a/tests/nostr/watchHistory.test.js b/tests/nostr/watchHistory.test.js index b5e90fc8..2d15affe 100644 --- a/tests/nostr/watchHistory.test.js +++ b/tests/nostr/watchHistory.test.js @@ -372,6 +372,84 @@ test("fetchWatchHistory decrypts encrypted pointer events with nip44 signer supp } }); +test("fetchWatchHistory routes decryption by ciphertext format and never tries the non-matching family (ignores a misleading encrypted tag)", async () => { + // A NIP-44 payload is plain base64 with NO "?iv=" marker. NIP-04 ciphertext + // always carries "?iv=". Here the event is *mislabeled* with an + // ["encrypted","nip04"] tag, but the ciphertext shape is authoritative. + // Under a NIP-46 remote signer, every decrypt attempt is a published relay + // RPC, so attempting the impossible nip04 family would be a wasted RPC that + // burns the serial queue / risks the rate limit. The decryptor must route by + // format and attempt ONLY nip44 — never nip04. + const actorHex = "6".repeat(64); + const ciphertext = "bmlwNDQtbWlzbGFiZWxlZC1wYXlsb2Fk"; // base64, no "?iv=" + let nip04Calls = 0; + let nip44Calls = 0; + + const pointerEvent = { + id: "pointer-mislabeled", + pubkey: actorHex, + created_at: 1_700_300_000, + content: ciphertext, + tags: [ + ["encrypted", "nip04"], // deliberately misleading + ["a", "30078:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:fallback"], + ["d", "watch-history-month"], + ], + }; + + const signer = { + // If routing were broken, the misleading tag would put nip04 first and this + // decoy would "succeed" with the wrong data — the assertions below catch it. + nip04Decrypt: async () => { + nip04Calls += 1; + return JSON.stringify({ + version: 2, + items: [{ type: "e", value: "WRONG-nip04-decoy", watchedAt: 1 }], + }); + }, + nip44Decrypt: async (pubkey, payload) => { + nip44Calls += 1; + assert.equal(pubkey, actorHex, "nip44 decrypt should target the actor pubkey"); + assert.equal(payload, ciphertext, "nip44 decrypt should receive the ciphertext"); + return JSON.stringify({ + version: 2, + items: [{ type: "e", value: "correct-nip44-decrypt", watchedAt: 77 }], + }); + }, + }; + + const pool = { + async list() { + return [pointerEvent]; + }, + }; + + const manager = createWatchHistoryManager({ + getActivePubkey: () => actorHex, + resolveActiveSigner: () => signer, + shouldRequestExtensionPermissions: () => false, + getPool: () => pool, + getReadRelays: () => ["wss://relay.example"], + }); + + try { + const result = await fetchWatchHistory(manager, actorHex, { forceRefresh: true }); + assert.equal( + nip04Calls, + 0, + "nip04 must NEVER be attempted for a nip44-shaped ciphertext (wasted NIP-46 RPC)", + ); + assert.equal(nip44Calls, 1, "nip44 decrypt should be attempted exactly once"); + assert.deepStrictEqual( + result.items.map((item) => item.value), + ["correct-nip44-decrypt"], + "the nip44 plaintext must be used, not the nip04 decoy", + ); + } finally { + manager.clear(); + } +}); + test("publishWatchHistorySnapshot uses injected nostr-tools helpers when signer cannot encrypt", async () => { const actorPubkey = "f".repeat(64); const sessionPrivateKey = "a".repeat(64);