mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-08 05:43:04 +00:00
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:
1 parent
414a20c1b6
commit
8850eb962a
3 files changed
+151
-2
No files matched your search
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user