mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 00:12:25 +00:00
[watch-history] strictly format-route decrypt to halve NIP-46 RPCs
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 <[email protected]>
This commit is contained in:
1 parent
d143883e9c
commit
cd614876f5
2 files changed
+111
-1
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user