mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
fix(nip46): route list decryption by ciphertext format (cut RPC fan-out)
Under a NIP-46 remote signer, every decrypt is a PUBLISHED relay RPC. The list
services (blocks/subscriptions/hashtags) probed all decryptor families
concurrently via Promise.any — so each list event published nip04 AND nip44 RPCs
at once. On login that flooded the relay ("rate-limited: you are noting too
much"), and the racing/queued RPCs blew past the 30s decrypt budget → the
"Decryption timed out" failures.
A NIP-04 payload carries the "?iv=" marker and can ONLY be decrypted by nip04;
otherwise it's nip44. The other family can never succeed on a given ciphertext,
so racing it is pure waste (and a wasted published RPC under NIP-46). Restrict
the decrypt probe to the family matching the ciphertext format (fall back to all
if the format is somehow unknown). Halves cold-load RPCs and stops the racing
publishes that tripped the rate limit; benefits all signer types.
Spec correction: the hashtag "retries decryptors when hinted scheme fails" test
stubbed an impossible scenario (the same ciphertext decrypting under nip04 after
nip44 failed). Rewritten to assert the real contract — the ciphertext format
routes decryption and overrides a misleading `encrypted` tag, invoking only the
matching decryptor.
test_integrity_note:
change_type: ["spec_correction"]
scenarios:
- id: SCN-decrypt-format-routing
given: "a nip04-format ciphertext (?iv=) tagged misleadingly as nip44"
when: "a list service decrypts it"
then: "only the nip04 decryptor is invoked; nip44 is never attempted"
anti_cheat_rationale:
prevents: ["over-mocking internal logic", "asserting impossible cross-family decryption"]
relaxation:
did_relax_any_assertion: false
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
ef66092727
commit
8ab8dde7a7
4 files changed
+61
-8
No files matched your search
@@ -1298,7 +1298,20 @@ class HashtagPreferencesService {
|
||||
s === "nip44" || s === "nip44_v2" ? "nip44" : s;
|
||||
const seenFamilies = new Set();
|
||||
const attemptSchemes = [];
|
||||
const attempts = order
|
||||
// Route by ciphertext format: only the family matching the payload can
|
||||
// decrypt it (NIP-04 carries "?iv="; otherwise NIP-44). Racing the other
|
||||
// family is wasted work — and under a NIP-46 remote signer it's a wasted
|
||||
// PUBLISHED relay RPC that floods the relay ("rate-limited") and stalls
|
||||
// decryption. Restrict the probe to the matching family.
|
||||
const formatFamily =
|
||||
typeof ciphertext === "string" && ciphertext.includes("?iv=")
|
||||
? "nip04"
|
||||
: "nip44";
|
||||
const matchingOrder = order.filter(
|
||||
(scheme) => getSchemeFamily(scheme) === formatFamily,
|
||||
);
|
||||
const probeOrder = matchingOrder.length ? matchingOrder : order;
|
||||
const attempts = probeOrder
|
||||
.map((scheme) => {
|
||||
const family = getSchemeFamily(scheme);
|
||||
if (seenFamilies.has(family)) return null;
|
||||
|
||||
+14
-1
@@ -1386,7 +1386,20 @@ class SubscriptionsManager {
|
||||
s === "nip44" || s === "nip44_v2" ? "nip44" : s;
|
||||
const seenFamilies = new Set();
|
||||
const attemptSchemes = [];
|
||||
const attempts = order
|
||||
// Route by ciphertext format: only the family matching the payload can
|
||||
// decrypt it (NIP-04 carries "?iv="; otherwise NIP-44). Racing the other
|
||||
// family is wasted work — and under a NIP-46 remote signer it's a wasted
|
||||
// PUBLISHED relay RPC that floods the relay ("rate-limited") and stalls
|
||||
// decryption. Restrict the probe to the matching family.
|
||||
const formatFamily =
|
||||
typeof ciphertext === "string" && ciphertext.includes("?iv=")
|
||||
? "nip04"
|
||||
: "nip44";
|
||||
const matchingOrder = order.filter(
|
||||
(scheme) => getSchemeFamily(scheme) === formatFamily,
|
||||
);
|
||||
const probeOrder = matchingOrder.length ? matchingOrder : order;
|
||||
const attempts = probeOrder
|
||||
.map((scheme) => {
|
||||
const family = getSchemeFamily(scheme);
|
||||
if (seenFamilies.has(family)) return null;
|
||||
|
||||
+16
-1
@@ -1473,8 +1473,23 @@ class UserBlockListManager {
|
||||
try {
|
||||
const getSchemeFamily = (s) =>
|
||||
s === "nip44" || s === "nip44_v2" ? "nip44" : s;
|
||||
// Route by ciphertext format: a NIP-04 payload carries the "?iv="
|
||||
// marker and can ONLY be decrypted by nip04; everything else is
|
||||
// nip44. Racing the non-matching family is wasted work — and under
|
||||
// a NIP-46 remote signer each attempt is a separately PUBLISHED
|
||||
// relay RPC, so racing both families floods the relay
|
||||
// ("rate-limited: noting too much") and stalls decryption. Restrict
|
||||
// the probe to the matching family (fall back to all if unknown).
|
||||
const formatFamily =
|
||||
typeof ev.content === "string" && ev.content.includes("?iv=")
|
||||
? "nip04"
|
||||
: "nip44";
|
||||
const matchingOrder = order.filter(
|
||||
(scheme) => getSchemeFamily(scheme) === formatFamily,
|
||||
);
|
||||
const probeOrder = matchingOrder.length ? matchingOrder : order;
|
||||
const seenFamilies = new Set();
|
||||
const attempts = order
|
||||
const attempts = probeOrder
|
||||
.map((scheme) => {
|
||||
const family = getSchemeFamily(scheme);
|
||||
if (seenFamilies.has(family)) return null;
|
||||
|
||||
@@ -180,8 +180,17 @@ test(
|
||||
},
|
||||
);
|
||||
|
||||
// Spec correction (was "load retries decryptors when hinted scheme fails"):
|
||||
// the old test stubbed an impossible scenario — the SAME ciphertext decrypting
|
||||
// under nip04 after nip44 "failed". A real NIP-04 payload (carrying "?iv=") can
|
||||
// only be decrypted by nip04, and a NIP-44 payload only by nip44, so the other
|
||||
// family never succeeds on a given ciphertext. Decryption now routes by the
|
||||
// ciphertext format and does NOT race the non-matching family — which also
|
||||
// avoids a wasted published RPC under a NIP-46 remote signer. This asserts the
|
||||
// real contract: the ciphertext format wins even over a misleading `encrypted`
|
||||
// tag, and only the matching decryptor is invoked.
|
||||
test(
|
||||
"load retries decryptors when hinted scheme fails",
|
||||
"load routes decryption by ciphertext format (ignores a misleading tag)",
|
||||
{ concurrency: false },
|
||||
async () => {
|
||||
const pubkey = "d".repeat(64);
|
||||
@@ -190,7 +199,7 @@ test(
|
||||
setActiveSigner({
|
||||
nip44Decrypt: async () => {
|
||||
attempts.push("nip44");
|
||||
throw new Error("nip44 failed");
|
||||
throw new Error("nip44 must not be attempted for a nip04 (?iv=) payload");
|
||||
},
|
||||
nip04Decrypt: async () => {
|
||||
attempts.push("nip04");
|
||||
@@ -209,8 +218,9 @@ test(
|
||||
id: "evt-retry",
|
||||
created_at: 300,
|
||||
pubkey,
|
||||
content: "cipher", // never decrypted successfully by nip44
|
||||
tags: [["encrypted", "nip44 nip04"]],
|
||||
// NIP-04 ciphertext format ("?iv=") but a misleading "nip44" tag.
|
||||
content: "deadbeefdeadbeef?iv=cafebabecafebabe",
|
||||
tags: [["encrypted", "nip44"]],
|
||||
},
|
||||
];
|
||||
},
|
||||
@@ -221,7 +231,9 @@ test(
|
||||
|
||||
await hashtagPreferences.load(pubkey);
|
||||
|
||||
assert.deepEqual(attempts, ["nip44", "nip04"]);
|
||||
// Only nip04 is tried — the "?iv=" format overrides the misleading tag, and
|
||||
// nip44 is never invoked (no wasted attempt / wasted NIP-46 RPC).
|
||||
assert.deepEqual(attempts, ["nip04"]);
|
||||
assert.deepEqual(hashtagPreferences.getInterests(), ["retry"]);
|
||||
assert.deepEqual(hashtagPreferences.getDisinterests(), []);
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user