diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index e6c0606b..8cef0472 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -10,6 +10,7 @@ */ import { clearDecryptionSchemeCache } from "../nostr/decryptionSchemeCache.js"; +import { clearWatchHistoryConversationKeyCache } from "../nostr/watchHistory.js"; import { FEED_TYPES } from "../constants.js"; /** @@ -881,6 +882,7 @@ export function createAuthSessionCoordinator(deps) { this.resetHashtagPreferencesState(); this.resetPermissionPromptState(); clearDecryptionSchemeCache(); + clearWatchHistoryConversationKeyCache(); this.updateAuthLoadingState({ profile: "idle", lists: "idle", dms: "idle" }); try { diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index a174aa28..919f058a 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -483,6 +483,25 @@ function hexToBytesCompat(hex, tools = null) { return bytes; } +// Memoizes NIP-44 conversation keys across an entire load. Deriving a +// conversation key is an ECDH operation (~12ms each in-bundle); without this +// cache the watch-history decrypt path re-derived the *same* key once per +// encrypted chunk (~150x for a full 1,500-item history), synchronously blocking +// the main thread for seconds. The key depends only on (scheme, privateKey, +// target), so it is safe to compute once and reuse. Cleared on logout alongside +// the decryption-scheme cache. +// +// @type {Map} +const conversationKeyCache = new Map(); + +/** + * Clear all memoized NIP-44 conversation keys. Must be called on logout/identity + * change so a derived shared secret never outlives its session. + */ +export function clearWatchHistoryConversationKeyCache() { + conversationKeyCache.clear(); +} + function createNip44CipherSuite(tools, privateKeyHex, targetPubkeyHex) { if (!tools || !privateKeyHex || !targetPubkeyHex) { return null; @@ -516,10 +535,12 @@ function createNip44CipherSuite(tools, privateKeyHex, targetPubkeyHex) { typeof nip44v2.decrypt === "function" && typeof nip44v2?.utils?.getConversationKey === "function" ) { - let cachedKey = null; + const v2CacheKey = `v2:${normalizedPrivateKey}:${normalizedTarget}`; const ensureKey = () => { + let cachedKey = conversationKeyCache.get(v2CacheKey); if (!cachedKey) { cachedKey = nip44v2.utils.getConversationKey(privateKeyBytes, normalizedTarget); + conversationKeyCache.set(v2CacheKey, cachedKey); } return cachedKey; }; @@ -544,10 +565,12 @@ function createNip44CipherSuite(tools, privateKeyHex, targetPubkeyHex) { typeof nip44?.decrypt === "function" && typeof legacyGetConversationKey === "function" ) { - let cachedKey = null; + const legacyCacheKey = `legacy:${normalizedPrivateKey}:${normalizedTarget}`; const ensureLegacyKey = () => { + let cachedKey = conversationKeyCache.get(legacyCacheKey); if (!cachedKey) { cachedKey = legacyGetConversationKey(privateKeyBytes, normalizedTarget); + conversationKeyCache.set(legacyCacheKey, cachedKey); } return cachedKey; }; @@ -2329,4 +2352,5 @@ export const watchHistoryHelpers = { serializeWatchHistoryItems, computeWatchHistoryFingerprintForItems, extractPointerItemsFromEvent, + createNip44CipherSuite, }; diff --git a/tests/nostr/watchHistoryConversationKeyCache.test.js b/tests/nostr/watchHistoryConversationKeyCache.test.js new file mode 100644 index 00000000..2ac7ff1d --- /dev/null +++ b/tests/nostr/watchHistoryConversationKeyCache.test.js @@ -0,0 +1,123 @@ +// Regression test for the NIP-44 conversation-key memoization in the +// watch-history decrypt path. +// +// Scenario (SCN-watch-history-convkey-memo): +// Given a user whose watch history is stored as many separately NIP-44 +// encrypted chunk events (each chunk historically built its own cipher +// suite, re-deriving the ECDH conversation key ~12ms at a time), +// When the chunks are decrypted during a single load (i.e. many cipher +// suites are created for the same private key + target), +// Then the conversation key must be derived exactly once and reused, so a +// 1,500-item / ~150-chunk history does not block the main thread for +// seconds. +// +// Observable outcome asserted at the boundary: the number of calls to +// nostr-tools' getConversationKey, which is the expensive ECDH operation. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + watchHistoryHelpers, + clearWatchHistoryConversationKeyCache, +} from "../../js/nostr/watchHistory.js"; + +const { createNip44CipherSuite } = watchHistoryHelpers; + +const PRIV_A = + "1111111111111111111111111111111111111111111111111111111111111111"; +const PRIV_B = + "2222222222222222222222222222222222222222222222222222222222222222"; +const TARGET_X = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1"; +const TARGET_Y = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1"; + +/** + * Build a fake nostr-tools surface whose getConversationKey counts invocations. + * decrypt simply echoes the ciphertext so we can confirm the suite is wired up. + */ +function makeCountingTools() { + const calls = []; + return { + calls, + tools: { + nip44: { + v2: { + encrypt: (plaintext) => `enc(${plaintext})`, + decrypt: (ciphertext) => `dec(${ciphertext})`, + utils: { + getConversationKey: (privBytes, target) => { + // Record the full (private key, target) identity so assertions can + // distinguish two pairs that share a target but differ by signer. + const privId = Array.from(privBytes ?? []).join(""); + calls.push(`${privId}:${target}`); + // Return a distinct "key" object per (priv,target) so we can also + // detect accidental cross-identity reuse if assertions tighten. + return { keyFor: `${privId}:${target}`, n: calls.length }; + }, + }, + }, + }, + }, + }; +} + +test("conversation key is derived once across many chunk cipher suites", () => { + clearWatchHistoryConversationKeyCache(); + const { tools, calls } = makeCountingTools(); + + // Simulate the per-chunk path: a fresh suite per chunk event, all sharing the + // same identity (PRIV_A -> TARGET_X), as a real watch-history load does. + const CHUNKS = 150; + for (let i = 0; i < CHUNKS; i++) { + const suite = createNip44CipherSuite(tools, PRIV_A, TARGET_X); + assert.ok(suite?.v2, "expected a v2 cipher suite"); + const out = suite.v2.decrypt(`chunk-${i}`); + assert.equal(out, `dec(chunk-${i})`); + } + + assert.equal( + calls.length, + 1, + `ECDH conversation key should be derived exactly once for ${CHUNKS} chunks, got ${calls.length}`, + ); +}); + +test("distinct identities and targets each derive their own key", () => { + clearWatchHistoryConversationKeyCache(); + const { tools, calls } = makeCountingTools(); + + // Three distinct (priv,target) pairs => three derivations, regardless of how + // many suites/decrypts each performs. This guards against over-caching that + // would leak one identity's key into another. + for (let i = 0; i < 5; i++) { + createNip44CipherSuite(tools, PRIV_A, TARGET_X).v2.decrypt("x"); + createNip44CipherSuite(tools, PRIV_A, TARGET_Y).v2.decrypt("x"); + createNip44CipherSuite(tools, PRIV_B, TARGET_X).v2.decrypt("x"); + } + + assert.deepEqual( + new Set(calls).size, + 3, + "expected exactly three distinct conversation keys", + ); + assert.equal(calls.length, 3, "each distinct pair derived exactly once"); +}); + +test("clearing the cache forces re-derivation (no secret outlives logout)", () => { + clearWatchHistoryConversationKeyCache(); + const { tools, calls } = makeCountingTools(); + + createNip44CipherSuite(tools, PRIV_A, TARGET_X).v2.decrypt("x"); + assert.equal(calls.length, 1); + + // Same identity again while cached: no new derivation. + createNip44CipherSuite(tools, PRIV_A, TARGET_X).v2.decrypt("x"); + assert.equal(calls.length, 1); + + // After logout-style clear, the key must be derived fresh. + clearWatchHistoryConversationKeyCache(); + createNip44CipherSuite(tools, PRIV_A, TARGET_X).v2.decrypt("x"); + assert.equal(calls.length, 2, "cache clear must force a fresh derivation"); +});