perf(watch-history): memoize NIP-44 conversation key across chunk decrypts

Watch history is stored as up to ~150 separately NIP-44-encrypted chunk
events. Each chunk built a fresh cipher suite whose conversation-key cache
lived only in its own closure, so the ECDH conversation key (~12ms each in
the bundle) was re-derived once per chunk — multiplied across actor-candidate
and scheme-fallback retries this synchronously blocked the main thread for up
to ~20s after login/profile open (the reported freeze + fan spin-up).

Memoize the derived key by (scheme, privateKey, target) at module scope so it
is computed once per load and reused. Cleared on logout/identity change next
to clearDecryptionSchemeCache so a derived shared secret never outlives its
session. On the 150-chunk path this drops decrypt time ~1955ms -> ~182ms.

Adds tests/nostr/watchHistoryConversationKeyCache.test.js covering: 150 chunks
=> one derivation, distinct identities derive independently (no cross-identity
reuse), and cache-clear forces re-derivation.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-12 09:41:06 -04:00
1 parent 414a20c1b6
commit 8850eb962a
3 files changed
+151 -2

No files matched your search

+2
View File
@@ -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 {
+26 -2
View File
@@ -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<string, unknown>}
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,
};
@@ -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");
});