Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add eventVerifier to fetch options (take 2) #332

Merged
merged 6 commits into from
Oct 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/kernel/src/fetcherBackend.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { LogLevel } from "./debugLogger";
import type { Filter, NostrEvent } from "./nostr";
import type { EventVerifier, Filter, NostrEvent } from "./nostr";

export type EnsureRelaysOptions = {
connectTimeoutMs: number;
};

export type FetchTillEoseOptions = {
subId?: string;
eventVerifier: EventVerifier;
skipVerification: boolean;
skipFilterMatching: boolean;
connectTimeoutMs: number;
Expand Down
2 changes: 2 additions & 0 deletions packages/kernel/src/nostr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@ const is64BytesHexStr = (s: string): boolean => {
return /^[a-f0-9]{128}$/.test(s);
};

export type EventVerifier = (event: NostrEvent) => boolean | Promise<boolean>;

type CompiledFilter = {
ids: Set<string> | undefined;
kinds: Set<number> | undefined;
Expand Down
14 changes: 11 additions & 3 deletions packages/nostr-fetch/src/fetcher.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { pubkeyFromAuthorName } from "@nostr-fetch/testutil/fakeEvent";
import { createdAtDesc } from "./fetcherHelper";
import { FakedFetcherBuilder } from "./testutil/fakedFetcher";

import { assert, describe, expect, test } from "vitest";
import { verifyEventSig } from "@nostr-fetch/kernel/crypto";
import { assert, describe, expect, test, vi } from "vitest";

/* Tests */
describe.concurrent("NostrFetcher", () => {
Expand Down Expand Up @@ -306,11 +307,18 @@ describe.concurrent("NostrFetcher", () => {
expect(snd).toEqual(expect.arrayContaining(["wss://dup1/", "wss://dup2/"]));
});

test("verifies signature by default", async () => {
const evIter = fetcher.allEventsIterator(["wss://healthy/", "wss://invalid-sig/"], {}, {});
test("verifies signature by default, with specified verifier", async () => {
const spyEventVerifier = vi.fn((ev: NostrEvent) => Promise.resolve(verifyEventSig(ev)));
const evIter = fetcher.allEventsIterator(
["wss://healthy/", "wss://invalid-sig/"],
{},
{},
{ eventVerifier: spyEventVerifier },
);
const evs = await collectAsyncIter(evIter);
expect(evs.length).toBe(10);
assert(evs.every(({ content }) => content.includes("healthy")));
expect(spyEventVerifier).toBeCalled();
});

test("skips signature verification if skipVerification is true", async () => {
Expand Down
44 changes: 35 additions & 9 deletions packages/nostr-fetch/src/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
isFetchTillEoseAbortedSignal,
isFetchTillEoseFailedSignal,
} from "@nostr-fetch/kernel/fetcherBackend";
import { type NostrEvent, isValidTagQueryKey } from "@nostr-fetch/kernel/nostr";
import { type EventVerifier, type NostrEvent, isValidTagQueryKey } from "@nostr-fetch/kernel/nostr";
import { abbreviate, currUnixtimeSec, normalizeRelayUrlSet } from "@nostr-fetch/kernel/utils";

import {
Expand Down Expand Up @@ -101,6 +101,31 @@ export type NostrEventWithAuthor<SeenOn extends boolean> = {
* Common options for all the fetch methods.
*/
export type FetchOptions<SeenOn extends boolean = false> = {
/**
* If specified, the fetcher uses the given function as an event signature verifier instead of the default one.
*
* The function must return `true` if the event signature is valid. Otherwise, it should return `false`.
*
* @example
* // How to use nostr-wasm's verifyEvent() with nostr-fetch
* import { NostrFetcher, type NostrEvent } from "nostr-fetch";
* import { initNostrWasm } from "nostr-wasm";
*
* const nw = await initNostrWasm();
* const nwVerifyEvent = (ev: NostrEvent) => {
* try {
* nw.verifyEvent(ev);
* return true;
* } catch {
* return false;
* }
* };
* const fetcher = NostrFetcher.init({
* eventVerifier: nwVerifyEvent,
* });
*/
eventVerifier?: EventVerifier;

/**
* If true, the fetcher skips event signature verification.
*
Expand Down Expand Up @@ -173,6 +198,7 @@ export type FetchOptions<SeenOn extends boolean = false> = {
};

const defaultFetchOptions: Required<FetchOptions> = {
eventVerifier: verifyEventSig,
skipVerification: false,
skipFilterMatching: false,
withSeenOn: false,
Expand Down Expand Up @@ -836,15 +862,15 @@ export class NostrFetcher {
evs.sort(createdAtDesc);

// take latest events
const res = (() => {
const res = (async () => {
// return latest `limit` events if not "reduced verification mode"
if (finalOpts.skipVerification || !finalOpts.reduceVerification) {
return evs.slice(0, limit);
}
// reduced verification: return latest `limit` events whose signature is valid
const verified: NostrEvent[] = [];
for (const ev of evs) {
if (verifyEventSig(ev)) {
if (await finalOpts.eventVerifier(ev)) {
verified.push(ev);
if (verified.length >= limit) {
break;
Expand All @@ -855,10 +881,10 @@ export class NostrFetcher {
})();

if (!finalOpts.withSeenOn) {
return res as NostrEventExt<SeenOn>[];
return (await res) as NostrEventExt<SeenOn>[];
}
// append "seen on" data to events if `withSeenOn` is true.
return res.map((e) => {
return (await res).map((e) => {
return { ...e, seenOn: globalSeenEvents.getSeenOn(e.id) };
}) as NostrEventExt<SeenOn>[];
}
Expand Down Expand Up @@ -1195,7 +1221,7 @@ export class NostrFetcher {
evsDeduped.sort(createdAtDesc);

// take latest events
const res = (() => {
const res = (async () => {
// return latest `limit` events if not "reduced verification mode"
if (options.skipVerification || !options.reduceVerification) {
return evsDeduped.slice(0, limit);
Expand All @@ -1204,7 +1230,7 @@ export class NostrFetcher {
// reduced verification: return latest `limit` events whose signature is valid
const verified = [];
for (const ev of evsDeduped) {
if (verifyEventSig(ev)) {
if (await options.eventVerifier(ev)) {
verified.push(ev);
if (verified.length >= limit) {
break;
Expand All @@ -1219,12 +1245,12 @@ export class NostrFetcher {
// append "seen on" data to events if `withSeenOn` is true.
tx.send({
key,
events: res.map((e) => {
events: (await res).map((e) => {
return { ...e, seenOn: globalSeenEvents.getSeenOn(e.id) };
}) as NostrEventExt<SeenOn>[],
});
} else {
tx.send({ key, events: res as NostrEventExt<SeenOn>[] });
tx.send({ key, events: (await res) as NostrEventExt<SeenOn>[] });
}
statsMngr?.addProgress(1);
}),
Expand Down
8 changes: 7 additions & 1 deletion packages/nostr-fetch/src/fetcherBackend.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { collectAsyncIterUntilThrow } from "@nostr-fetch/testutil/asyncIter";
import { setupMockRelayServer } from "@nostr-fetch/testutil/mockRelayServer";
import { DefaultFetcherBackend } from "./fetcherBackend";

import { verifyEventSig } from "@nostr-fetch/kernel/crypto";
import type { NostrEvent } from "@nostr-fetch/kernel/nostr";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import WS from "vitest-websocket-mock";
import WebSocket from "ws";
Expand All @@ -16,6 +18,7 @@ vi.mock("ws");
describe("DefaultFetcherBackend", () => {
describe("fetchTillEose", () => {
const defaultOpts: FetchTillEoseOptions = {
eventVerifier: verifyEventSig,
abortSignal: undefined,
abortSubBeforeEoseTimeoutMs: 5000,
connectTimeoutMs: 1000,
Expand Down Expand Up @@ -45,14 +48,17 @@ describe("DefaultFetcherBackend", () => {

test("fetches events until EOSE", async () => {
setupMockRelayServer(wsServer, [{ type: "events", eventsSpec: { content: "test", n: 10 } }]);
const eventVerifier = vi.fn((ev: NostrEvent) => verifyEventSig(ev));

await backend.ensureRelays([url], { connectTimeoutMs: 1000 });
const iter = backend.fetchTillEose(url, {}, defaultOpts);
const iter = backend.fetchTillEose(url, {}, optsWithDefault({ eventVerifier }));
const evs = await collectAsyncIterUntilThrow(iter);
expect(evs.length).toBe(10);

await expect(wsServer).toReceiveMessage(["REQ", "test", {}]);
await expect(wsServer).toReceiveMessage(["CLOSE", "test"]);

expect(eventVerifier).toBeCalled();
});

test("handles subscription close by relay w/ CLOSED message", async () => {
Expand Down
68 changes: 30 additions & 38 deletions packages/nostr-fetch/src/relay.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import type {
SubClosedCb,
SubEoseCb,
SubEventCb,
SubscriptionOptions,
} from "./relay";
import { initRelay } from "./relay";

import { setTimeout as delay } from "node:timers/promises";
import { verifyEventSig } from "@nostr-fetch/kernel/crypto";
import type { NostrEvent } from "@nostr-fetch/kernel/nostr";
import { type WSCloseEvent, WebSocketReadyState } from "@nostr-fetch/kernel/webSocket";

import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import WS from "vitest-websocket-mock";
import WebSocket from "ws";
Expand Down Expand Up @@ -153,6 +155,19 @@ describe("Relay", () => {
describe("subscription", () => {
const rurl = "ws://localhost:8000";

const defaultOpts: SubscriptionOptions = {
eventVerifier: verifyEventSig,
skipVerification: false,
skipFilterMatching: false,
abortSubBeforeEoseTimeoutMs: 1000,
};
const optsWithDefault = (opts: Partial<SubscriptionOptions>) => {
return {
...defaultOpts,
...opts,
};
};

let server: WS;
let spyCbs: {
event: SubEventCb;
Expand All @@ -179,12 +194,11 @@ describe("Relay", () => {
await r.connect();

const waitEose = new Deferred<void>();
const sub = r.prepareSub([{}], {
skipVerification: false,
skipFilterMatching: false,
abortSubBeforeEoseTimeoutMs: 1000,
subId: "normal",
});

// mock it to check if the eventVerifier is actually called
const eventVerifier = vi.fn((ev: NostrEvent) => verifyEventSig(ev));

const sub = r.prepareSub([{}], optsWithDefault({ eventVerifier, subId: "normal" }));
sub.on("event", spyCbs.event);
sub.on("eose", spyCbs.eose);
sub.on("eose", () => waitEose.resolve());
Expand All @@ -202,6 +216,9 @@ describe("Relay", () => {
expect(spyCbs.event).toBeCalledTimes(5);
expect(spyCbs.eose).toBeCalledTimes(1);
expect(spyCbs.closed).not.toBeCalled();

// specified eventVerifier should be called for each event
expect(eventVerifier).toBeCalledTimes(5);
});

test("CLOSED by relay", async () => {
Expand All @@ -212,12 +229,7 @@ describe("Relay", () => {

const waitClosed = new Deferred<void>();

const sub = r.prepareSub([{}], {
skipVerification: false,
skipFilterMatching: false,
abortSubBeforeEoseTimeoutMs: 1000,
subId: "malformed_sub",
});
const sub = r.prepareSub([{}], defaultOpts);
sub.on("event", spyCbs.event);
sub.on("eose", spyCbs.eose);
sub.on("closed", spyCbs.closed);
Expand All @@ -243,11 +255,7 @@ describe("Relay", () => {
await r.connect();

const waitEose = new Deferred<void>();
const sub = r.prepareSub([{}], {
skipVerification: false,
skipFilterMatching: false,
abortSubBeforeEoseTimeoutMs: 1000,
});
const sub = r.prepareSub([{}], defaultOpts);
sub.on("event", spyCbs.event);
sub.on("eose", spyCbs.eose);
sub.on("eose", () => waitEose.resolve());
Expand All @@ -270,11 +278,7 @@ describe("Relay", () => {
await r.connect();

const waitEose = new Deferred<void>();
const sub = r.prepareSub([{}], {
skipVerification: false,
skipFilterMatching: false,
abortSubBeforeEoseTimeoutMs: 1000,
});
const sub = r.prepareSub([{}], defaultOpts);
sub.on("event", spyCbs.event);
sub.on("eose", () => waitEose.resolve());

Expand All @@ -295,11 +299,7 @@ describe("Relay", () => {
await r.connect();

const waitEose = new Deferred<void>();
const sub = r.prepareSub([{}], {
skipVerification: true,
skipFilterMatching: false,
abortSubBeforeEoseTimeoutMs: 1000,
});
const sub = r.prepareSub([{}], optsWithDefault({ skipVerification: true }));
sub.on("event", spyCbs.event);
sub.on("eose", () => waitEose.resolve());

Expand All @@ -320,11 +320,7 @@ describe("Relay", () => {
await r.connect();

const waitEose = new Deferred<void>();
const sub = r.prepareSub([{ kinds: [1] }], {
skipVerification: false,
skipFilterMatching: false,
abortSubBeforeEoseTimeoutMs: 1000,
});
const sub = r.prepareSub([{ kinds: [1] }], defaultOpts);
sub.on("event", spyCbs.event);
sub.on("eose", () => waitEose.resolve());

Expand All @@ -345,11 +341,7 @@ describe("Relay", () => {
await r.connect();

const waitEose = new Deferred<void>();
const sub = r.prepareSub([{ kinds: [1] }], {
skipVerification: false,
skipFilterMatching: true,
abortSubBeforeEoseTimeoutMs: 1000,
});
const sub = r.prepareSub([{ kinds: [1] }], optsWithDefault({ skipFilterMatching: true }));
sub.on("event", spyCbs.event);
sub.on("eose", () => waitEose.resolve());

Expand Down
Loading