Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
599 lines
17 KiB
TypeScript
599 lines
17 KiB
TypeScript
// Nostr tests cover nostr bus.inbound plugin behavior.
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { startNostrBus } from "./nostr-bus.js";
|
|
import { TEST_HEX_PRIVATE_KEY } from "./test-fixtures.js";
|
|
|
|
const BOT_PUBKEY = "b".repeat(64);
|
|
|
|
const mockState = vi.hoisted(() => ({
|
|
handlers: null as {
|
|
onevent: (event: Record<string, unknown>) => void | Promise<void>;
|
|
oneose?: () => void;
|
|
onclose?: (reason: string[]) => void;
|
|
} | null,
|
|
subscribeMany: vi.fn(),
|
|
close: vi.fn(),
|
|
subscriptionClose: vi.fn(),
|
|
verifyEvent: vi.fn(() => true),
|
|
decrypt: vi.fn(() => "plaintext"),
|
|
publishProfile: vi.fn(async () => ({
|
|
createdAt: 0,
|
|
eventId: "profile-event",
|
|
successes: [],
|
|
failures: [],
|
|
})),
|
|
}));
|
|
|
|
vi.mock("nostr-tools", () => {
|
|
class MockSimplePool {
|
|
subscribeMany(
|
|
relays: string[],
|
|
filters: unknown,
|
|
handlers: {
|
|
onevent: (event: Record<string, unknown>) => void | Promise<void>;
|
|
oneose?: () => void;
|
|
onclose?: (reason: string[]) => void;
|
|
},
|
|
) {
|
|
mockState.subscribeMany(relays, filters, handlers);
|
|
mockState.handlers = handlers;
|
|
return {
|
|
close: mockState.subscriptionClose,
|
|
};
|
|
}
|
|
|
|
publish = vi.fn(async () => {});
|
|
|
|
close(relays: string[]) {
|
|
mockState.close(relays);
|
|
}
|
|
}
|
|
|
|
return {
|
|
SimplePool: MockSimplePool,
|
|
finalizeEvent: vi.fn((event: unknown) => event),
|
|
getPublicKey: vi.fn(() => BOT_PUBKEY),
|
|
verifyEvent: mockState.verifyEvent,
|
|
nip19: {
|
|
decode: vi.fn(),
|
|
npubEncode: vi.fn((value: string) => `npub-${value}`),
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("nostr-tools/nip04", () => ({
|
|
decrypt: mockState.decrypt,
|
|
encrypt: vi.fn(() => "ciphertext"),
|
|
}));
|
|
|
|
vi.mock("./nostr-state-store.js", () => ({
|
|
readNostrBusState: vi.fn(async () => null),
|
|
writeNostrBusState: vi.fn(async () => {}),
|
|
computeSinceTimestamp: vi.fn(() => 0),
|
|
readNostrProfileState: vi.fn(async () => null),
|
|
writeNostrProfileState: vi.fn(async () => {}),
|
|
}));
|
|
|
|
vi.mock("./nostr-profile.js", () => ({
|
|
publishProfile: mockState.publishProfile,
|
|
}));
|
|
|
|
function createEvent(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: "event-1",
|
|
kind: 4,
|
|
pubkey: "a".repeat(64),
|
|
content: "ciphertext",
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [["p", BOT_PUBKEY]],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function emitEvent(event: Record<string, unknown>) {
|
|
if (!mockState.handlers) {
|
|
throw new Error("missing subscription handlers");
|
|
}
|
|
await mockState.handlers.onevent(event);
|
|
}
|
|
|
|
describe("startNostrBus inbound guards", () => {
|
|
beforeEach(() => {
|
|
mockState.handlers = null;
|
|
mockState.subscribeMany.mockClear();
|
|
mockState.close.mockClear();
|
|
mockState.subscriptionClose.mockReset();
|
|
mockState.verifyEvent.mockClear();
|
|
mockState.verifyEvent.mockReturnValue(true);
|
|
mockState.decrypt.mockClear();
|
|
mockState.decrypt.mockReturnValue("plaintext");
|
|
});
|
|
|
|
afterEach(() => {
|
|
mockState.handlers = null;
|
|
});
|
|
|
|
it("subscribes to DMs with a single Nostr filter object", async () => {
|
|
const bus = await startNostrBus({
|
|
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
onMessage: vi.fn(async () => {}),
|
|
onMetric: () => {},
|
|
});
|
|
|
|
expect(mockState.subscribeMany).toHaveBeenCalledTimes(1);
|
|
const filters = mockState.subscribeMany.mock.calls[0]?.[1];
|
|
expect(Array.isArray(filters)).toBe(false);
|
|
expect(filters).toMatchObject({
|
|
kinds: [4],
|
|
"#p": [BOT_PUBKEY],
|
|
since: 0,
|
|
});
|
|
|
|
bus.close();
|
|
});
|
|
|
|
it("closes the relay pool when the bus closes", async () => {
|
|
const bus = await startNostrBus({
|
|
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
relays: ["wss://relay.example"],
|
|
onMessage: vi.fn(async () => {}),
|
|
onMetric: () => {},
|
|
});
|
|
|
|
bus.close();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(mockState.close).toHaveBeenCalledWith(["wss://relay.example"]);
|
|
});
|
|
});
|
|
|
|
it("closes the relay pool after the active subscription closes", async () => {
|
|
let releaseClose = () => {};
|
|
const subscriptionClosed = new Promise<void>((resolve) => {
|
|
releaseClose = resolve;
|
|
});
|
|
mockState.subscriptionClose.mockImplementationOnce(async () => {
|
|
await subscriptionClosed;
|
|
});
|
|
const bus = await startNostrBus({
|
|
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
relays: ["wss://relay.example"],
|
|
onMessage: vi.fn(async () => {}),
|
|
onMetric: () => {},
|
|
});
|
|
|
|
bus.close();
|
|
|
|
expect(mockState.subscriptionClose).toHaveBeenCalledWith("closed by caller");
|
|
expect(mockState.close).not.toHaveBeenCalled();
|
|
|
|
releaseClose();
|
|
await vi.waitFor(() => {
|
|
expect(mockState.close).toHaveBeenCalledWith(["wss://relay.example"]);
|
|
});
|
|
});
|
|
|
|
it("checks sender authorization after verify and before decrypt", async () => {
|
|
const onMessage = vi.fn(async () => {});
|
|
const authorizeSender = vi.fn(async () => "block" as const);
|
|
const bus = await startNostrBus({
|
|
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
onMessage,
|
|
authorizeSender,
|
|
onMetric: () => {},
|
|
});
|
|
|
|
await emitEvent(createEvent());
|
|
|
|
expect(authorizeSender).toHaveBeenCalledTimes(1);
|
|
expect(mockState.verifyEvent).toHaveBeenCalledTimes(1);
|
|
expect(mockState.decrypt).not.toHaveBeenCalled();
|
|
expect(onMessage).not.toHaveBeenCalled();
|
|
expect(bus.getMetrics().eventsReceived).toBe(1);
|
|
|
|
bus.close();
|
|
});
|
|
|
|
it("rejects invalid signatures before sender authorization", async () => {
|
|
mockState.verifyEvent.mockReturnValueOnce(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: () => {},
|
|
});
|
|
|
|
await emitEvent(createEvent());
|
|
|
|
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);
|
|
|
|
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({
|
|
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
onMessage,
|
|
onMetric: () => {},
|
|
});
|
|
|
|
for (let i = 0; i < 21; i += 1) {
|
|
await emitEvent(
|
|
createEvent({
|
|
id: `event-${i}`,
|
|
}),
|
|
);
|
|
}
|
|
|
|
const snapshot = bus.getMetrics();
|
|
expect(snapshot.eventsRejected.rateLimited).toBe(1);
|
|
expect(mockState.decrypt).toHaveBeenCalledTimes(20);
|
|
expect(onMessage).toHaveBeenCalledTimes(20);
|
|
|
|
bus.close();
|
|
});
|
|
|
|
it("does not let a blocked sender starve a different verified sender", async () => {
|
|
const onMessage = vi.fn(async () => {});
|
|
const authorizeSender = vi.fn(async ({ senderPubkey }: { senderPubkey: string }) =>
|
|
senderPubkey.startsWith("blocked") ? ("block" as const) : ("allow" as const),
|
|
);
|
|
const bus = await startNostrBus({
|
|
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
onMessage,
|
|
authorizeSender,
|
|
onMetric: () => {},
|
|
guardPolicy: {
|
|
rateLimit: {
|
|
windowMs: 60_000,
|
|
maxGlobalPerWindow: 2,
|
|
maxPerSenderPerWindow: 1,
|
|
maxTrackedSenderKeys: 32,
|
|
},
|
|
},
|
|
});
|
|
|
|
await emitEvent(
|
|
createEvent({
|
|
id: "blocked-event",
|
|
pubkey: `blocked${"a".repeat(57)}`,
|
|
}),
|
|
);
|
|
await emitEvent(
|
|
createEvent({
|
|
id: "allowed-event",
|
|
pubkey: `allowed${"b".repeat(57)}`,
|
|
}),
|
|
);
|
|
|
|
expect(authorizeSender).toHaveBeenCalledTimes(2);
|
|
expect(mockState.decrypt).toHaveBeenCalledTimes(1);
|
|
expect(onMessage).toHaveBeenCalledTimes(1);
|
|
expect(bus.getMetrics().eventsRejected.rateLimited).toBe(0);
|
|
|
|
bus.close();
|
|
});
|
|
|
|
it("dedupes replayed verified events that authorization blocks", async () => {
|
|
const onMessage = vi.fn(async () => {});
|
|
const authorizeSender = vi.fn(async () => "block" as const);
|
|
const bus = await startNostrBus({
|
|
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
onMessage,
|
|
authorizeSender,
|
|
onMetric: () => {},
|
|
});
|
|
|
|
const blockedEvent = createEvent({
|
|
id: "blocked-replay",
|
|
pubkey: `blocked${"a".repeat(57)}`,
|
|
});
|
|
|
|
await emitEvent(blockedEvent);
|
|
await emitEvent(blockedEvent);
|
|
|
|
expect(mockState.verifyEvent).toHaveBeenCalledTimes(1);
|
|
expect(authorizeSender).toHaveBeenCalledTimes(1);
|
|
expect(mockState.decrypt).not.toHaveBeenCalled();
|
|
expect(onMessage).not.toHaveBeenCalled();
|
|
|
|
bus.close();
|
|
});
|
|
|
|
it("retries a replayed event after the message handler fails", async () => {
|
|
const onMessage = vi
|
|
.fn<(sender: string, plaintext: string) => Promise<void>>()
|
|
.mockRejectedValueOnce(new Error("boom"))
|
|
.mockResolvedValueOnce(undefined);
|
|
const bus = await startNostrBus({
|
|
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
onMessage,
|
|
onMetric: () => {},
|
|
});
|
|
|
|
const event = createEvent({
|
|
id: "retry-after-handler-failure",
|
|
});
|
|
|
|
await emitEvent(event);
|
|
await emitEvent(event);
|
|
|
|
expect(mockState.verifyEvent).toHaveBeenCalledTimes(2);
|
|
expect(mockState.decrypt).toHaveBeenCalledTimes(2);
|
|
expect(onMessage).toHaveBeenCalledTimes(2);
|
|
expect(bus.getMetrics().eventsProcessed).toBe(1);
|
|
|
|
bus.close();
|
|
});
|
|
|
|
it("does not rate limit an allowed sender while another authorization is still pending", async () => {
|
|
const onMessage = vi.fn(async () => {});
|
|
let resolveBlocked: ((value: "block") => void) | undefined;
|
|
const blockedPromise = new Promise<"block">((resolve) => {
|
|
resolveBlocked = resolve;
|
|
});
|
|
const authorizeSender = vi
|
|
.fn<(params: { senderPubkey: string }) => Promise<"allow" | "block" | "pairing">>()
|
|
.mockImplementationOnce(async () => await blockedPromise)
|
|
.mockResolvedValueOnce("allow");
|
|
const bus = await startNostrBus({
|
|
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
onMessage,
|
|
authorizeSender,
|
|
onMetric: () => {},
|
|
guardPolicy: {
|
|
rateLimit: {
|
|
windowMs: 60_000,
|
|
maxGlobalPerWindow: 2,
|
|
maxPerSenderPerWindow: 1,
|
|
maxTrackedSenderKeys: 32,
|
|
},
|
|
},
|
|
});
|
|
|
|
const blockedEventPromise = emitEvent(
|
|
createEvent({
|
|
id: "blocked-pending",
|
|
pubkey: `blocked${"a".repeat(57)}`,
|
|
}),
|
|
);
|
|
await emitEvent(
|
|
createEvent({
|
|
id: "allowed-during-pending-auth",
|
|
pubkey: `allowed${"b".repeat(57)}`,
|
|
}),
|
|
);
|
|
resolveBlocked?.("block");
|
|
await blockedEventPromise;
|
|
|
|
expect(authorizeSender).toHaveBeenCalledTimes(2);
|
|
expect(mockState.decrypt).toHaveBeenCalledTimes(1);
|
|
expect(onMessage).toHaveBeenCalledTimes(1);
|
|
expect(bus.getMetrics().eventsRejected.rateLimited).toBe(0);
|
|
|
|
bus.close();
|
|
});
|
|
|
|
it("rate limits repeated invalid signatures before authorization work fans out", 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: () => {},
|
|
guardPolicy: {
|
|
rateLimit: {
|
|
windowMs: 60_000,
|
|
maxGlobalPerWindow: 1,
|
|
maxPerSenderPerWindow: 10,
|
|
maxTrackedSenderKeys: 32,
|
|
},
|
|
},
|
|
});
|
|
|
|
await emitEvent(createEvent({ id: "invalid-1" }));
|
|
await emitEvent(createEvent({ id: "invalid-2" }));
|
|
|
|
expect(mockState.verifyEvent).toHaveBeenCalledTimes(1);
|
|
expect(authorizeSender).not.toHaveBeenCalled();
|
|
expect(bus.getMetrics().eventsRejected.invalidSignature).toBe(1);
|
|
expect(bus.getMetrics().eventsRejected.rateLimited).toBe(1);
|
|
|
|
bus.close();
|
|
});
|
|
|
|
it("counts oversized ciphertext toward the global inbound rate limit", async () => {
|
|
const onMessage = vi.fn(async () => {});
|
|
const bus = await startNostrBus({
|
|
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
onMessage,
|
|
onMetric: () => {},
|
|
guardPolicy: {
|
|
maxCiphertextBytes: 4,
|
|
rateLimit: {
|
|
windowMs: 60_000,
|
|
maxGlobalPerWindow: 1,
|
|
maxPerSenderPerWindow: 10,
|
|
maxTrackedSenderKeys: 32,
|
|
},
|
|
},
|
|
});
|
|
|
|
await emitEvent(
|
|
createEvent({
|
|
id: "oversized-global-1",
|
|
pubkey: `sender1${"a".repeat(57)}`,
|
|
content: "ciphertext-too-large",
|
|
}),
|
|
);
|
|
await emitEvent(
|
|
createEvent({
|
|
id: "oversized-global-2",
|
|
pubkey: `sender2${"b".repeat(57)}`,
|
|
content: "ciphertext-too-large",
|
|
}),
|
|
);
|
|
|
|
expect(bus.getMetrics().eventsRejected.oversizedCiphertext).toBe(1);
|
|
expect(bus.getMetrics().eventsRejected.rateLimited).toBe(1);
|
|
expect(mockState.verifyEvent).not.toHaveBeenCalled();
|
|
expect(mockState.decrypt).not.toHaveBeenCalled();
|
|
expect(onMessage).not.toHaveBeenCalled();
|
|
|
|
bus.close();
|
|
});
|
|
|
|
it("does not spend per-sender buckets on oversized ciphertext before verification", async () => {
|
|
const onMessage = vi.fn(async () => {});
|
|
const bus = await startNostrBus({
|
|
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
onMessage,
|
|
onMetric: () => {},
|
|
guardPolicy: {
|
|
maxCiphertextBytes: 4,
|
|
rateLimit: {
|
|
windowMs: 60_000,
|
|
maxGlobalPerWindow: 10,
|
|
maxPerSenderPerWindow: 1,
|
|
maxTrackedSenderKeys: 32,
|
|
},
|
|
},
|
|
});
|
|
|
|
await emitEvent(
|
|
createEvent({
|
|
id: "oversized-sender-1",
|
|
content: "ciphertext-too-large",
|
|
}),
|
|
);
|
|
await emitEvent(
|
|
createEvent({
|
|
id: "oversized-sender-2",
|
|
content: "ciphertext-too-large",
|
|
}),
|
|
);
|
|
await emitEvent(
|
|
createEvent({
|
|
id: "allowed-after-oversized",
|
|
content: "ok",
|
|
}),
|
|
);
|
|
|
|
expect(bus.getMetrics().eventsRejected.oversizedCiphertext).toBe(2);
|
|
expect(bus.getMetrics().eventsRejected.rateLimited).toBe(0);
|
|
expect(mockState.verifyEvent).toHaveBeenCalledTimes(1);
|
|
expect(mockState.decrypt).toHaveBeenCalledTimes(1);
|
|
expect(onMessage).toHaveBeenCalledTimes(1);
|
|
|
|
bus.close();
|
|
});
|
|
|
|
it("rejects far-future events before crypto", async () => {
|
|
const onMessage = vi.fn(async () => {});
|
|
const bus = await startNostrBus({
|
|
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
onMessage,
|
|
onMetric: () => {},
|
|
});
|
|
|
|
await emitEvent(
|
|
createEvent({
|
|
created_at: Math.floor(Date.now() / 1000) + 600,
|
|
}),
|
|
);
|
|
|
|
const snapshot = bus.getMetrics();
|
|
expect(snapshot.eventsRejected.future).toBe(1);
|
|
expect(mockState.verifyEvent).not.toHaveBeenCalled();
|
|
expect(mockState.decrypt).not.toHaveBeenCalled();
|
|
expect(onMessage).not.toHaveBeenCalled();
|
|
|
|
bus.close();
|
|
});
|
|
|
|
it("rejects oversized ciphertext before verify/decrypt", async () => {
|
|
const onMessage = vi.fn(async () => {});
|
|
const bus = await startNostrBus({
|
|
privateKey: TEST_HEX_PRIVATE_KEY,
|
|
onMessage,
|
|
onMetric: () => {},
|
|
});
|
|
|
|
await emitEvent(
|
|
createEvent({
|
|
content: "x".repeat(20_000),
|
|
}),
|
|
);
|
|
|
|
const snapshot = bus.getMetrics();
|
|
expect(snapshot.eventsRejected.oversizedCiphertext).toBe(1);
|
|
expect(mockState.verifyEvent).not.toHaveBeenCalled();
|
|
expect(mockState.decrypt).not.toHaveBeenCalled();
|
|
expect(onMessage).not.toHaveBeenCalled();
|
|
|
|
bus.close();
|
|
});
|
|
});
|