fix(nostr): dedupe deterministic rejected events

This commit is contained in:
Vincent Koc
2026-04-13 18:07:23 +01:00
parent d1e3ed3743
commit 1b20c1aca4
2 changed files with 68 additions and 11 deletions

View File

@@ -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({

View File

@@ -505,15 +505,24 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
}
inflight.add(event.id);
const markSeen = () => {
seen.add(event.id);
metrics.emit("memory.seen_tracker_size", seen.size());
};
const rejectAndMarkSeen = (metric: Parameters<typeof metrics.emit>[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<NostrBusH
}
if (!guardPolicy.allowedKinds.includes(event.kind)) {
metrics.emit("event.rejected.wrong_kind");
rejectAndMarkSeen("event.rejected.wrong_kind");
return;
}
@@ -536,7 +545,7 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
}
}
if (!targetsUs) {
metrics.emit("event.rejected.wrong_kind");
rejectAndMarkSeen("event.rejected.wrong_kind");
return;
}
@@ -578,16 +587,11 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
return false;
};
const markSeen = () => {
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<NostrBusH
// Verify signature (must pass before we trust the event)
if (!verifyEvent(event)) {
metrics.emit("event.rejected.invalid_signature");
rejectAndMarkSeen("event.rejected.invalid_signature");
onError?.(new Error("Invalid signature"), `event ${event.id}`);
return;
}