@openclaw/nostr 2026.3.12 → 2026.5.1-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/api.ts +10 -0
- package/channel-plugin-api.ts +1 -0
- package/index.ts +60 -36
- package/openclaw.plugin.json +190 -1
- package/package.json +41 -9
- package/runtime-api.ts +6 -0
- package/setup-api.ts +1 -0
- package/setup-entry.ts +9 -0
- package/setup-plugin-api.ts +3 -0
- package/src/channel-api.ts +15 -0
- package/src/channel.inbound.test.ts +176 -0
- package/src/channel.outbound.test.ts +89 -49
- package/src/channel.setup.ts +231 -0
- package/src/channel.test.ts +439 -71
- package/src/channel.ts +146 -284
- package/src/config-schema.ts +18 -12
- package/src/default-relays.ts +1 -0
- package/src/gateway.ts +302 -0
- package/src/inbound-direct-dm-runtime.ts +1 -0
- package/src/metrics.ts +6 -6
- package/src/nostr-bus.fuzz.test.ts +74 -247
- package/src/nostr-bus.inbound.test.ts +526 -0
- package/src/nostr-bus.integration.test.ts +88 -64
- package/src/nostr-bus.test.ts +22 -31
- package/src/nostr-bus.ts +206 -136
- package/src/nostr-key-utils.ts +94 -0
- package/src/nostr-profile-core.ts +134 -0
- package/src/nostr-profile-http-runtime.ts +6 -0
- package/src/nostr-profile-http.test.ts +310 -192
- package/src/nostr-profile-http.ts +51 -36
- package/src/nostr-profile-import.ts +3 -3
- package/src/nostr-profile-url-safety.ts +21 -0
- package/src/nostr-profile.fuzz.test.ts +7 -57
- package/src/nostr-profile.test.ts +16 -14
- package/src/nostr-profile.ts +13 -146
- package/src/nostr-state-store.test.ts +106 -2
- package/src/nostr-state-store.ts +46 -49
- package/src/runtime.ts +6 -3
- package/src/seen-tracker.ts +1 -1
- package/src/session-route.ts +25 -0
- package/src/setup-surface.ts +265 -0
- package/src/test-fixtures.ts +45 -0
- package/src/types.ts +26 -25
- package/test-api.ts +1 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -110
- package/src/types.test.ts +0 -175
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { startNostrBus } from "./nostr-bus.js";
|
|
3
|
+
import { TEST_HEX_PRIVATE_KEY } from "./test-fixtures.js";
|
|
4
|
+
|
|
5
|
+
const BOT_PUBKEY = "b".repeat(64);
|
|
6
|
+
|
|
7
|
+
const mockState = vi.hoisted(() => ({
|
|
8
|
+
handlers: null as {
|
|
9
|
+
onevent: (event: Record<string, unknown>) => void | Promise<void>;
|
|
10
|
+
oneose?: () => void;
|
|
11
|
+
onclose?: (reason: string[]) => void;
|
|
12
|
+
} | null,
|
|
13
|
+
verifyEvent: vi.fn(() => true),
|
|
14
|
+
decrypt: vi.fn(() => "plaintext"),
|
|
15
|
+
publishProfile: vi.fn(async () => ({
|
|
16
|
+
createdAt: 0,
|
|
17
|
+
eventId: "profile-event",
|
|
18
|
+
successes: [],
|
|
19
|
+
failures: [],
|
|
20
|
+
})),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock("nostr-tools", () => {
|
|
24
|
+
class MockSimplePool {
|
|
25
|
+
subscribeMany(
|
|
26
|
+
_relays: string[],
|
|
27
|
+
_filters: unknown,
|
|
28
|
+
handlers: {
|
|
29
|
+
onevent: (event: Record<string, unknown>) => void | Promise<void>;
|
|
30
|
+
oneose?: () => void;
|
|
31
|
+
onclose?: (reason: string[]) => void;
|
|
32
|
+
},
|
|
33
|
+
) {
|
|
34
|
+
mockState.handlers = handlers;
|
|
35
|
+
return {
|
|
36
|
+
close: vi.fn(),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
publish = vi.fn(async () => {});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
SimplePool: MockSimplePool,
|
|
45
|
+
finalizeEvent: vi.fn((event: unknown) => event),
|
|
46
|
+
getPublicKey: vi.fn(() => BOT_PUBKEY),
|
|
47
|
+
verifyEvent: mockState.verifyEvent,
|
|
48
|
+
nip19: {
|
|
49
|
+
decode: vi.fn(),
|
|
50
|
+
npubEncode: vi.fn((value: string) => `npub-${value}`),
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
vi.mock("nostr-tools/nip04", () => ({
|
|
56
|
+
decrypt: mockState.decrypt,
|
|
57
|
+
encrypt: vi.fn(() => "ciphertext"),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
vi.mock("./nostr-state-store.js", () => ({
|
|
61
|
+
readNostrBusState: vi.fn(async () => null),
|
|
62
|
+
writeNostrBusState: vi.fn(async () => {}),
|
|
63
|
+
computeSinceTimestamp: vi.fn(() => 0),
|
|
64
|
+
readNostrProfileState: vi.fn(async () => null),
|
|
65
|
+
writeNostrProfileState: vi.fn(async () => {}),
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
vi.mock("./nostr-profile.js", () => ({
|
|
69
|
+
publishProfile: mockState.publishProfile,
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
function createEvent(overrides: Record<string, unknown> = {}) {
|
|
73
|
+
return {
|
|
74
|
+
id: "event-1",
|
|
75
|
+
kind: 4,
|
|
76
|
+
pubkey: "a".repeat(64),
|
|
77
|
+
content: "ciphertext",
|
|
78
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
79
|
+
tags: [["p", BOT_PUBKEY]],
|
|
80
|
+
...overrides,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function emitEvent(event: Record<string, unknown>) {
|
|
85
|
+
if (!mockState.handlers) {
|
|
86
|
+
throw new Error("missing subscription handlers");
|
|
87
|
+
}
|
|
88
|
+
await mockState.handlers.onevent(event);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe("startNostrBus inbound guards", () => {
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
mockState.handlers = null;
|
|
94
|
+
mockState.verifyEvent.mockClear();
|
|
95
|
+
mockState.verifyEvent.mockReturnValue(true);
|
|
96
|
+
mockState.decrypt.mockClear();
|
|
97
|
+
mockState.decrypt.mockReturnValue("plaintext");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
mockState.handlers = null;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("checks sender authorization after verify and before decrypt", async () => {
|
|
105
|
+
const onMessage = vi.fn(async () => {});
|
|
106
|
+
const authorizeSender = vi.fn(async () => "block" as const);
|
|
107
|
+
const bus = await startNostrBus({
|
|
108
|
+
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
109
|
+
onMessage,
|
|
110
|
+
authorizeSender,
|
|
111
|
+
onMetric: () => {},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await emitEvent(createEvent());
|
|
115
|
+
|
|
116
|
+
expect(authorizeSender).toHaveBeenCalledTimes(1);
|
|
117
|
+
expect(mockState.verifyEvent).toHaveBeenCalledTimes(1);
|
|
118
|
+
expect(mockState.decrypt).not.toHaveBeenCalled();
|
|
119
|
+
expect(onMessage).not.toHaveBeenCalled();
|
|
120
|
+
expect(bus.getMetrics().eventsReceived).toBe(1);
|
|
121
|
+
|
|
122
|
+
bus.close();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("rejects invalid signatures before sender authorization", async () => {
|
|
126
|
+
mockState.verifyEvent.mockReturnValueOnce(false);
|
|
127
|
+
const onMessage = vi.fn(async () => {});
|
|
128
|
+
const authorizeSender = vi.fn(async () => "allow" as const);
|
|
129
|
+
const bus = await startNostrBus({
|
|
130
|
+
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
131
|
+
onMessage,
|
|
132
|
+
authorizeSender,
|
|
133
|
+
onMetric: () => {},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await emitEvent(createEvent());
|
|
137
|
+
|
|
138
|
+
expect(mockState.verifyEvent).toHaveBeenCalledTimes(1);
|
|
139
|
+
expect(authorizeSender).not.toHaveBeenCalled();
|
|
140
|
+
expect(mockState.decrypt).not.toHaveBeenCalled();
|
|
141
|
+
expect(onMessage).not.toHaveBeenCalled();
|
|
142
|
+
expect(bus.getMetrics().eventsRejected.invalidSignature).toBe(1);
|
|
143
|
+
|
|
144
|
+
bus.close();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("dedupes replayed invalid-signature events before verify fans out again", async () => {
|
|
148
|
+
mockState.verifyEvent.mockReturnValue(false);
|
|
149
|
+
const onMessage = vi.fn(async () => {});
|
|
150
|
+
const authorizeSender = vi.fn(async () => "allow" as const);
|
|
151
|
+
const bus = await startNostrBus({
|
|
152
|
+
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
153
|
+
onMessage,
|
|
154
|
+
authorizeSender,
|
|
155
|
+
onMetric: () => {},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const invalidEvent = createEvent({ id: "invalid-replay" });
|
|
159
|
+
|
|
160
|
+
await emitEvent(invalidEvent);
|
|
161
|
+
await emitEvent(invalidEvent);
|
|
162
|
+
|
|
163
|
+
expect(mockState.verifyEvent).toHaveBeenCalledTimes(1);
|
|
164
|
+
expect(authorizeSender).not.toHaveBeenCalled();
|
|
165
|
+
expect(mockState.decrypt).not.toHaveBeenCalled();
|
|
166
|
+
expect(onMessage).not.toHaveBeenCalled();
|
|
167
|
+
expect(bus.getMetrics().eventsRejected.invalidSignature).toBe(1);
|
|
168
|
+
expect(bus.getMetrics().eventsDuplicate).toBe(1);
|
|
169
|
+
|
|
170
|
+
bus.close();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("dedupes replayed self-message events before other guards rerun", async () => {
|
|
174
|
+
const onMessage = vi.fn(async () => {});
|
|
175
|
+
const authorizeSender = vi.fn(async () => "allow" as const);
|
|
176
|
+
const bus = await startNostrBus({
|
|
177
|
+
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
178
|
+
onMessage,
|
|
179
|
+
authorizeSender,
|
|
180
|
+
onMetric: () => {},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const selfEvent = createEvent({
|
|
184
|
+
id: "self-replay",
|
|
185
|
+
pubkey: BOT_PUBKEY,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await emitEvent(selfEvent);
|
|
189
|
+
await emitEvent(selfEvent);
|
|
190
|
+
|
|
191
|
+
expect(mockState.verifyEvent).not.toHaveBeenCalled();
|
|
192
|
+
expect(authorizeSender).not.toHaveBeenCalled();
|
|
193
|
+
expect(mockState.decrypt).not.toHaveBeenCalled();
|
|
194
|
+
expect(onMessage).not.toHaveBeenCalled();
|
|
195
|
+
expect(bus.getMetrics().eventsDuplicate).toBe(1);
|
|
196
|
+
|
|
197
|
+
bus.close();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("rate limits repeated events before decrypt", async () => {
|
|
201
|
+
const onMessage = vi.fn(async () => {});
|
|
202
|
+
const bus = await startNostrBus({
|
|
203
|
+
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
204
|
+
onMessage,
|
|
205
|
+
onMetric: () => {},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
for (let i = 0; i < 21; i += 1) {
|
|
209
|
+
await emitEvent(
|
|
210
|
+
createEvent({
|
|
211
|
+
id: `event-${i}`,
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const snapshot = bus.getMetrics();
|
|
217
|
+
expect(snapshot.eventsRejected.rateLimited).toBe(1);
|
|
218
|
+
expect(mockState.decrypt).toHaveBeenCalledTimes(20);
|
|
219
|
+
expect(onMessage).toHaveBeenCalledTimes(20);
|
|
220
|
+
|
|
221
|
+
bus.close();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("does not let a blocked sender starve a different verified sender", async () => {
|
|
225
|
+
const onMessage = vi.fn(async () => {});
|
|
226
|
+
const authorizeSender = vi.fn(async ({ senderPubkey }: { senderPubkey: string }) =>
|
|
227
|
+
senderPubkey.startsWith("blocked") ? ("block" as const) : ("allow" as const),
|
|
228
|
+
);
|
|
229
|
+
const bus = await startNostrBus({
|
|
230
|
+
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
231
|
+
onMessage,
|
|
232
|
+
authorizeSender,
|
|
233
|
+
onMetric: () => {},
|
|
234
|
+
guardPolicy: {
|
|
235
|
+
rateLimit: {
|
|
236
|
+
windowMs: 60_000,
|
|
237
|
+
maxGlobalPerWindow: 2,
|
|
238
|
+
maxPerSenderPerWindow: 1,
|
|
239
|
+
maxTrackedSenderKeys: 32,
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
await emitEvent(
|
|
245
|
+
createEvent({
|
|
246
|
+
id: "blocked-event",
|
|
247
|
+
pubkey: `blocked${"a".repeat(57)}`,
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
await emitEvent(
|
|
251
|
+
createEvent({
|
|
252
|
+
id: "allowed-event",
|
|
253
|
+
pubkey: `allowed${"b".repeat(57)}`,
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
expect(authorizeSender).toHaveBeenCalledTimes(2);
|
|
258
|
+
expect(mockState.decrypt).toHaveBeenCalledTimes(1);
|
|
259
|
+
expect(onMessage).toHaveBeenCalledTimes(1);
|
|
260
|
+
expect(bus.getMetrics().eventsRejected.rateLimited).toBe(0);
|
|
261
|
+
|
|
262
|
+
bus.close();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("dedupes replayed verified events that authorization blocks", async () => {
|
|
266
|
+
const onMessage = vi.fn(async () => {});
|
|
267
|
+
const authorizeSender = vi.fn(async () => "block" as const);
|
|
268
|
+
const bus = await startNostrBus({
|
|
269
|
+
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
270
|
+
onMessage,
|
|
271
|
+
authorizeSender,
|
|
272
|
+
onMetric: () => {},
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const blockedEvent = createEvent({
|
|
276
|
+
id: "blocked-replay",
|
|
277
|
+
pubkey: `blocked${"a".repeat(57)}`,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
await emitEvent(blockedEvent);
|
|
281
|
+
await emitEvent(blockedEvent);
|
|
282
|
+
|
|
283
|
+
expect(mockState.verifyEvent).toHaveBeenCalledTimes(1);
|
|
284
|
+
expect(authorizeSender).toHaveBeenCalledTimes(1);
|
|
285
|
+
expect(mockState.decrypt).not.toHaveBeenCalled();
|
|
286
|
+
expect(onMessage).not.toHaveBeenCalled();
|
|
287
|
+
|
|
288
|
+
bus.close();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("retries a replayed event after the message handler fails", async () => {
|
|
292
|
+
const onMessage = vi
|
|
293
|
+
.fn<(sender: string, plaintext: string) => Promise<void>>()
|
|
294
|
+
.mockRejectedValueOnce(new Error("boom"))
|
|
295
|
+
.mockResolvedValueOnce(undefined);
|
|
296
|
+
const bus = await startNostrBus({
|
|
297
|
+
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
298
|
+
onMessage,
|
|
299
|
+
onMetric: () => {},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const event = createEvent({
|
|
303
|
+
id: "retry-after-handler-failure",
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
await emitEvent(event);
|
|
307
|
+
await emitEvent(event);
|
|
308
|
+
|
|
309
|
+
expect(mockState.verifyEvent).toHaveBeenCalledTimes(2);
|
|
310
|
+
expect(mockState.decrypt).toHaveBeenCalledTimes(2);
|
|
311
|
+
expect(onMessage).toHaveBeenCalledTimes(2);
|
|
312
|
+
expect(bus.getMetrics().eventsProcessed).toBe(1);
|
|
313
|
+
|
|
314
|
+
bus.close();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("does not rate limit an allowed sender while another authorization is still pending", async () => {
|
|
318
|
+
const onMessage = vi.fn(async () => {});
|
|
319
|
+
let resolveBlocked: ((value: "block") => void) | undefined;
|
|
320
|
+
const blockedPromise = new Promise<"block">((resolve) => {
|
|
321
|
+
resolveBlocked = resolve;
|
|
322
|
+
});
|
|
323
|
+
const authorizeSender = vi
|
|
324
|
+
.fn<(params: { senderPubkey: string }) => Promise<"allow" | "block" | "pairing">>()
|
|
325
|
+
.mockImplementationOnce(async () => await blockedPromise)
|
|
326
|
+
.mockResolvedValueOnce("allow");
|
|
327
|
+
const bus = await startNostrBus({
|
|
328
|
+
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
329
|
+
onMessage,
|
|
330
|
+
authorizeSender,
|
|
331
|
+
onMetric: () => {},
|
|
332
|
+
guardPolicy: {
|
|
333
|
+
rateLimit: {
|
|
334
|
+
windowMs: 60_000,
|
|
335
|
+
maxGlobalPerWindow: 2,
|
|
336
|
+
maxPerSenderPerWindow: 1,
|
|
337
|
+
maxTrackedSenderKeys: 32,
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const blockedEventPromise = emitEvent(
|
|
343
|
+
createEvent({
|
|
344
|
+
id: "blocked-pending",
|
|
345
|
+
pubkey: `blocked${"a".repeat(57)}`,
|
|
346
|
+
}),
|
|
347
|
+
);
|
|
348
|
+
await emitEvent(
|
|
349
|
+
createEvent({
|
|
350
|
+
id: "allowed-during-pending-auth",
|
|
351
|
+
pubkey: `allowed${"b".repeat(57)}`,
|
|
352
|
+
}),
|
|
353
|
+
);
|
|
354
|
+
resolveBlocked?.("block");
|
|
355
|
+
await blockedEventPromise;
|
|
356
|
+
|
|
357
|
+
expect(authorizeSender).toHaveBeenCalledTimes(2);
|
|
358
|
+
expect(mockState.decrypt).toHaveBeenCalledTimes(1);
|
|
359
|
+
expect(onMessage).toHaveBeenCalledTimes(1);
|
|
360
|
+
expect(bus.getMetrics().eventsRejected.rateLimited).toBe(0);
|
|
361
|
+
|
|
362
|
+
bus.close();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("rate limits repeated invalid signatures before authorization work fans out", async () => {
|
|
366
|
+
mockState.verifyEvent.mockReturnValue(false);
|
|
367
|
+
const onMessage = vi.fn(async () => {});
|
|
368
|
+
const authorizeSender = vi.fn(async () => "allow" as const);
|
|
369
|
+
const bus = await startNostrBus({
|
|
370
|
+
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
371
|
+
onMessage,
|
|
372
|
+
authorizeSender,
|
|
373
|
+
onMetric: () => {},
|
|
374
|
+
guardPolicy: {
|
|
375
|
+
rateLimit: {
|
|
376
|
+
windowMs: 60_000,
|
|
377
|
+
maxGlobalPerWindow: 1,
|
|
378
|
+
maxPerSenderPerWindow: 10,
|
|
379
|
+
maxTrackedSenderKeys: 32,
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
await emitEvent(createEvent({ id: "invalid-1" }));
|
|
385
|
+
await emitEvent(createEvent({ id: "invalid-2" }));
|
|
386
|
+
|
|
387
|
+
expect(mockState.verifyEvent).toHaveBeenCalledTimes(1);
|
|
388
|
+
expect(authorizeSender).not.toHaveBeenCalled();
|
|
389
|
+
expect(bus.getMetrics().eventsRejected.invalidSignature).toBe(1);
|
|
390
|
+
expect(bus.getMetrics().eventsRejected.rateLimited).toBe(1);
|
|
391
|
+
|
|
392
|
+
bus.close();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("counts oversized ciphertext toward the global inbound rate limit", async () => {
|
|
396
|
+
const onMessage = vi.fn(async () => {});
|
|
397
|
+
const bus = await startNostrBus({
|
|
398
|
+
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
399
|
+
onMessage,
|
|
400
|
+
onMetric: () => {},
|
|
401
|
+
guardPolicy: {
|
|
402
|
+
maxCiphertextBytes: 4,
|
|
403
|
+
rateLimit: {
|
|
404
|
+
windowMs: 60_000,
|
|
405
|
+
maxGlobalPerWindow: 1,
|
|
406
|
+
maxPerSenderPerWindow: 10,
|
|
407
|
+
maxTrackedSenderKeys: 32,
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
await emitEvent(
|
|
413
|
+
createEvent({
|
|
414
|
+
id: "oversized-global-1",
|
|
415
|
+
pubkey: `sender1${"a".repeat(57)}`,
|
|
416
|
+
content: "ciphertext-too-large",
|
|
417
|
+
}),
|
|
418
|
+
);
|
|
419
|
+
await emitEvent(
|
|
420
|
+
createEvent({
|
|
421
|
+
id: "oversized-global-2",
|
|
422
|
+
pubkey: `sender2${"b".repeat(57)}`,
|
|
423
|
+
content: "ciphertext-too-large",
|
|
424
|
+
}),
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
expect(bus.getMetrics().eventsRejected.oversizedCiphertext).toBe(1);
|
|
428
|
+
expect(bus.getMetrics().eventsRejected.rateLimited).toBe(1);
|
|
429
|
+
expect(mockState.verifyEvent).not.toHaveBeenCalled();
|
|
430
|
+
expect(mockState.decrypt).not.toHaveBeenCalled();
|
|
431
|
+
expect(onMessage).not.toHaveBeenCalled();
|
|
432
|
+
|
|
433
|
+
bus.close();
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("does not spend per-sender buckets on oversized ciphertext before verification", async () => {
|
|
437
|
+
const onMessage = vi.fn(async () => {});
|
|
438
|
+
const bus = await startNostrBus({
|
|
439
|
+
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
440
|
+
onMessage,
|
|
441
|
+
onMetric: () => {},
|
|
442
|
+
guardPolicy: {
|
|
443
|
+
maxCiphertextBytes: 4,
|
|
444
|
+
rateLimit: {
|
|
445
|
+
windowMs: 60_000,
|
|
446
|
+
maxGlobalPerWindow: 10,
|
|
447
|
+
maxPerSenderPerWindow: 1,
|
|
448
|
+
maxTrackedSenderKeys: 32,
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
await emitEvent(
|
|
454
|
+
createEvent({
|
|
455
|
+
id: "oversized-sender-1",
|
|
456
|
+
content: "ciphertext-too-large",
|
|
457
|
+
}),
|
|
458
|
+
);
|
|
459
|
+
await emitEvent(
|
|
460
|
+
createEvent({
|
|
461
|
+
id: "oversized-sender-2",
|
|
462
|
+
content: "ciphertext-too-large",
|
|
463
|
+
}),
|
|
464
|
+
);
|
|
465
|
+
await emitEvent(
|
|
466
|
+
createEvent({
|
|
467
|
+
id: "allowed-after-oversized",
|
|
468
|
+
content: "ok",
|
|
469
|
+
}),
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
expect(bus.getMetrics().eventsRejected.oversizedCiphertext).toBe(2);
|
|
473
|
+
expect(bus.getMetrics().eventsRejected.rateLimited).toBe(0);
|
|
474
|
+
expect(mockState.verifyEvent).toHaveBeenCalledTimes(1);
|
|
475
|
+
expect(mockState.decrypt).toHaveBeenCalledTimes(1);
|
|
476
|
+
expect(onMessage).toHaveBeenCalledTimes(1);
|
|
477
|
+
|
|
478
|
+
bus.close();
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("rejects far-future events before crypto", async () => {
|
|
482
|
+
const onMessage = vi.fn(async () => {});
|
|
483
|
+
const bus = await startNostrBus({
|
|
484
|
+
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
485
|
+
onMessage,
|
|
486
|
+
onMetric: () => {},
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
await emitEvent(
|
|
490
|
+
createEvent({
|
|
491
|
+
created_at: Math.floor(Date.now() / 1000) + 600,
|
|
492
|
+
}),
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
const snapshot = bus.getMetrics();
|
|
496
|
+
expect(snapshot.eventsRejected.future).toBe(1);
|
|
497
|
+
expect(mockState.verifyEvent).not.toHaveBeenCalled();
|
|
498
|
+
expect(mockState.decrypt).not.toHaveBeenCalled();
|
|
499
|
+
expect(onMessage).not.toHaveBeenCalled();
|
|
500
|
+
|
|
501
|
+
bus.close();
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("rejects oversized ciphertext before verify/decrypt", async () => {
|
|
505
|
+
const onMessage = vi.fn(async () => {});
|
|
506
|
+
const bus = await startNostrBus({
|
|
507
|
+
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
508
|
+
onMessage,
|
|
509
|
+
onMetric: () => {},
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
await emitEvent(
|
|
513
|
+
createEvent({
|
|
514
|
+
content: "x".repeat(20_000),
|
|
515
|
+
}),
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
const snapshot = bus.getMetrics();
|
|
519
|
+
expect(snapshot.eventsRejected.oversizedCiphertext).toBe(1);
|
|
520
|
+
expect(mockState.verifyEvent).not.toHaveBeenCalled();
|
|
521
|
+
expect(mockState.decrypt).not.toHaveBeenCalled();
|
|
522
|
+
expect(onMessage).not.toHaveBeenCalled();
|
|
523
|
+
|
|
524
|
+
bus.close();
|
|
525
|
+
});
|
|
526
|
+
});
|