Vendor OpenClaw source as Adolf fork baseline
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
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
This commit is contained in:
598
extensions/nostr/src/nostr-bus.inbound.test.ts
Normal file
598
extensions/nostr/src/nostr-bus.inbound.test.ts
Normal file
@@ -0,0 +1,598 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user