diff --git a/extensions/nostr/src/nostr-bus.inbound.test.ts b/extensions/nostr/src/nostr-bus.inbound.test.ts index 55a118db68c..512aa465204 100644 --- a/extensions/nostr/src/nostr-bus.inbound.test.ts +++ b/extensions/nostr/src/nostr-bus.inbound.test.ts @@ -144,6 +144,59 @@ describe("startNostrBus inbound guards", () => { bus.close(); }); + it("dedupes replayed invalid-signature events before verify fans out again", async () => { + mockState.verifyEvent.mockReturnValue(false); + const onMessage = vi.fn(async () => {}); + const authorizeSender = vi.fn(async () => "allow" as const); + const bus = await startNostrBus({ + privateKey: TEST_HEX_PRIVATE_KEY, + onMessage, + authorizeSender, + onMetric: () => {}, + }); + + const invalidEvent = createEvent({ id: "invalid-replay" }); + + await emitEvent(invalidEvent); + await emitEvent(invalidEvent); + + expect(mockState.verifyEvent).toHaveBeenCalledTimes(1); + expect(authorizeSender).not.toHaveBeenCalled(); + expect(mockState.decrypt).not.toHaveBeenCalled(); + expect(onMessage).not.toHaveBeenCalled(); + expect(bus.getMetrics().eventsRejected.invalidSignature).toBe(1); + expect(bus.getMetrics().eventsDuplicate).toBe(1); + + bus.close(); + }); + + it("dedupes replayed self-message events before other guards rerun", async () => { + const onMessage = vi.fn(async () => {}); + const authorizeSender = vi.fn(async () => "allow" as const); + const bus = await startNostrBus({ + privateKey: TEST_HEX_PRIVATE_KEY, + onMessage, + authorizeSender, + onMetric: () => {}, + }); + + const selfEvent = createEvent({ + id: "self-replay", + pubkey: BOT_PUBKEY, + }); + + await emitEvent(selfEvent); + await emitEvent(selfEvent); + + expect(mockState.verifyEvent).not.toHaveBeenCalled(); + expect(authorizeSender).not.toHaveBeenCalled(); + expect(mockState.decrypt).not.toHaveBeenCalled(); + expect(onMessage).not.toHaveBeenCalled(); + expect(bus.getMetrics().eventsDuplicate).toBe(1); + + bus.close(); + }); + it("rate limits repeated events before decrypt", async () => { const onMessage = vi.fn(async () => {}); const bus = await startNostrBus({ diff --git a/extensions/nostr/src/nostr-bus.ts b/extensions/nostr/src/nostr-bus.ts index 41a8fdbc1f3..51a59cf138e 100644 --- a/extensions/nostr/src/nostr-bus.ts +++ b/extensions/nostr/src/nostr-bus.ts @@ -505,15 +505,24 @@ export async function startNostrBus(options: NostrBusOptions): Promise { + seen.add(event.id); + metrics.emit("memory.seen_tracker_size", seen.size()); + }; + const rejectAndMarkSeen = (metric: Parameters[0]) => { + markSeen(); + metrics.emit(metric); + }; + // Self-message loop prevention: skip our own messages if (event.pubkey === pk) { - metrics.emit("event.rejected.self_message"); + rejectAndMarkSeen("event.rejected.self_message"); return; } // Skip events older than our `since` (relay may ignore filter) if (event.created_at < since) { - metrics.emit("event.rejected.stale"); + rejectAndMarkSeen("event.rejected.stale"); return; } @@ -523,7 +532,7 @@ export async function startNostrBus(options: NostrBusOptions): Promise { - seen.add(event.id); - metrics.emit("memory.seen_tracker_size", seen.size()); - }; - if (Buffer.byteLength(event.content, "utf8") > guardPolicy.maxCiphertextBytes) { if (rejectIfGlobalRateLimited()) { return; } - metrics.emit("event.rejected.oversized_ciphertext"); + rejectAndMarkSeen("event.rejected.oversized_ciphertext"); return; } @@ -597,7 +601,7 @@ export async function startNostrBus(options: NostrBusOptions): Promise