From 05605bdf28e722be5e29c7ebca3744969e7dc2e2 Mon Sep 17 00:00:00 2001 From: sistemd Date: Mon, 27 Mar 2023 11:09:48 +0200 Subject: [PATCH] `nostr` package: vastly simplify the API (#412) * vastly simplify the api * add missing await * add eose to emitter * add eose to conn * add eose to the client * eose test * improve test suite, add dm tests * demonstrate that nostr-rs-relay auth options don't work * readme files * cleanup * fetch relay info * test readyState * export fetchRelayInfo * cleanup * better async/await linting * use strictEqual in tests * additional eslint rules * allow arbitrary extensions * saner error handling * update README * implement nip-02 --------- Co-authored-by: Kieran --- packages/nostr/.eslintrc.cjs | 6 +- packages/nostr/README.md | 66 ++++ packages/nostr/docker-compose.yaml | 4 +- packages/nostr/package.json | 11 +- packages/nostr/relay/.dockerignore | 1 + packages/nostr/relay/.gitignore | 1 + packages/nostr/relay/Dockerfile | 12 + packages/nostr/relay/config.toml | 11 + packages/nostr/relay/index.ts | 26 ++ packages/nostr/relay/package.json | 14 + packages/nostr/src/client/conn.ts | 297 ++++++++-------- packages/nostr/src/client/emitter.ts | 66 +++- packages/nostr/src/client/index.ts | 298 +++++++++------- packages/nostr/src/client/relay.ts | 142 ++++++++ packages/nostr/src/common.ts | 42 +++ packages/nostr/src/crypto.ts | 182 ++++------ packages/nostr/src/error.ts | 10 - packages/nostr/src/event.ts | 366 -------------------- packages/nostr/src/event/contact-list.ts | 83 +++++ packages/nostr/src/event/direct-message.ts | 124 +++++++ packages/nostr/src/event/index.ts | 212 ++++++++++++ packages/nostr/src/event/set-metadata.ts | 50 +++ packages/nostr/src/event/text.ts | 18 + packages/nostr/src/filters.ts | 81 +++++ packages/nostr/src/util.ts | 18 - packages/nostr/test/contact-list.ts | 56 +++ packages/nostr/test/dm.ts | 128 +++++++ packages/nostr/test/ready-state.ts | 24 ++ packages/nostr/test/relay-info.ts | 26 ++ packages/nostr/test/setup.ts | 98 ++++++ packages/nostr/test/simple-communication.ts | 85 ----- packages/nostr/test/text-note.ts | 75 ++++ 32 files changed, 1758 insertions(+), 875 deletions(-) create mode 100644 packages/nostr/README.md create mode 100644 packages/nostr/relay/.dockerignore create mode 100644 packages/nostr/relay/.gitignore create mode 100644 packages/nostr/relay/Dockerfile create mode 100644 packages/nostr/relay/config.toml create mode 100644 packages/nostr/relay/index.ts create mode 100644 packages/nostr/relay/package.json create mode 100644 packages/nostr/src/client/relay.ts create mode 100644 packages/nostr/src/common.ts delete mode 100644 packages/nostr/src/error.ts delete mode 100644 packages/nostr/src/event.ts create mode 100644 packages/nostr/src/event/contact-list.ts create mode 100644 packages/nostr/src/event/direct-message.ts create mode 100644 packages/nostr/src/event/index.ts create mode 100644 packages/nostr/src/event/set-metadata.ts create mode 100644 packages/nostr/src/event/text.ts create mode 100644 packages/nostr/src/filters.ts delete mode 100644 packages/nostr/src/util.ts create mode 100644 packages/nostr/test/contact-list.ts create mode 100644 packages/nostr/test/dm.ts create mode 100644 packages/nostr/test/ready-state.ts create mode 100644 packages/nostr/test/relay-info.ts create mode 100644 packages/nostr/test/setup.ts delete mode 100644 packages/nostr/test/simple-communication.ts create mode 100644 packages/nostr/test/text-note.ts diff --git a/packages/nostr/.eslintrc.cjs b/packages/nostr/.eslintrc.cjs index 1e571d86..d478b681 100644 --- a/packages/nostr/.eslintrc.cjs +++ b/packages/nostr/.eslintrc.cjs @@ -3,10 +3,14 @@ module.exports = { parser: "@typescript-eslint/parser", plugins: ["@typescript-eslint"], root: true, - ignorePatterns: ["dist/"], + ignorePatterns: ["dist/", "src/legacy"], env: { browser: true, node: true, mocha: true, }, + rules: { + "require-await": "error", + eqeqeq: "error", + }, } diff --git a/packages/nostr/README.md b/packages/nostr/README.md new file mode 100644 index 00000000..b088f31f --- /dev/null +++ b/packages/nostr/README.md @@ -0,0 +1,66 @@ +# `@snort/nostr` + +A strongly-typed nostr client for Node and the browser. + +## NIP support + +### Applicable + +The goal of the project is to have all of the following implemented +and tested against a real-world relay implementation. + +_Progress: 7/34 (20%)._ + +- [X] NIP-01: Basic protocol flow description +- [X] NIP-02: Contact List and Petnames +- [ ] NIP-03: OpenTimestamps Attestations for Events +- [X] NIP-04: Encrypted Direct Message +- [ ] NIP-05: Mapping Nostr keys to DNS-based internet identifiers +- [ ] NIP-06: Basic key derivation from mnemonic seed phrase +- [ ] NIP-07: window.nostr capability for web browsers +- [ ] NIP-08: Handling Mentions +- [ ] NIP-09: Event Deletion +- [ ] NIP-10: Conventions for clients' use of `e` and `p` tags in text events + - TODO Check if this applies +- [X] NIP-11: Relay Information Document +- [X] NIP-12: Generic Tag Queries +- [ ] NIP-13: Proof of Work +- [ ] NIP-14: Subject tag in text events +- [X] NIP-15: End of Stored Events Notice +- [ ] NIP-19: bech32-encoded entities + - [X] `npub` + - [X] `nsec` + - [ ] `note`, `nprofile`, `nevent`, `nrelay`, `naddr` +- [X] NIP-20: Command Results +- [ ] NIP-21: `nostr:` URL scheme +- [ ] NIP-23: Long-form Content +- [ ] NIP-25: Reactions +- [ ] NIP-26: Delegated Event Signing +- [ ] NIP-28: Public Chat +- [ ] NIP-36: Sensitive Content +- [ ] NIP-39: External Identities in Profiles +- [ ] NIP-40: Expiration Timestamp +- [ ] NIP-42: Authentication of clients to relays +- [ ] NIP-46: Nostr Connect + - Not sure how much of this applies, but I sure would love to see WalletConnect disappear +- [ ] NIP-50: Keywords filter +- [ ] NIP-51: Lists +- [ ] NIP-56: Reporting +- [ ] NIP-57: Lightning Zaps +- [ ] NIP-58: Badges +- [ ] NIP-65: Relay List Metadata +- [ ] NIP-78: Application-specific data + +### Not Applicable + +These NIPs only apply to relays and have no implications for a generic nostr client. + +- NIP-16: Event Treatment +- NIP-22: Event `created_at` Limits +- NIP-33: Parameterized Replaceable Events + +### Others + +_If you notice an accepted NIP missing from both lists above, please [open an +issue](https://github.com/v0l/snort/issues/new?assignees=&labels=&template=feature_request.md&title=) +to let us know_. diff --git a/packages/nostr/docker-compose.yaml b/packages/nostr/docker-compose.yaml index 32f5cb51..49470d0f 100644 --- a/packages/nostr/docker-compose.yaml +++ b/packages/nostr/docker-compose.yaml @@ -1,6 +1,8 @@ version: "3.1" services: relay: - image: scsibug/nostr-rs-relay + build: ./relay + restart: on-failure ports: - 12648:8080 + - 12649:8000 diff --git a/packages/nostr/package.json b/packages/nostr/package.json index 7927d439..93c05c5d 100644 --- a/packages/nostr/package.json +++ b/packages/nostr/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "tsc", "watch": "tsc -w", - "test": "ts-mocha --type-check -j 1 test/*.ts", + "test": "ts-mocha --type-check -j 1 --timeout 5s test/*.ts", "lint": "eslint ." }, "devDependencies": { @@ -31,5 +31,12 @@ "events": "^3.3.0", "isomorphic-ws": "^5.0.0", "ws": "^8.12.1" - } + }, + "directories": { + "test": "test" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "" } diff --git a/packages/nostr/relay/.dockerignore b/packages/nostr/relay/.dockerignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/packages/nostr/relay/.dockerignore @@ -0,0 +1 @@ +node_modules/ diff --git a/packages/nostr/relay/.gitignore b/packages/nostr/relay/.gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/packages/nostr/relay/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/packages/nostr/relay/Dockerfile b/packages/nostr/relay/Dockerfile new file mode 100644 index 00000000..5552ffde --- /dev/null +++ b/packages/nostr/relay/Dockerfile @@ -0,0 +1,12 @@ +FROM scsibug/nostr-rs-relay + +USER root +RUN apt-get update && apt-get install -y curl nodejs npm +RUN npm i -g yarn + +EXPOSE 8000 + +COPY . . +USER $APP_USER +RUN yarn +CMD yarn app /bin/bash -c "rm -rf /usr/src/app/db/* && ./nostr-rs-relay --db /usr/src/app/db --config ./config.toml" diff --git a/packages/nostr/relay/config.toml b/packages/nostr/relay/config.toml new file mode 100644 index 00000000..10bacad1 --- /dev/null +++ b/packages/nostr/relay/config.toml @@ -0,0 +1,11 @@ +[info] +relay_url = "wss://nostr.example.com/" +name = "nostr-rs-relay" +description = "nostr-rs-relay description" +contact = "mailto:contact@example.com" +favicon = "favicon.ico" + +[authorization] +nip42_auth = true +# This seems to have no effect. +nip42_dms = true diff --git a/packages/nostr/relay/index.ts b/packages/nostr/relay/index.ts new file mode 100644 index 00000000..1105f157 --- /dev/null +++ b/packages/nostr/relay/index.ts @@ -0,0 +1,26 @@ +/** + * Allows the relay to be shut down with an HTTP request, after which + * docker-compose will restart it. This allows each test to have a clean + * slate. The drawback is that the tests can't run in parallel, so the + * test suite is very slow. A better option would be to have this relay + * server manage the relay completely: star/stop isolated relay instances + * with HTTP requests and allow multiple instances to run at the same + * time so that the tests can be parallelized. + */ + +import http from "node:http" +import { spawn } from "node:child_process" + +const child = spawn(process.argv[2], process.argv.slice(3), { + stdio: "inherit", +}) + +const server = http.createServer((_, res) => { + if (!child.kill(9)) { + console.error("killing the subprocess failed") + } + res.end() + process.exit(1) +}) + +server.listen(8000) diff --git a/packages/nostr/relay/package.json b/packages/nostr/relay/package.json new file mode 100644 index 00000000..d6eba799 --- /dev/null +++ b/packages/nostr/relay/package.json @@ -0,0 +1,14 @@ +{ + "name": "relay", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "scripts": { + "app": "ts-node index.ts" + }, + "dependencies": { + "@types/node": "^18.15.0", + "ts-node": "^10.9.1", + "typescript": "^4.9.5" + } +} diff --git a/packages/nostr/src/client/conn.ts b/packages/nostr/src/client/conn.ts index e35ff618..4584fbe9 100644 --- a/packages/nostr/src/client/conn.ts +++ b/packages/nostr/src/client/conn.ts @@ -1,26 +1,25 @@ -import { ProtocolError } from "../error" -import { Filters, SubscriptionId } from "." -import { EventId, RawEvent, SignedEvent } from "../event" -import WebSocket from "ws" -import { unixTimestamp } from "../util" +import { NostrError, parseJson } from "../common" +import { SubscriptionId } from "." +import { EventId, RawEvent } from "../event" +import WebSocket from "isomorphic-ws" +import { Filters } from "../filters" /** * The connection to a relay. This is the lowest layer of the nostr protocol. * The only responsibility of this type is to send and receive * well-formatted nostr messages on the underlying websocket. All other details of the protocol - * are handled by `Nostr`. + * are handled by `Nostr`. This type does not know anything about event semantics. * * @see Nostr */ export class Conn { readonly #socket: WebSocket + // TODO This should probably be moved to Nostr (ConnState) because deciding whether or not to send a message + // requires looking at relay info which the Conn should know nothing about. /** * Messages which were requested to be sent before the websocket was ready. * Once the websocket becomes ready, these messages will be sent and cleared. */ - // TODO Another reason why pending messages might be required is when the user tries to send a message - // before NIP-44 auth. The legacy code reuses the same array for these two but I think they should be - // different, and the NIP-44 stuff should be handled by Nostr. #pending: OutgoingMessage[] = [] /** * Callback for errors. @@ -34,10 +33,14 @@ export class Conn { constructor({ url, onMessage, + onOpen, + onClose, onError, }: { url: URL - onMessage: (msg: IncomingMessage) => void + onMessage: (msg: IncomingMessage) => Promise + onOpen: () => Promise + onClose: () => void onError: (err: unknown) => void }) { this.#onError = onError @@ -45,40 +48,48 @@ export class Conn { // Handle incoming messages. this.#socket.addEventListener("message", async (msgData) => { - const value = msgData.data.valueOf() - // Validate and parse the message. - if (typeof value !== "string") { - const err = new ProtocolError(`invalid message data: ${value}`) - onError(err) - return - } try { - const msg = await Conn.#parseIncomingMessage(value) - onMessage(msg) + const value = msgData.data.valueOf() + // Validate and parse the message. + if (typeof value !== "string") { + throw new NostrError(`invalid message data: ${value}`) + } + const msg = parseIncomingMessage(value) + await onMessage(msg) } catch (err) { onError(err) } }) // When the connection is ready, send any outstanding messages. - this.#socket.addEventListener("open", () => { - for (const msg of this.#pending) { - this.send(msg) + this.#socket.addEventListener("open", async () => { + try { + for (const msg of this.#pending) { + this.send(msg) + } + this.#pending = [] + await onOpen() + } catch (e) { + onError(e) } - this.#pending = [] }) - this.#socket.addEventListener("error", (err) => { - onError(err) + this.#socket.addEventListener("close", () => { + try { + onClose() + } catch (e) { + onError(e) + } }) + this.#socket.addEventListener("error", onError) } send(msg: OutgoingMessage): void { - if (this.#socket.readyState < WebSocket.OPEN) { - this.#pending.push(msg) - return - } try { + if (this.#socket.readyState < WebSocket.OPEN) { + this.#pending.push(msg) + return + } this.#socket.send(serializeOutgoingMessage(msg), (err) => { if (err !== undefined && err !== null) { this.#onError?.(err) @@ -96,77 +107,19 @@ export class Conn { this.#onError?.(err) } } - - static async #parseIncomingMessage(data: string): Promise { - const json = parseJson(data) - if (!(json instanceof Array)) { - throw new ProtocolError(`incoming message is not an array: ${data}`) - } - if (json.length === 0) { - throw new ProtocolError(`incoming message is an empty array: ${data}`) - } - if (json[0] === "EVENT") { - if (typeof json[1] !== "string") { - throw new ProtocolError( - `second element of "EVENT" should be a string, but wasn't: ${data}` - ) - } - if (typeof json[2] !== "object") { - throw new ProtocolError( - `second element of "EVENT" should be an object, but wasn't: ${data}` - ) - } - const raw = parseEventData(json[2]) - return { - kind: "event", - subscriptionId: new SubscriptionId(json[1]), - raw, - } - } - if (json[0] === "NOTICE") { - if (typeof json[1] !== "string") { - throw new ProtocolError( - `second element of "NOTICE" should be a string, but wasn't: ${data}` - ) - } - return { - kind: "notice", - notice: json[1], - } - } - if (json[0] === "OK") { - if (typeof json[1] !== "string") { - throw new ProtocolError( - `second element of "OK" should be a string, but wasn't: ${data}` - ) - } - if (typeof json[2] !== "boolean") { - throw new ProtocolError( - `third element of "OK" should be a boolean, but wasn't: ${data}` - ) - } - if (typeof json[3] !== "string") { - throw new ProtocolError( - `fourth element of "OK" should be a string, but wasn't: ${data}` - ) - } - return { - kind: "ok", - eventId: new EventId(json[1]), - ok: json[2], - message: json[3], - } - } - throw new ProtocolError(`unknown incoming message: ${data}`) - } } /** * A message sent from a relay to the client. */ -export type IncomingMessage = IncomingEvent | IncomingNotice | IncomingOk +export type IncomingMessage = + | IncomingEvent + | IncomingNotice + | IncomingOk + | IncomingEose + | IncomingAuth -export type IncomingKind = "event" | "notice" | "ok" +export type IncomingKind = "event" | "notice" | "ok" | "eose" | "auth" /** * Incoming "EVENT" message. @@ -174,7 +127,7 @@ export type IncomingKind = "event" | "notice" | "ok" export interface IncomingEvent { kind: "event" subscriptionId: SubscriptionId - raw: RawEvent + event: RawEvent } /** @@ -195,6 +148,21 @@ export interface IncomingOk { message: string } +/** + * Incoming "EOSE" message. + */ +export interface IncomingEose { + kind: "eose" + subscriptionId: SubscriptionId +} + +/** + * Incoming "AUTH" message. + */ +export interface IncomingAuth { + kind: "auth" +} + /** * A message sent from the client to a relay. */ @@ -210,7 +178,7 @@ export type OutgoingKind = "event" | "openSubscription" | "closeSubscription" */ export interface OutgoingEvent { kind: "event" - event: SignedEvent | RawEvent + event: RawEvent } /** @@ -230,49 +198,110 @@ export interface OutgoingCloseSubscription { id: SubscriptionId } -interface RawFilters { - ids?: string[] - authors?: string[] - kinds?: number[] - ["#e"]?: string[] - ["#p"]?: string[] - since?: number - until?: number - limit?: number -} - function serializeOutgoingMessage(msg: OutgoingMessage): string { if (msg.kind === "event") { - const raw = - msg.event instanceof SignedEvent ? msg.event.serialize() : msg.event - return JSON.stringify(["EVENT", raw]) + return JSON.stringify(["EVENT", msg.event]) } else if (msg.kind === "openSubscription") { - return JSON.stringify([ - "REQ", - msg.id.toString(), - ...serializeFilters(msg.filters), - ]) + // If there are no filters, the client is expected to specify a single empty filter. + const filters = msg.filters.length === 0 ? [{}] : msg.filters + return JSON.stringify(["REQ", msg.id.toString(), ...filters]) } else if (msg.kind === "closeSubscription") { return JSON.stringify(["CLOSE", msg.id.toString()]) } else { - throw new Error(`invalid message: ${JSON.stringify(msg)}`) + throw new NostrError(`invalid message: ${JSON.stringify(msg)}`) } } -function serializeFilters(filters: Filters[]): RawFilters[] { - if (filters.length === 0) { - return [{}] +function parseIncomingMessage(data: string): IncomingMessage { + // Parse the incoming data as a nonempty JSON array. + const json = parseJson(data) + if (!(json instanceof Array)) { + throw new NostrError(`incoming message is not an array: ${data}`) } - return filters.map((filter) => ({ - ids: filter.ids?.map((id) => id.toHex()), - authors: filter.authors?.map((author) => author), - kinds: filter.kinds?.map((kind) => kind), - ["#e"]: filter.eventTags?.map((e) => e.toHex()), - ["#p"]: filter.pubkeyTags?.map((p) => p.toHex()), - since: filter.since !== undefined ? unixTimestamp(filter.since) : undefined, - until: filter.until !== undefined ? unixTimestamp(filter.until) : undefined, - limit: filter.limit, - })) + if (json.length === 0) { + throw new NostrError(`incoming message is an empty array: ${data}`) + } + + // Handle incoming events. + if (json[0] === "EVENT") { + if (typeof json[1] !== "string") { + throw new NostrError( + `second element of "EVENT" should be a string, but wasn't: ${data}` + ) + } + if (typeof json[2] !== "object") { + throw new NostrError( + `second element of "EVENT" should be an object, but wasn't: ${data}` + ) + } + const event = parseEventData(json[2]) + return { + kind: "event", + subscriptionId: json[1], + event, + } + } + + // Handle incoming notices. + if (json[0] === "NOTICE") { + if (typeof json[1] !== "string") { + throw new NostrError( + `second element of "NOTICE" should be a string, but wasn't: ${data}` + ) + } + return { + kind: "notice", + notice: json[1], + } + } + + // Handle incoming "OK" messages. + if (json[0] === "OK") { + if (typeof json[1] !== "string") { + throw new NostrError( + `second element of "OK" should be a string, but wasn't: ${data}` + ) + } + if (typeof json[2] !== "boolean") { + throw new NostrError( + `third element of "OK" should be a boolean, but wasn't: ${data}` + ) + } + if (typeof json[3] !== "string") { + throw new NostrError( + `fourth element of "OK" should be a string, but wasn't: ${data}` + ) + } + return { + kind: "ok", + eventId: json[1], + ok: json[2], + message: json[3], + } + } + + // Handle incoming "EOSE" messages. + if (json[0] === "EOSE") { + if (typeof json[1] !== "string") { + throw new NostrError( + `second element of "EOSE" should be a string, but wasn't: ${data}` + ) + } + return { + kind: "eose", + subscriptionId: json[1], + } + } + + // TODO This is incomplete + // Handle incoming "AUTH" messages. + if (json[0] === "AUTH") { + return { + kind: "auth", + } + } + + throw new NostrError(`unknown incoming message: ${data}`) } function parseEventData(json: { [key: string]: unknown }): RawEvent { @@ -288,15 +317,7 @@ function parseEventData(json: { [key: string]: unknown }): RawEvent { typeof json["content"] !== "string" || typeof json["sig"] !== "string" ) { - throw new ProtocolError(`invalid event: ${JSON.stringify(json)}`) + throw new NostrError(`invalid event: ${JSON.stringify(json)}`) } return json as unknown as RawEvent } - -function parseJson(data: string) { - try { - return JSON.parse(data) - } catch (e) { - throw new ProtocolError(`invalid event json: ${data}`) - } -} diff --git a/packages/nostr/src/client/emitter.ts b/packages/nostr/src/client/emitter.ts index 2c9eb78c..59663f4d 100644 --- a/packages/nostr/src/client/emitter.ts +++ b/packages/nostr/src/client/emitter.ts @@ -1,29 +1,43 @@ import Base from "events" import { Nostr, SubscriptionId } from "." -import { EventId, RawEvent, SignedEvent } from "../event" +import { Event, EventId } from "../event" /** * Overrides providing better types for EventEmitter methods. */ export class EventEmitter extends Base { + constructor() { + super({ captureRejections: true }) + } + override addListener(eventName: "newListener", listener: NewListener): this override addListener( eventName: "removeListener", listener: RemoveListener ): this + override addListener(eventName: "open", listener: OpenListener): this + override addListener(eventName: "close", listener: CloseListener): this + override addListener(eventName: "event", listener: EventListener): this override addListener(eventName: "notice", listener: NoticeListener): this override addListener(eventName: "ok", listener: OkListener): this + override addListener(eventName: "eose", listener: EoseListener): this override addListener(eventName: "error", listener: ErrorListener): this - override addListener(eventName: "newListener", listener: ErrorListener): this override addListener(eventName: EventName, listener: Listener): this { return super.addListener(eventName, listener) } override emit(eventName: "newListener", listener: NewListener): boolean override emit(eventName: "removeListener", listener: RemoveListener): boolean + override emit(eventName: "open", relay: URL, nostr: Nostr): boolean + override emit(eventName: "close", relay: URL, nostr: Nostr): boolean override emit(eventName: "event", params: EventParams, nostr: Nostr): boolean override emit(eventName: "notice", notice: string, nostr: Nostr): boolean override emit(eventName: "ok", params: OkParams, nostr: Nostr): boolean + override emit( + eventName: "eose", + subscriptionId: SubscriptionId, + nostr: Nostr + ): boolean override emit(eventName: "error", err: unknown, nostr: Nostr): boolean override emit(eventName: EventName, ...args: unknown[]): boolean { return super.emit(eventName, ...args) @@ -35,9 +49,12 @@ export class EventEmitter extends Base { override listeners(eventName: "newListener"): EventListener[] override listeners(eventName: "removeListener"): EventListener[] + override listeners(eventName: "open"): OpenListener[] + override listeners(eventName: "close"): CloseListener[] override listeners(eventName: "event"): EventListener[] override listeners(eventName: "notice"): NoticeListener[] override listeners(eventName: "ok"): OkListener[] + override listeners(eventName: "eose"): EoseListener[] override listeners(eventName: "error"): ErrorListener[] override listeners(eventName: EventName): Listener[] { return super.listeners(eventName) as Listener[] @@ -45,9 +62,12 @@ export class EventEmitter extends Base { override off(eventName: "newListener", listener: NewListener): this override off(eventName: "removeListener", listener: RemoveListener): this + override off(eventName: "open", listener: OpenListener): this + override off(eventName: "close", listener: CloseListener): this override off(eventName: "event", listener: EventListener): this override off(eventName: "notice", listener: NoticeListener): this override off(eventName: "ok", listener: OkListener): this + override off(eventName: "eose", listener: EoseListener): this override off(eventName: "error", listener: ErrorListener): this override off(eventName: EventName, listener: Listener): this { return super.off(eventName, listener) @@ -55,9 +75,12 @@ export class EventEmitter extends Base { override on(eventName: "newListener", listener: NewListener): this override on(eventName: "removeListener", listener: RemoveListener): this + override on(eventName: "open", listener: OpenListener): this + override on(eventName: "close", listener: CloseListener): this override on(eventName: "event", listener: EventListener): this override on(eventName: "notice", listener: NoticeListener): this override on(eventName: "ok", listener: OkListener): this + override on(eventName: "eose", listener: EoseListener): this override on(eventName: "error", listener: ErrorListener): this override on(eventName: EventName, listener: Listener): this { return super.on(eventName, listener) @@ -65,9 +88,12 @@ export class EventEmitter extends Base { override once(eventName: "newListener", listener: NewListener): this override once(eventName: "removeListener", listener: RemoveListener): this + override once(eventName: "open", listener: OpenListener): this + override once(eventName: "close", listener: CloseListener): this override once(eventName: "event", listener: EventListener): this override once(eventName: "notice", listener: NoticeListener): this override once(eventName: "ok", listener: OkListener): this + override once(eventName: "eose", listener: EoseListener): this override once(eventName: "error", listener: ErrorListener): this override once(eventName: EventName, listener: Listener): this { return super.once(eventName, listener) @@ -81,9 +107,12 @@ export class EventEmitter extends Base { eventName: "removeListener", listener: RemoveListener ): this + override prependListener(eventName: "open", listener: OpenListener): this + override prependListener(eventName: "close", listener: CloseListener): this override prependListener(eventName: "event", listener: EventListener): this override prependListener(eventName: "notice", listener: NoticeListener): this override prependListener(eventName: "ok", listener: OkListener): this + override prependListener(eventName: "eose", listener: EoseListener): this override prependListener(eventName: "error", listener: ErrorListener): this override prependListener(eventName: EventName, listener: Listener): this { return super.prependListener(eventName, listener) @@ -97,6 +126,11 @@ export class EventEmitter extends Base { eventName: "removeListener", listener: RemoveListener ): this + override prependOnceListener(eventName: "open", listener: OpenListener): this + override prependOnceListener( + eventName: "close", + listener: CloseListener + ): this override prependOnceListener( eventName: "event", listener: EventListener @@ -106,6 +140,7 @@ export class EventEmitter extends Base { listener: NoticeListener ): this override prependOnceListener(eventName: "ok", listener: OkListener): this + override prependOnceListener(eventName: "eose", listener: EoseListener): this override prependOnceListener( eventName: "error", listener: ErrorListener @@ -123,9 +158,12 @@ export class EventEmitter extends Base { eventName: "removeListener", listener: RemoveListener ): this + override removeListener(eventName: "open", listener: OpenListener): this + override removeListener(eventName: "close", listener: CloseListener): this override removeListener(eventName: "event", listener: EventListener): this override removeListener(eventName: "notice", listener: NoticeListener): this override removeListener(eventName: "ok", listener: OkListener): this + override removeListener(eventName: "eose", listener: EoseListener): this override removeListener(eventName: "error", listener: ErrorListener): this override removeListener(eventName: EventName, listener: Listener): this { return super.removeListener(eventName, listener) @@ -134,41 +172,49 @@ export class EventEmitter extends Base { override rawListeners(eventName: EventName): Listener[] { return super.rawListeners(eventName) as Listener[] } - - // TODO - // emitter[Symbol.for('nodejs.rejection')](err, eventName[, ...args]) shenanigans? } -// TODO Also add on: ("subscribed", subscriptionId) which checks "OK"/"NOTICE" and makes a callback? -// TODO Also add on: ("ok", boolean, eventId) which checks "OK"/"NOTICE" and makes a callback? +// TODO Refactor the params to always be a single interface +// TODO Params should always include relay as well +// TODO Params should not include Nostr, `this` should be Nostr +// TODO Ideas for events: "auth" for NIP-42 AUTH, "message" for the raw incoming messages, +// "publish" for published events, "send" for sent messages type EventName = | "newListener" | "removeListener" + | "open" + | "close" | "event" | "notice" | "ok" + | "eose" | "error" type NewListener = (eventName: EventName, listener: Listener) => void type RemoveListener = (eventName: EventName, listener: Listener) => void +type OpenListener = (relay: URL, nostr: Nostr) => void +type CloseListener = (relay: URL, nostr: Nostr) => void type EventListener = (params: EventParams, nostr: Nostr) => void -type OkListener = (params: OkParams, nostr: Nostr) => void type NoticeListener = (notice: string, nostr: Nostr) => void +type OkListener = (params: OkParams, nostr: Nostr) => void +type EoseListener = (subscriptionId: SubscriptionId, nostr: Nostr) => void type ErrorListener = (error: unknown, nostr: Nostr) => void type Listener = | NewListener | RemoveListener + | OpenListener + | CloseListener | EventListener | NoticeListener | OkListener + | EoseListener | ErrorListener // TODO Document this export interface EventParams { - signed: SignedEvent + event: Event subscriptionId: SubscriptionId - raw: RawEvent } // TODO Document this diff --git a/packages/nostr/src/client/index.ts b/packages/nostr/src/client/index.ts index 34af5e12..df11d1bc 100644 --- a/packages/nostr/src/client/index.ts +++ b/packages/nostr/src/client/index.ts @@ -1,37 +1,39 @@ -import { ProtocolError } from "../error" -import { EventId, Event, EventKind, SignedEvent, RawEvent } from "../event" -import { PrivateKey, PublicKey } from "../crypto" +import { NostrError } from "../common" +import { RawEvent, parseEvent } from "../event" import { Conn } from "./conn" import * as secp from "@noble/secp256k1" import { EventEmitter } from "./emitter" -import { defined } from "../util" +import { fetchRelayInfo, ReadyState, Relay } from "./relay" +import { Filters } from "../filters" /** * A nostr client. * * TODO Document the events here + * TODO When document this type, remember to explicitly say that promise rejections will also be routed to "error"! */ export class Nostr extends EventEmitter { - // TODO NIP-44 AUTH, leave this for later + static get CONNECTING(): ReadyState.CONNECTING { + return ReadyState.CONNECTING + } + + static get OPEN(): ReadyState.OPEN { + return ReadyState.OPEN + } + + static get CLOSED(): ReadyState.CLOSED { + return ReadyState.CLOSED + } + /** * Open connections to relays. */ - readonly #conns: Map = new Map() + readonly #conns: ConnState[] = [] /** * Mapping of subscription IDs to corresponding filters. */ - readonly #subscriptions: Map = new Map() - - /** - * Optional client private key. - */ - readonly #key?: PrivateKey - - constructor(key?: PrivateKey) { - super() - this.#key = key - } + readonly #subscriptions: Map = new Map() /** * Open a connection and start communicating with a relay. This method recreates all existing @@ -39,12 +41,19 @@ export class Nostr extends EventEmitter { * this method will only update it with the new options, and an exception will be thrown * if no options are specified. */ - open(url: URL | string, opts?: { read?: boolean; write?: boolean }): void { + open( + url: URL | string, + opts?: { read?: boolean; write?: boolean; fetchInfo?: boolean } + ): void { + const relayUrl = new URL(url) + // If the connection already exists, update the options. - const existingConn = this.#conns.get(url.toString()) + const existingConn = this.#conns.find( + (c) => c.relay.url.toString() === relayUrl.toString() + ) if (existingConn !== undefined) { if (opts === undefined) { - throw new Error( + throw new NostrError( `called connect with existing connection ${url}, but options were not specified` ) } @@ -57,59 +66,124 @@ export class Nostr extends EventEmitter { return } - const connUrl = new URL(url) + // Fetch the relay info in parallel to opening the WebSocket connection. + const fetchInfo = + opts?.fetchInfo === false + ? Promise.resolve({}) + : fetchRelayInfo(relayUrl).catch((e) => { + this.#error(e) + return {} + }) // If there is no existing connection, open a new one. const conn = new Conn({ - url: connUrl, + url: relayUrl, + // Handle messages on this connection. onMessage: async (msg) => { - try { - if (msg.kind === "event") { - this.emit( - "event", - { - signed: await SignedEvent.verify(msg.raw, this.#key), - subscriptionId: msg.subscriptionId, - raw: msg.raw, - }, - this - ) - } else if (msg.kind === "notice") { - this.emit("notice", msg.notice, this) - } else if (msg.kind === "ok") { - this.emit( - "ok", - { - eventId: msg.eventId, - relay: connUrl, - ok: msg.ok, - message: msg.message, - }, - this - ) - } else { - throw new ProtocolError(`invalid message ${msg}`) - } - } catch (err) { - this.emit("error", err, this) + if (msg.kind === "event") { + this.emit( + "event", + { + event: await parseEvent(msg.event), + subscriptionId: msg.subscriptionId, + }, + this + ) + } else if (msg.kind === "notice") { + this.emit("notice", msg.notice, this) + } else if (msg.kind === "ok") { + this.emit( + "ok", + { + eventId: msg.eventId, + relay: relayUrl, + ok: msg.ok, + message: msg.message, + }, + this + ) + } else if (msg.kind === "eose") { + this.emit("eose", msg.subscriptionId, this) + } else if (msg.kind === "auth") { + // TODO This is incomplete + } else { + this.#error(new NostrError(`invalid message ${JSON.stringify(msg)}`)) } }, + + // Handle "open" events. + onOpen: async () => { + // Update the connection readyState. + const conn = this.#conns.find( + (c) => c.relay.url.toString() === relayUrl.toString() + ) + if (conn === undefined) { + this.#error( + new NostrError( + `bug: expected connection to ${relayUrl.toString()} to be in the map` + ) + ) + } else { + if (conn.relay.readyState !== ReadyState.CONNECTING) { + this.#error( + new NostrError( + `bug: expected connection to ${relayUrl.toString()} to have readyState CONNECTING, got ${ + conn.relay.readyState + }` + ) + ) + } + conn.relay = { + ...conn.relay, + readyState: ReadyState.OPEN, + info: await fetchInfo, + } + } + // Forward the event to the user. + this.emit("open", relayUrl, this) + }, + + // Handle "close" events. + onClose: () => { + // Update the connection readyState. + const conn = this.#conns.find( + (c) => c.relay.url.toString() === relayUrl.toString() + ) + if (conn === undefined) { + this.#error( + new NostrError( + `bug: expected connection to ${relayUrl.toString()} to be in the map` + ) + ) + } else { + conn.relay.readyState = ReadyState.CLOSED + } + // Forward the event to the user. + this.emit("close", relayUrl, this) + }, + + // TODO If there is no error handler, this will silently swallow the error. Maybe have an + // #onError method which re-throws if emit() returns false? This should at least make + // some noise. // Forward errors on this connection. - onError: (err) => this.emit("error", err, this), + onError: (err) => this.#error(err), }) // Resend existing subscriptions to this connection. for (const [key, filters] of this.#subscriptions.entries()) { - const subscriptionId = new SubscriptionId(key) conn.send({ kind: "openSubscription", - id: subscriptionId, + id: key, filters, }) } - this.#conns.set(url.toString(), { + this.#conns.push({ + relay: { + url: relayUrl, + readyState: ReadyState.CONNECTING, + }, conn, auth: false, read: opts?.read ?? true, @@ -123,23 +197,21 @@ export class Nostr extends EventEmitter { * @param url If specified, only close the connection to this relay. If the connection does * not exist, an exception will be thrown. If this parameter is not specified, all connections * will be closed. - * - * TODO There needs to be a way to check connection state. isOpen(), isReady(), isClosing() maybe? - * Because of how WebSocket states work this isn't as simple as it seems. */ close(url?: URL | string): void { if (url === undefined) { for (const { conn } of this.#conns.values()) { conn.close() } - this.#conns.clear() return } - const c = this.#conns.get(url.toString()) + const relayUrl = new URL(url) + const c = this.#conns.find( + (c) => c.relay.url.toString() === relayUrl.toString() + ) if (c === undefined) { - throw new Error(`connection to ${url} doesn't exist`) + throw new NostrError(`connection to ${url} doesn't exist`) } - this.#conns.delete(url.toString()) c.conn.close() } @@ -159,9 +231,9 @@ export class Nostr extends EventEmitter { */ subscribe( filters: Filters[], - subscriptionId: SubscriptionId = SubscriptionId.random() + subscriptionId: SubscriptionId = randomSubscriptionId() ): SubscriptionId { - this.#subscriptions.set(subscriptionId.toString(), filters) + this.#subscriptions.set(subscriptionId, filters) for (const { conn, read } of this.#conns.values()) { if (!read) { continue @@ -180,9 +252,9 @@ export class Nostr extends EventEmitter { * * TODO Reference subscribed() */ - async unsubscribe(subscriptionId: SubscriptionId): Promise { - if (!this.#subscriptions.delete(subscriptionId.toString())) { - throw new Error(`subscription ${subscriptionId} does not exist`) + unsubscribe(subscriptionId: SubscriptionId): void { + if (!this.#subscriptions.delete(subscriptionId)) { + throw new NostrError(`subscription ${subscriptionId} does not exist`) } for (const { conn, read } of this.#conns.values()) { if (!read) { @@ -198,48 +270,45 @@ export class Nostr extends EventEmitter { /** * Publish an event. */ - async publish(event: SignedEvent): Promise - async publish(event: RawEvent): Promise - // TODO This will need to change when I add NIP-44 AUTH support - the key should be optional - async publish(event: Event, key: PrivateKey): Promise - async publish( - event: SignedEvent | RawEvent | Event, - key?: PrivateKey - ): Promise { - // Validate the parameters. - if (event instanceof SignedEvent || "sig" in event) { - if (key !== undefined) { - throw new Error( - "when calling publish with a SignedEvent, private key should not be specified" - ) - } - } else { - if (key === undefined) { - throw new Error( - "publish called with an unsigned Event, private key must be specified" - ) - } - if (event.pubkey.toHex() !== key.pubkey.toHex()) { - throw new Error("invalid private key") - } - } - + publish(event: RawEvent): void { for (const { conn, write } of this.#conns.values()) { if (!write) { continue } - if (!(event instanceof SignedEvent) && !("sig" in event)) { - event = await SignedEvent.sign(event, defined(key)) - } conn.send({ kind: "event", event, }) } } + + /** + * Get the relays which this client has tried to open connections to. + */ + get relays(): Relay[] { + return this.#conns.map(({ relay }) => { + if (relay.readyState === ReadyState.CONNECTING) { + return { ...relay } + } else { + const info = + relay.info === undefined + ? undefined + : // Deep copy of the info. + JSON.parse(JSON.stringify(relay.info)) + return { ...relay, info } + } + }) + } + + #error(e: unknown) { + if (!this.emit("error", e, this)) { + throw e + } + } } interface ConnState { + relay: Relay conn: Conn /** * Has this connection been authenticated via NIP-44 AUTH? @@ -258,39 +327,8 @@ interface ConnState { /** * A string uniquely identifying a client subscription. */ -export class SubscriptionId { - #id: string +export type SubscriptionId = string - constructor(subscriptionId: string) { - this.#id = subscriptionId - } - - static random(): SubscriptionId { - return new SubscriptionId(secp.utils.bytesToHex(secp.utils.randomBytes(32))) - } - - toString() { - return this.#id - } -} - -/** - * Subscription filters. All filters from the fields must pass for a message to get through. - */ -export interface Filters { - // TODO Document the filters, document that for the arrays only one is enough for the message to pass - ids?: EventId[] - authors?: string[] - kinds?: EventKind[] - /** - * Filters for the "#e" tags. - */ - eventTags?: EventId[] - /** - * Filters for the "#p" tags. - */ - pubkeyTags?: PublicKey[] - since?: Date - until?: Date - limit?: number +function randomSubscriptionId(): SubscriptionId { + return secp.utils.bytesToHex(secp.utils.randomBytes(32)) } diff --git a/packages/nostr/src/client/relay.ts b/packages/nostr/src/client/relay.ts new file mode 100644 index 00000000..ac8f9137 --- /dev/null +++ b/packages/nostr/src/client/relay.ts @@ -0,0 +1,142 @@ +import { PublicKey } from "../crypto" +import { NostrError } from "../common" + +export type Relay = + | { + url: URL + readyState: ReadyState.CONNECTING + } + | { + url: URL + readyState: ReadyState.OPEN + info: RelayInfo + } + | { + url: URL + readyState: ReadyState.CLOSED + /** + * If the relay is closed before the opening process is fully finished, + * the relay info may be undefined. + */ + info?: RelayInfo + } + +/** + * The information that a relay broadcasts about itself as defined in NIP-11. + */ +export interface RelayInfo { + name?: string + description?: string + pubkey?: PublicKey + contact?: string + supported_nips?: number[] + software?: string + version?: string + [key: string]: unknown +} + +/** + * The state of a relay connection. + */ +export enum ReadyState { + /** + * The connection has not been established yet. + */ + CONNECTING = 0, + /** + * The connection has been established. + */ + OPEN = 1, + /** + * The connection has been closed, forcefully or gracefully, by either party. + */ + CLOSED = 2, +} + +// TODO Keep in mind this should be part of the public API of the lib +/** + * Fetch the NIP-11 relay info with some reasonable timeout. Throw an error if + * the info is invalid. + */ +export async function fetchRelayInfo(url: URL | string): Promise { + url = new URL(url.toString().trim().replace(/^ws/, "http")) + const abort = new AbortController() + const timeout = setTimeout(() => abort.abort(), 15_000) + const res = await fetch(url, { + signal: abort.signal, + headers: { + Accept: "application/nostr+json", + }, + }) + clearTimeout(timeout) + const info = await res.json() + // Validate the known fields in the JSON. + if (info.name !== undefined && typeof info.name !== "string") { + info.name = undefined + throw new NostrError( + `invalid relay info, expected "name" to be a string: ${JSON.stringify( + info + )}` + ) + } + if (info.description !== undefined && typeof info.description !== "string") { + info.description = undefined + throw new NostrError( + `invalid relay info, expected "description" to be a string: ${JSON.stringify( + info + )}` + ) + } + if (info.pubkey !== undefined && typeof info.pubkey !== "string") { + info.pubkey = undefined + throw new NostrError( + `invalid relay info, expected "pubkey" to be a string: ${JSON.stringify( + info + )}` + ) + } + if (info.contact !== undefined && typeof info.contact !== "string") { + info.contact = undefined + throw new NostrError( + `invalid relay info, expected "contact" to be a string: ${JSON.stringify( + info + )}` + ) + } + if (info.supported_nips !== undefined) { + if (info.supported_nips instanceof Array) { + if (info.supported_nips.some((e: unknown) => typeof e !== "number")) { + info.supported_nips = undefined + throw new NostrError( + `invalid relay info, expected "supported_nips" elements to be numbers: ${JSON.stringify( + info + )}` + ) + } + } else { + info.supported_nips = undefined + throw new NostrError( + `invalid relay info, expected "supported_nips" to be an array: ${JSON.stringify( + info + )}` + ) + } + } + if (info.software !== undefined && typeof info.software !== "string") { + info.software = undefined + throw new NostrError( + `invalid relay info, expected "software" to be a string: ${JSON.stringify( + info + )}` + ) + } + if (info.version !== undefined && typeof info.version !== "string") { + info.version = undefined + throw new NostrError( + `invalid relay info, expected "version" to be a string: ${JSON.stringify( + info + )}` + ) + } + return info +} diff --git a/packages/nostr/src/common.ts b/packages/nostr/src/common.ts new file mode 100644 index 00000000..b1c542f6 --- /dev/null +++ b/packages/nostr/src/common.ts @@ -0,0 +1,42 @@ +/** + * A UNIX timestamp. + */ +export type Timestamp = number + +/** + * Calculate the unix timestamp (seconds since epoch) of the `Date`. If no date is specified, + * return the current unix timestamp. + */ +export function unixTimestamp(date?: Date): Timestamp { + return Math.floor((date ?? new Date()).getTime() / 1000) +} + +/** + * Throw if the parameter is null or undefined. Return the parameter otherwise. + */ +export function defined(v: T | undefined | null): T { + if (v === undefined || v === null) { + throw new NostrError("bug: unexpected undefined") + } + return v +} + +/** + * Parse the JSON and throw a @see {@link NostrError} in case of error. + */ +export function parseJson(data: string) { + try { + return JSON.parse(data) + } catch (e) { + throw new NostrError(`invalid json: ${e}: ${data}`) + } +} + +/** + * The error thrown by this library. + */ +export class NostrError extends Error { + constructor(message?: string) { + super(message) + } +} diff --git a/packages/nostr/src/crypto.ts b/packages/nostr/src/crypto.ts index 8a4fa8c0..712b91fa 100644 --- a/packages/nostr/src/crypto.ts +++ b/packages/nostr/src/crypto.ts @@ -1,105 +1,97 @@ import * as secp from "@noble/secp256k1" -import { ProtocolError } from "./error" import base64 from "base64-js" import { bech32 } from "bech32" +import { NostrError } from "./common" // TODO Use toHex as well as toString? Might be more explicit // Or maybe replace toString with toHex // TODO Or maybe always store Uint8Array and properly use the format parameter passed into toString /** - * A 32-byte secp256k1 public key. + * A lowercase hex string. */ -export class PublicKey { - #hex: Hex +export type Hex = string - /** - * Expects the key encoded as an npub-prefixed bech32 string, lowercase hex string, or byte buffer. - */ - constructor(key: string | Uint8Array) { - this.#hex = parseKey(key, "npub1") - if (this.#hex.toString().length !== 64) { - throw new ProtocolError(`invalid pubkey: ${key}`) - } - } +/** + * A public key encoded as hex. + */ +export type PublicKey = string - toHex(): string { - return this.#hex.toString() - } +/** + * A private key encoded as hex or bech32 with the "nsec" prefix. + */ +export type HexOrBechPublicKey = string - toString(): string { - return this.toHex() - } +/** + * A private key encoded as hex. + */ +export type PrivateKey = string + +/** + * A private key encoded as hex or bech32 with the "nsec" prefix. + */ +export type HexOrBechPrivateKey = string + +/** + * Get a public key corresponding to a private key. + */ +export function getPublicKey(priv: HexOrBechPrivateKey): PublicKey { + priv = parsePrivateKey(priv) + return toHex(secp.schnorr.getPublicKey(priv)) } /** - * A 32-byte secp256k1 private key. + * Convert the data to lowercase hex. */ -export class PrivateKey { - #hex: Hex - - /** - * Expects the key encoded as an nsec-prefixed bech32 string, lowercase hex string, or byte buffer. - */ - constructor(key: string | Uint8Array) { - this.#hex = parseKey(key, "nsec1") - if (this.#hex.toString().length !== 64) { - throw new ProtocolError(`invalid private key: ${this.#hex}`) - } - } - - get pubkey(): PublicKey { - return new PublicKey(secp.schnorr.getPublicKey(this.#hex.toString())) - } - - /** - * The hex representation of the private key. Use with caution! - */ - toHexDangerous(): string { - return this.#hex.toString() - } - - toString(): string { - return "PrivateKey" - } +function toHex(data: Uint8Array): Hex { + return secp.utils.bytesToHex(data).toLowerCase() } /** - * Parse a public or private key into its hex representation. + * Convert the public key to hex. Accepts a hex or bech32 string with the "npub" prefix. */ -function parseKey(key: string | Uint8Array, bechPrefix: string): Hex { - if (typeof key === "string") { - // If the key is bech32-encoded, decode it. - if (key.startsWith(bechPrefix)) { - const { words } = bech32.decode(key) - const bytes = Uint8Array.from(bech32.fromWords(words)) - return new Hex(bytes) - } - } - return new Hex(key) +export function parsePublicKey(key: HexOrBechPublicKey): PublicKey { + return parseKey(key, "npub") } /** - * Get the SHA256 hash of the data. + * Convert the private key to hex. Accepts a hex or bech32 string with the "nsec" prefix. */ -export async function sha256(data: Uint8Array): Promise { - return await secp.utils.sha256(data) +export function parsePrivateKey(key: HexOrBechPrivateKey): PrivateKey { + return parseKey(key, "nsec") +} + +/** + * Convert a public or private key into its hex representation. + */ +function parseKey(key: string, bechPrefix: string): Hex { + // If the key is bech32-encoded, decode it. + if (key.startsWith(bechPrefix)) { + const { words } = bech32.decode(key) + const bytes = Uint8Array.from(bech32.fromWords(words)) + return toHex(bytes) + } + return key +} + +/** + * Get the SHA256 hash of the data, in hex format. + */ +export async function sha256(data: Uint8Array): Promise { + return toHex(await secp.utils.sha256(data)) } /** * Sign the data using elliptic curve cryptography. */ -export async function schnorrSign( - data: Hex, - key: PrivateKey -): Promise { - return secp.schnorr.sign(data.toString(), key.toHexDangerous()) +export async function schnorrSign(data: Hex, priv: PrivateKey): Promise { + return toHex(await secp.schnorr.sign(data, priv)) } /** * Verify that the elliptic curve signature is correct. */ -export async function schnorrVerify( +export function schnorrVerify( sig: Hex, data: Hex, key: PublicKey @@ -107,21 +99,13 @@ export async function schnorrVerify( return secp.schnorr.verify(sig.toString(), data.toString(), key.toString()) } -interface AesEncryptedBase64 { - data: string - iv: string -} - export async function aesEncryptBase64( sender: PrivateKey, recipient: PublicKey, plaintext: string ): Promise { - const sharedPoint = secp.getSharedSecret( - sender.toHexDangerous(), - "02" + recipient.toHex() - ) - const sharedKey = sharedPoint.slice(2, 33) + const sharedPoint = secp.getSharedSecret(sender, "02" + recipient) + const sharedKey = sharedPoint.slice(1, 33) if (typeof window === "object") { const key = await window.crypto.subtle.importKey( "raw", @@ -158,7 +142,7 @@ export async function aesEncryptBase64( ) let encrypted = cipher.update(plaintext, "utf8", "base64") // TODO Could save an allocation here by avoiding the += - encrypted += cipher.final() + encrypted += cipher.final("base64") return { data: encrypted, iv: Buffer.from(iv.buffer).toString("base64"), @@ -166,20 +150,16 @@ export async function aesEncryptBase64( } } -// TODO export async function aesDecryptBase64( sender: PublicKey, recipient: PrivateKey, { data, iv }: AesEncryptedBase64 ): Promise { - const sharedPoint = secp.getSharedSecret( - recipient.toHexDangerous(), - "02" + sender.toHex() - ) - const sharedKey = sharedPoint.slice(2, 33) + const sharedPoint = secp.getSharedSecret(recipient, "02" + sender) + const sharedKey = sharedPoint.slice(1, 33) if (typeof window === "object") { // TODO Can copy this from the legacy code - throw new Error("todo") + throw new NostrError("todo") } else { const crypto = await import("crypto") const decipher = crypto.createDecipheriv( @@ -192,33 +172,7 @@ export async function aesDecryptBase64( } } -/** - * A string in lowercase hex. This type is not available to the users of the library. - */ -export class Hex { - #value: string - - /** - * Passing a non-lowercase or non-hex string to the constructor - * results in an error being thrown. - */ - constructor(value: string | Uint8Array) { - if (value instanceof Uint8Array) { - value = secp.utils.bytesToHex(value).toLowerCase() - } - if (value.length % 2 != 0) { - throw new ProtocolError(`invalid lowercase hex string: ${value}`) - } - const valid = "0123456789abcdef" - for (const c of value) { - if (!valid.includes(c)) { - throw new ProtocolError(`invalid lowercase hex string: ${value}`) - } - } - this.#value = value - } - - toString(): string { - return this.#value - } +interface AesEncryptedBase64 { + data: string + iv: string } diff --git a/packages/nostr/src/error.ts b/packages/nostr/src/error.ts deleted file mode 100644 index 075c677e..00000000 --- a/packages/nostr/src/error.ts +++ /dev/null @@ -1,10 +0,0 @@ -// TODO Rename to NostrError and move to util.ts, always throw NostrError and never throw Error -/** - * An error in the protocol. This error is thrown when a relay sends invalid or - * unexpected data, or otherwise behaves in an unexpected way. - */ -export class ProtocolError extends Error { - constructor(message?: string) { - super(message) - } -} diff --git a/packages/nostr/src/event.ts b/packages/nostr/src/event.ts deleted file mode 100644 index 4e865f68..00000000 --- a/packages/nostr/src/event.ts +++ /dev/null @@ -1,366 +0,0 @@ -import { ProtocolError } from "./error" -import { - PublicKey, - PrivateKey, - sha256, - Hex, - schnorrSign, - schnorrVerify, - aesDecryptBase64, -} from "./crypto" -import { defined, unixTimestamp } from "./util" - -// TODO This file is missing proper documentation -// TODO Add remaining event types - -export enum EventKind { - SetMetadata = 0, // NIP-01 - TextNote = 1, // NIP-01 - RecommendServer = 2, // NIP-01 - ContactList = 3, // NIP-02 - DirectMessage = 4, // NIP-04 - Deletion = 5, // NIP-09 - Repost = 6, // NIP-18 - Reaction = 7, // NIP-25 - Relays = 10002, // NIP-65 - Auth = 22242, // NIP-42 - PubkeyLists = 30000, // NIP-51a - NoteLists = 30001, // NIP-51b - TagLists = 30002, // NIP-51c - ZapRequest = 9734, // NIP 57 - ZapReceipt = 9735, // NIP 57 -} - -export type Event = - | SetMetadataEvent - | TextNoteEvent - | DirectMessageEvent - | UnknownEvent - -interface EventCommon { - pubkey: PublicKey - createdAt: Date -} - -// TODO Refactor: the event names don't need to all end with *Event - -export interface SetMetadataEvent extends EventCommon { - kind: EventKind.SetMetadata - content: UserMetadata -} - -export interface UserMetadata { - name: string - about: string - picture: string -} - -export interface TextNoteEvent extends EventCommon { - kind: EventKind.TextNote - content: string -} - -export interface DirectMessageEvent extends EventCommon { - kind: EventKind.DirectMessage - /** - * The plaintext message, or undefined if this client is not the recipient. - */ - message?: string - recipient: PublicKey - previous?: EventId -} - -export interface UnknownEvent extends EventCommon { - kind: Exclude -} - -// TODO Doc comment -export class EventId { - #hex: Hex - - static async create(event: Event | RawEvent): Promise { - // It's not defined whether JSON.stringify produces a string with whitespace stripped. - // Building the JSON string manually as follows ensures that there's no whitespace. - // In hindsight using JSON as a data format for hashing and signing is not the best - // design decision. - if ("id" in event) { - // Raw event. - const serializedTags = `[${event.tags - .map((tag) => `[${tag.map((v) => `"${v}"`).join(",")}]`) - .join(",")}]` - const serialized = `[0,"${event.pubkey}",${event.created_at},${event.kind},${serializedTags},"${event.content}"]` - const hash = await sha256(Uint8Array.from(charCodes(serialized))) - return new EventId(hash) - } else { - // Not a raw event. - const tags = serializeTags(event) - const content = serializeContent(event) - const serializedTags = `[${tags - .map((tag) => `[${tag.map((v) => `"${v}"`).join(",")}]`) - .join(",")}]` - const serialized = `[0,"${event.pubkey}",${unixTimestamp( - event.createdAt - )},${event.kind},${serializedTags},"${content}"]` - const hash = await sha256(Uint8Array.from(charCodes(serialized))) - return new EventId(hash) - } - } - - constructor(hex: string | Uint8Array) { - this.#hex = new Hex(hex) - } - - toHex(): string { - return this.#hex.toString() - } - - toString(): string { - return this.toHex() - } -} - -/** - * A signed event. Provides access to the event data, ID, and signature. - */ -export class SignedEvent { - #event: Readonly - #eventId: EventId - #signature: Hex - - /** - * Sign an event using the specified private key. The private key must match the - * public key from the event. - */ - static async sign(event: Event, key: PrivateKey): Promise { - const id = await EventId.create(event) - const sig = await schnorrSign(new Hex(id.toHex()), key) - return new SignedEvent(event, id, new Hex(sig)) - } - - /** - * Verify the signature of a raw event. Throw a `ProtocolError` if the signature - * is invalid. - */ - static async verify(raw: RawEvent, key?: PrivateKey): Promise { - const id = await EventId.create(raw) - if (id.toHex() !== raw.id) { - throw new ProtocolError(`invalid event id: ${raw.id}, expected ${id}`) - } - const sig = new Hex(raw.sig) - if ( - !(await schnorrVerify( - sig, - new Hex(id.toHex()), - new PublicKey(raw.pubkey) - )) - ) { - throw new ProtocolError(`invalid signature: ${sig}`) - } - return new SignedEvent(await parseEvent(raw, key), id, sig) - } - - private constructor(event: Event, eventId: EventId, signature: Hex) { - this.#event = deepCopy(event) - this.#eventId = eventId - this.#signature = signature - } - - /** - * Event ID. - */ - get eventId(): EventId { - return this.#eventId - } - - /** - * Event data. - */ - get event(): Event { - return deepCopy(this.#event) - } - - /** - * Event signature in hex format. - */ - get signature(): string { - return this.#signature.toString() - } - - /** - * Serialize the event into its raw format. - */ - serialize(): RawEvent { - const { event, eventId: id, signature } = this - const tags = serializeTags(event) - const content = serializeContent(event) - return { - id: id.toHex(), - pubkey: event.pubkey.toHex(), - created_at: unixTimestamp(event.createdAt), - kind: event.kind, - tags, - content, - sig: signature, - } - } -} - -export interface RawEvent { - id: string - pubkey: string - created_at: number - kind: number - tags: string[][] - content: string - sig: string -} - -/** - * Parse an event from its raw format. - */ -async function parseEvent( - raw: RawEvent, - key: PrivateKey | undefined -): Promise { - const pubkey = new PublicKey(raw.pubkey) - const createdAt = new Date(raw.created_at * 1000) - const event = { - pubkey, - createdAt, - } - - if (raw.kind === EventKind.SetMetadata) { - const userMetadata = parseJson(raw.content) - if ( - typeof userMetadata["name"] !== "string" || - typeof userMetadata["about"] !== "string" || - typeof userMetadata["picture"] !== "string" - ) { - throw new ProtocolError( - `invalid user metadata ${userMetadata} in ${JSON.stringify(raw)}` - ) - } - return { - ...event, - kind: EventKind.SetMetadata, - content: userMetadata, - } - } - - if (raw.kind === EventKind.TextNote) { - return { - ...event, - kind: EventKind.TextNote, - content: raw.content, - } - } - - if (raw.kind === EventKind.DirectMessage) { - // Parse the tag identifying the recipient. - const recipientTag = raw.tags.find((tag) => tag[0] === "p") - if (typeof recipientTag?.[1] !== "string") { - throw new ProtocolError( - `expected "p" tag to be of type string, but got ${ - recipientTag?.[1] - } in ${JSON.stringify(raw)}` - ) - } - const recipient = new PublicKey(recipientTag[1]) - - // Parse the tag identifying the optional previous message. - const previousTag = raw.tags.find((tag) => tag[0] === "e") - if (typeof recipientTag[1] !== "string") { - throw new ProtocolError( - `expected "e" tag to be of type string, but got ${ - previousTag?.[1] - } in ${JSON.stringify(raw)}` - ) - } - const previous = new EventId(defined(previousTag?.[1])) - - // Decrypt the message content. - const [data, iv] = raw.content.split("?iv=") - if (data === undefined || iv === undefined) { - throw new ProtocolError(`invalid direct message content ${raw.content}`) - } - let message: string | undefined - if (key?.pubkey?.toHex() === recipient.toHex()) { - message = await aesDecryptBase64(event.pubkey, key, { data, iv }) - } - return { - ...event, - kind: EventKind.DirectMessage, - message, - recipient, - previous, - } - } - - return { - ...event, - kind: raw.kind, - } -} - -function serializeTags(_event: Event): string[][] { - // TODO As I add different event kinds, this will change - return [] -} - -function serializeContent(event: Event): string { - if (event.kind === EventKind.SetMetadata) { - return JSON.stringify(event.content) - } else if (event.kind === EventKind.TextNote) { - return event.content - } else { - return "" - } -} - -/** - * Create a deep copy of the event. - */ -function deepCopy(event: Event): Event { - const common = { - createdAt: structuredClone(event.createdAt), - pubkey: event.pubkey, - } - if (event.kind === EventKind.SetMetadata) { - return { - kind: EventKind.SetMetadata, - content: { - about: event.content.about, - name: event.content.name, - picture: event.content.picture, - }, - ...common, - } - } else if (event.kind === EventKind.TextNote) { - return { - kind: EventKind.TextNote, - content: event.content, - ...common, - } - } else if (event.kind === EventKind.DirectMessage) { - throw new Error("todo") - } else { - return { - kind: event.kind, - ...common, - } - } -} - -function parseJson(data: string) { - try { - return JSON.parse(data) - } catch (e) { - throw new ProtocolError(`invalid json: ${data}`) - } -} - -function* charCodes(data: string): Iterable { - for (let i = 0; i < data.length; i++) { - yield data.charCodeAt(i) - } -} diff --git a/packages/nostr/src/event/contact-list.ts b/packages/nostr/src/event/contact-list.ts new file mode 100644 index 00000000..e3740c74 --- /dev/null +++ b/packages/nostr/src/event/contact-list.ts @@ -0,0 +1,83 @@ +import { EventKind, RawEvent, Unsigned } from "." +import { NostrError } from "../common" +import { PublicKey } from "../crypto" + +/** + * Contact list event. + * + * Related NIPs: NIP-02. + */ +export interface ContactList extends RawEvent { + kind: EventKind.ContactList + + /** + * Get the contacts in from the contact list. + */ + getContacts(): Contact[] +} + +/** + * A contact from the contact list. + */ +export interface Contact { + pubkey: PublicKey + relay?: URL + petname?: string +} + +/** + * Create a contact list event. + */ +export function createContactList(contacts: Contact[]): Unsigned { + return { + kind: EventKind.ContactList, + tags: contacts.map((contact) => [ + "p", + contact.pubkey, + contact.relay?.toString() ?? "", + contact.petname ?? "", + ]), + content: "", + getContacts, + } +} + +export function getContacts(this: ContactList): Contact[] { + return this.tags + .filter((tags) => tags[0] === "p") + .map((tags) => { + // The first element is the pubkey. + const pubkey = tags[1] + if (pubkey === undefined) { + throw new NostrError( + `missing contact pubkey for contact list event: ${JSON.stringify( + this + )}` + ) + } + + // The second element is the optional relay URL. + let relay: URL | undefined + try { + if (tags[2] !== undefined && tags[2] !== "") { + relay = new URL(tags[2]) + } + } catch (e) { + throw new NostrError( + `invalid relay URL for contact list event: ${JSON.stringify(this)}` + ) + } + + // The third element is the optional petname. + let petname: string | undefined + if (tags[3] !== undefined && tags[3] !== "") { + petname = tags[3] + } + + return { + pubkey, + relay, + petname, + } + }) +} diff --git a/packages/nostr/src/event/direct-message.ts b/packages/nostr/src/event/direct-message.ts new file mode 100644 index 00000000..ade4b0c8 --- /dev/null +++ b/packages/nostr/src/event/direct-message.ts @@ -0,0 +1,124 @@ +import { + EventId, + EventKind, + RawEvent, + signEvent, + Unsigned, + UnsignedWithPubkey, +} from "." +import { defined, NostrError } from "../common" +import { + aesDecryptBase64, + aesEncryptBase64, + getPublicKey, + HexOrBechPrivateKey, + parsePrivateKey, + parsePublicKey, + PrivateKey, + PublicKey, +} from "../crypto" + +/** + * An encrypted direct message event. + * + * Related NIPs: NIP-04. + */ +export interface DirectMessage extends RawEvent { + kind: EventKind.DirectMessage + + /** + * Get the message plaintext, or undefined if you are not the recipient. + */ + getMessage(priv?: HexOrBechPrivateKey): Promise + /** + * Get the recipient pubkey. + */ + getRecipient(): PublicKey + /** + * Get the event ID of the previous message. + */ + getPrevious(): EventId | undefined +} + +// TODO Since you already require the private key, maybe this should return the message already signed? +// With NIP-07 the parameter will be optional, then what? +/** + * Create an encrypted direct message event. + */ +export async function createDirectMessage( + { + message, + recipient, + }: { + message: string + recipient: PublicKey + }, + priv?: PrivateKey +): Promise { + recipient = parsePublicKey(recipient) + if (priv === undefined) { + // TODO Use NIP-07 + throw new NostrError("todo") + } else { + priv = parsePrivateKey(priv) + const { data, iv } = await aesEncryptBase64(priv, recipient, message) + return await signEvent( + { + kind: EventKind.DirectMessage, + tags: [["p", recipient]], + content: `${data}?iv=${iv}`, + getMessage, + getRecipient, + getPrevious, + }, + priv + ) + } +} + +export async function getMessage( + this: UnsignedWithPubkey, + priv?: HexOrBechPrivateKey +): Promise { + if (priv !== undefined) { + priv = parsePrivateKey(priv) + } + const [data, iv] = this.content.split("?iv=") + if (data === undefined || iv === undefined) { + throw new NostrError(`invalid direct message content ${this.content}`) + } + if (priv === undefined) { + // TODO Try to use NIP-07 + throw new NostrError("todo") + } else if (getPublicKey(priv) === this.getRecipient()) { + return await aesDecryptBase64(this.pubkey, priv, { data, iv }) + } + return undefined +} + +export function getRecipient(this: Unsigned): PublicKey { + const recipientTag = this.tags.find((tag) => tag[0] === "p") + if (typeof recipientTag?.[1] !== "string") { + throw new NostrError( + `expected "p" tag to be of type string, but got ${ + recipientTag?.[1] + } in ${JSON.stringify(this)}` + ) + } + return recipientTag[1] +} + +export function getPrevious(this: Unsigned): EventId | undefined { + const previousTag = this.tags.find((tag) => tag[0] === "e") + if (previousTag === undefined) { + return undefined + } + if (typeof previousTag[1] !== "string") { + throw new NostrError( + `expected "e" tag to be of type string, but got ${ + previousTag?.[1] + } in ${JSON.stringify(this)}` + ) + } + return previousTag[1] +} diff --git a/packages/nostr/src/event/index.ts b/packages/nostr/src/event/index.ts new file mode 100644 index 00000000..91bc184f --- /dev/null +++ b/packages/nostr/src/event/index.ts @@ -0,0 +1,212 @@ +import { + PublicKey, + sha256, + schnorrSign, + schnorrVerify, + getPublicKey, + HexOrBechPrivateKey, + parsePrivateKey, +} from "../crypto" +import { Timestamp, unixTimestamp, NostrError } from "../common" +import { TextNote } from "./text" +import { getUserMetadata, SetMetadata } from "./set-metadata" +import { + DirectMessage, + getMessage, + getPrevious, + getRecipient, +} from "./direct-message" +import { ContactList, getContacts } from "./contact-list" + +// TODO Add remaining event types + +// TODO +// Think about this more +// Perhaps the best option is for all these factory methods to have an overload which also accept a private +// key as last parameter and return the event already signed +// Or maybe opts: { sign?: boolean | HexOrBechPrivateKey } setting sign to true should use nip07, setting +// it to a string will use that string as the private key + +export enum EventKind { + SetMetadata = 0, // NIP-01 + TextNote = 1, // NIP-01 + RecommendServer = 2, // NIP-01 + ContactList = 3, // NIP-02 + DirectMessage = 4, // NIP-04 + Deletion = 5, // NIP-09 + Repost = 6, // NIP-18 + Reaction = 7, // NIP-25 + Relays = 10002, // NIP-65 + Auth = 22242, // NIP-42 + PubkeyLists = 30000, // NIP-51a + NoteLists = 30001, // NIP-51b + TagLists = 30002, // NIP-51c + ZapRequest = 9734, // NIP 57 + ZapReceipt = 9735, // NIP 57 +} + +/** + * A nostr event in the format that's sent across the wire. + */ +export interface RawEvent { + id: string + pubkey: PublicKey + created_at: Timestamp + kind: EventKind + tags: string[][] + content: string + sig: string + + [key: string]: unknown +} + +export interface Unknown extends RawEvent { + kind: Exclude< + EventKind, + | EventKind.SetMetadata + | EventKind.TextNote + | EventKind.DirectMessage + | EventKind.ContactList + > +} + +export type Event = + | SetMetadata + | TextNote + | ContactList + | DirectMessage + | Unknown + +/** + * Event ID encoded as hex. + */ +export type EventId = string + +/** + * An unsigned event. + */ +export type Unsigned = { + [Property in keyof UnsignedWithPubkey as Exclude< + Property, + "pubkey" + >]: T[Property] +} & { + pubkey?: PublicKey +} + +// TODO This doesn't need to be exposed by the lib +/** + * Same as @see {@link Unsigned}, but with the pubkey field. + */ +export type UnsignedWithPubkey = { + [Property in keyof T as Exclude< + Property, + "id" | "sig" | "created_at" + >]: T[Property] +} & { + id?: EventId + sig?: string + created_at?: number +} + +/** + * Add the "id," "sig," and "pubkey" fields to the event. Set "created_at" to the current timestamp + * if missing. Return the event. + */ +export async function signEvent( + event: Unsigned, + priv?: HexOrBechPrivateKey +): Promise { + event.created_at ??= unixTimestamp() + if (priv !== undefined) { + priv = parsePrivateKey(priv) + event.pubkey = getPublicKey(priv) + const id = await serializeEventId( + // This conversion is safe because the pubkey field is set above. + event as unknown as UnsignedWithPubkey + ) + event.id = id + event.sig = await schnorrSign(id, priv) + return event as T + } else { + // TODO Try to use NIP-07, otherwise throw + throw new NostrError("todo") + } +} + +/** + * Parse an event from its raw format. + */ +export async function parseEvent(event: RawEvent): Promise { + if (event.id !== (await serializeEventId(event))) { + throw new NostrError( + `invalid id ${event.id} for event ${JSON.stringify( + event + )}, expected ${await serializeEventId(event)}` + ) + } + if (!(await schnorrVerify(event.sig, event.id, event.pubkey))) { + throw new NostrError(`invalid signature for event ${JSON.stringify(event)}`) + } + + // TODO Validate all the fields. Lowercase hex fields, etc. Make sure everything is correct. + // TODO Also validate that tags have at least one element + + if (event.kind === EventKind.TextNote) { + return { + ...event, + kind: EventKind.TextNote, + } + } + + if (event.kind === EventKind.SetMetadata) { + return { + ...event, + kind: EventKind.SetMetadata, + getUserMetadata, + } + } + + if (event.kind === EventKind.DirectMessage) { + return { + ...event, + kind: EventKind.DirectMessage, + getMessage, + getRecipient, + getPrevious, + } + } + + if (event.kind === EventKind.ContactList) { + return { + ...event, + kind: EventKind.ContactList, + getContacts, + } + } + + return { + ...event, + kind: event.kind, + } +} + +async function serializeEventId( + event: UnsignedWithPubkey +): Promise { + // It's not defined whether JSON.stringify produces a string with whitespace stripped. + // Building the JSON string manually as follows ensures that there's no whitespace. + // In hindsight using JSON as a data format for hashing and signing is not the best + // design decision. + const serializedTags = `[${event.tags + .map((tag) => `[${tag.map((v) => `"${v}"`).join(",")}]`) + .join(",")}]` + const serialized = `[0,"${event.pubkey}",${event.created_at},${event.kind},${serializedTags},"${event.content}"]` + return await sha256(Uint8Array.from(charCodes(serialized))) +} + +function* charCodes(data: string): Iterable { + for (let i = 0; i < data.length; i++) { + yield data.charCodeAt(i) + } +} diff --git a/packages/nostr/src/event/set-metadata.ts b/packages/nostr/src/event/set-metadata.ts new file mode 100644 index 00000000..0c20c05f --- /dev/null +++ b/packages/nostr/src/event/set-metadata.ts @@ -0,0 +1,50 @@ +import { EventKind, RawEvent, Unsigned } from "." +import { NostrError, parseJson } from "../common" + +/** + * Set metadata event. Used for disseminating use profile information. + * + * Related NIPs: NIP-01. + */ +export interface SetMetadata extends RawEvent { + kind: EventKind.SetMetadata + + /** + * Get the user metadata specified in this event. + */ + getUserMetadata(): UserMetadata +} + +export interface UserMetadata { + name: string + about: string + picture: string +} + +/** + * Create a set metadata event. + */ +export function createSetMetadata( + content: UserMetadata +): Unsigned { + return { + kind: EventKind.SetMetadata, + tags: [], + content: JSON.stringify(content), + getUserMetadata, + } +} + +export function getUserMetadata(this: Unsigned): UserMetadata { + const userMetadata = parseJson(this.content) + if ( + typeof userMetadata.name !== "string" || + typeof userMetadata.about !== "string" || + typeof userMetadata.picture !== "string" + ) { + throw new NostrError( + `invalid user metadata ${userMetadata} in ${JSON.stringify(this)}` + ) + } + return userMetadata +} diff --git a/packages/nostr/src/event/text.ts b/packages/nostr/src/event/text.ts new file mode 100644 index 00000000..0fb845a2 --- /dev/null +++ b/packages/nostr/src/event/text.ts @@ -0,0 +1,18 @@ +import { EventKind, RawEvent, Unsigned } from "." + +/** + * A text note event. Used for transmitting user posts. + * + * Related NIPs: NIP-01. + */ +export interface TextNote extends RawEvent { + kind: EventKind.TextNote +} + +export function createTextNote(content: string): Unsigned { + return { + kind: EventKind.TextNote, + tags: [], + content, + } +} diff --git a/packages/nostr/src/filters.ts b/packages/nostr/src/filters.ts new file mode 100644 index 00000000..6211fc99 --- /dev/null +++ b/packages/nostr/src/filters.ts @@ -0,0 +1,81 @@ +import { PublicKey } from "./crypto" +import { EventId, EventKind } from "./event" +import { Timestamp } from "./common" + +/** + * Subscription filters. All filters from the fields must pass for a message to get through. + */ +export interface Filters extends TagFilters { + // TODO Document the filters, document that for the arrays only one is enough for the message to pass + ids?: EventId[] + authors?: string[] + kinds?: EventKind[] + since?: Timestamp + until?: Timestamp + limit?: number + + /** + * Allows for arbitrary, nonstandard extensions. + */ + [key: string]: unknown +} + +/** + * Generic tag queries as defined by NIP-12. + */ +interface TagFilters { + ["#e"]: EventId[] + ["#p"]: PublicKey[] + + ["#a"]: string[] + ["#b"]: string[] + ["#c"]: string[] + ["#d"]: string[] + ["#f"]: string[] + ["#g"]: string[] + ["#h"]: string[] + ["#i"]: string[] + ["#j"]: string[] + ["#k"]: string[] + ["#l"]: string[] + ["#m"]: string[] + ["#n"]: string[] + ["#o"]: string[] + ["#q"]: string[] + ["#r"]: string[] + ["#s"]: string[] + ["#t"]: string[] + ["#u"]: string[] + ["#v"]: string[] + ["#w"]: string[] + ["#x"]: string[] + ["#y"]: string[] + ["#z"]: string[] + + ["#A"]: string[] + ["#B"]: string[] + ["#C"]: string[] + ["#D"]: string[] + ["#E"]: string[] + ["#F"]: string[] + ["#G"]: string[] + ["#H"]: string[] + ["#I"]: string[] + ["#J"]: string[] + ["#K"]: string[] + ["#L"]: string[] + ["#M"]: string[] + ["#N"]: string[] + ["#O"]: string[] + ["#P"]: string[] + ["#Q"]: string[] + ["#R"]: string[] + ["#S"]: string[] + ["#T"]: string[] + ["#U"]: string[] + ["#V"]: string[] + ["#W"]: string[] + ["#X"]: string[] + ["#Y"]: string[] + ["#Z"]: string[] +} diff --git a/packages/nostr/src/util.ts b/packages/nostr/src/util.ts deleted file mode 100644 index 0a3a3d76..00000000 --- a/packages/nostr/src/util.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ProtocolError } from "./error" - -/** - * Calculate the unix timestamp (seconds since epoch) of the `Date`. - */ -export function unixTimestamp(date: Date): number { - return Math.floor(date.getTime() / 1000) -} - -/** - * Throw if the parameter is null or undefined. Return the parameter otherwise. - */ -export function defined(v: T | undefined | null): T { - if (v === undefined || v === null) { - throw new ProtocolError("bug: unexpected undefined") - } - return v -} diff --git a/packages/nostr/test/contact-list.ts b/packages/nostr/test/contact-list.ts new file mode 100644 index 00000000..a6dbedc4 --- /dev/null +++ b/packages/nostr/test/contact-list.ts @@ -0,0 +1,56 @@ +import assert from "assert" +import { EventKind, signEvent } from "../src/event" +import { createContactList } from "../src/event/contact-list" +import { setup } from "./setup" + +describe("contact-list", () => { + it("publish and receive the contact list", (done) => { + setup(done, ({ publisher, subscriber, subscriberSecret, done }) => { + const contacts = [ + { + pubkey: + "db9df52f7fcaf30b2718ad17e4c5521058bb20b95073b5c4ff53221b36447c4f", + relay: undefined, + petname: undefined, + }, + { + pubkey: + "94d5ce4cb06f67cab69a2f6e28e0a795222a74ac6a1dd6223743913cc99eaf37", + relay: new URL("ws://example.com"), + petname: undefined, + }, + { + pubkey: + "e6e9a25dbf3e931c991f43c97378e294c25f59e88adc91eda11ed17249a00c20", + relay: undefined, + petname: "john", + }, + { + pubkey: + "13d629a3a879f2157199491408711ff5e1450002a9f9d8b0ad750f1c6b96661d", + relay: new URL("ws://example2.com"), + petname: "jack", + }, + ] + + subscriber.on("event", ({ event }) => { + assert.strictEqual(event.kind, EventKind.ContactList) + assert.strictEqual(event.content, "") + if (event.kind === EventKind.ContactList) { + assert.deepStrictEqual(event.getContacts(), contacts) + } + done() + }) + + subscriber.subscribe([]) + + // After the subscription event sync is done, publish the test event. + subscriber.on("eose", async () => { + // TODO No signEvent, have a convenient way to do this + publisher.publish( + await signEvent(createContactList(contacts), subscriberSecret) + ) + }) + }) + }) +}) diff --git a/packages/nostr/test/dm.ts b/packages/nostr/test/dm.ts new file mode 100644 index 00000000..ec09324d --- /dev/null +++ b/packages/nostr/test/dm.ts @@ -0,0 +1,128 @@ +import { EventKind, signEvent } from "../src/event" +import { parsePublicKey } from "../src/crypto" +import assert from "assert" +import { setup } from "./setup" +import { createDirectMessage } from "../src/event/direct-message" + +describe("dm", () => { + const message = "for your eyes only" + + // Test that the intended recipient can receive and decrypt the direct message. + it("to intended recipient", (done) => { + setup( + done, + ({ + publisher, + publisherPubkey, + publisherSecret, + subscriber, + subscriberPubkey, + subscriberSecret, + timestamp, + done, + }) => { + // Expect the direct message. + subscriber.on( + "event", + async ({ event, subscriptionId: actualSubscriptionId }, nostr) => { + assert.strictEqual(nostr, subscriber) + assert.strictEqual(event.kind, EventKind.DirectMessage) + assert.strictEqual(event.pubkey, parsePublicKey(publisherPubkey)) + assert.strictEqual(actualSubscriptionId, subscriptionId) + assert.ok(event.created_at >= timestamp) + + if (event.kind === EventKind.DirectMessage) { + assert.strictEqual( + event.getRecipient(), + parsePublicKey(subscriberPubkey) + ) + assert.strictEqual( + await event.getMessage(subscriberSecret), + message + ) + } + + done() + } + ) + + const subscriptionId = subscriber.subscribe([]) + + subscriber.on("eose", async () => { + // TODO No signEvent, do something more convenient + const event = await signEvent( + await createDirectMessage({ + message, + recipient: subscriberPubkey, + priv: publisherSecret, + }), + publisherSecret + ) + publisher.publish(event) + }) + } + ) + }) + + // Test that an unintended recipient still receives the direct message event, but cannot decrypt it. + it("to unintended recipient", (done) => { + setup( + done, + ({ + publisher, + publisherPubkey, + publisherSecret, + subscriber, + subscriberSecret, + timestamp, + done, + }) => { + const recipientPubkey = + "npub1u2dl3scpzuwyd45flgtm3wcjgv20j4azuzgevdpgtsvvmqzvc63sz327gc" + + // Expect the direct message. + subscriber.on( + "event", + async ({ event, subscriptionId: actualSubscriptionId }, nostr) => { + try { + assert.strictEqual(nostr, subscriber) + assert.strictEqual(event.kind, EventKind.DirectMessage) + assert.strictEqual(event.pubkey, parsePublicKey(publisherPubkey)) + assert.strictEqual(actualSubscriptionId, subscriptionId) + assert.ok(event.created_at >= timestamp) + + if (event.kind === EventKind.DirectMessage) { + assert.strictEqual( + event.getRecipient(), + parsePublicKey(recipientPubkey) + ) + assert.strictEqual( + await event.getMessage(subscriberSecret), + undefined + ) + } + + done() + } catch (e) { + done(e) + } + } + ) + + const subscriptionId = subscriber.subscribe([]) + + subscriber.on("eose", async () => { + // TODO No signEvent, do something more convenient + const event = await createDirectMessage( + { + message, + recipient: recipientPubkey, + }, + publisherSecret + ) + publisher.publish(event) + }) + } + ) + }) +}) diff --git a/packages/nostr/test/ready-state.ts b/packages/nostr/test/ready-state.ts new file mode 100644 index 00000000..3e341ea2 --- /dev/null +++ b/packages/nostr/test/ready-state.ts @@ -0,0 +1,24 @@ +import assert from "assert" +import { Nostr } from "../src/client" +import { relayUrl } from "./setup" + +describe("ready state", () => { + it("ready state transitions", (done) => { + const nostr = new Nostr() + + nostr.on("error", done) + + nostr.on("open", () => { + assert.strictEqual(nostr.relays[0].readyState, Nostr.OPEN) + nostr.close() + }) + + nostr.on("close", () => { + assert.strictEqual(nostr.relays[0].readyState, Nostr.CLOSED) + done() + }) + + nostr.open(relayUrl) + assert.strictEqual(nostr.relays[0].readyState, Nostr.CONNECTING) + }) +}) diff --git a/packages/nostr/test/relay-info.ts b/packages/nostr/test/relay-info.ts new file mode 100644 index 00000000..3551f7b5 --- /dev/null +++ b/packages/nostr/test/relay-info.ts @@ -0,0 +1,26 @@ +import assert from "assert" +import { Nostr } from "../src/client" +import { setup } from "./setup" + +describe("relay info", () => { + it("fetching relay info", (done) => { + setup(done, ({ publisher, done }) => { + assert.strictEqual(publisher.relays.length, 1) + const relay = publisher.relays[0] + assert.strictEqual(relay.readyState, Nostr.OPEN) + if (relay.readyState === Nostr.OPEN) { + assert.strictEqual(relay.info.name, "nostr-rs-relay") + assert.strictEqual(relay.info.description, "nostr-rs-relay description") + assert.strictEqual(relay.info.pubkey, undefined) + assert.strictEqual(relay.info.contact, "mailto:contact@example.com") + assert.ok((relay.info.supported_nips?.length ?? 0) > 0) + assert.strictEqual( + relay.info.software, + "https://git.sr.ht/~gheartsfield/nostr-rs-relay" + ) + assert.strictEqual(relay.info.version, "0.8.8") + } + done() + }) + }) +}) diff --git a/packages/nostr/test/setup.ts b/packages/nostr/test/setup.ts new file mode 100644 index 00000000..f8d50e25 --- /dev/null +++ b/packages/nostr/test/setup.ts @@ -0,0 +1,98 @@ +import { Nostr } from "../src/client" +import { Timestamp, unixTimestamp } from "../src/common" + +export const relayUrl = new URL("ws://localhost:12648") + +export interface Setup { + publisher: Nostr + publisherSecret: string + publisherPubkey: string + subscriber: Nostr + subscriberSecret: string + subscriberPubkey: string + timestamp: Timestamp + url: URL + /** + * Signal that the test is done. Call this instead of the callback provided by + * mocha. This will also take care of test cleanup. + */ + done: (e?: unknown) => void +} + +export async function setup( + done: jest.DoneCallback, + test: (setup: Setup) => void | Promise +) { + try { + await restartRelay() + const publisher = new Nostr() + const subscriber = new Nostr() + + publisher.on("error", done) + subscriber.on("error", done) + + const openPromise = Promise.all([ + new Promise((resolve) => publisher.on("open", resolve)), + new Promise((resolve) => subscriber.on("open", resolve)), + ]) + + publisher.open(relayUrl) + subscriber.open(relayUrl) + + await openPromise + + const result = test({ + publisher, + publisherSecret: + "nsec15fnff4uxlgyu79ua3l7327w0wstrd6x565cx6zze78zgkktmr8vs90j363", + publisherPubkey: + "npub1he978sxy7tgc7yfp2zra05v045kfuqnfl3gwr82jd00mzxjj9fjqzw2dg7", + subscriber, + subscriberSecret: + "nsec1fxvlyqn3rugvxwaz6dr5h8jcfn0fe0lxyp7pl4mgntxfzqr7dmgst7z9ps", + subscriberPubkey: + "npub1mtwskm558jugtj724nsgf3jf80c5adl39ttydngrn48250l6xmjqa00yxd", + timestamp: unixTimestamp(), + url: relayUrl, + done: (e?: unknown) => { + publisher.close() + subscriber.close() + done(e) + }, + }) + if (result instanceof Promise) { + await result + } + } catch (e) { + done(e) + } +} + +async function restartRelay() { + // Make a request to the endpoint which will crash the process and cause it to restart. + try { + await fetch("http://localhost:12649") + } catch (e) { + // Since the process exits, an error is expected. + } + + // Wait until the relay process is ready. + for (;;) { + const ok = await new Promise((resolve) => { + const nostr = new Nostr() + nostr.on("error", () => { + nostr.close() + resolve(false) + }) + nostr.on("open", () => { + nostr.close() + resolve(true) + }) + nostr.open("ws://localhost:12648", { fetchInfo: false }) + }) + if (ok) { + break + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } +} diff --git a/packages/nostr/test/simple-communication.ts b/packages/nostr/test/simple-communication.ts deleted file mode 100644 index c7e52cb3..00000000 --- a/packages/nostr/test/simple-communication.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Nostr } from "../src/client" -import { EventKind, SignedEvent } from "../src/event" -import { PrivateKey } from "../src/crypto" -import assert from "assert" -import { EventParams } from "../src/client/emitter" - -// TODO Switch out the relay implementation and see if the issue persists -// TODO Do on("error", done) for all of these - -describe("simple communication", function () { - const secret = new PrivateKey( - "nsec1xlu55y6fqfgrq448xslt6a8j2rh7lj08hyhgs94ryq04yf6surwsjl0kzh" - ) - const pubkey = secret.pubkey - const timestamp = new Date() - const note = "hello world" - const url = new URL("ws://localhost:12648") - - const publisher = new Nostr() - const subscriber = new Nostr() - - beforeEach(() => { - publisher.open(url) - subscriber.open(url) - }) - - afterEach(() => { - publisher.close() - subscriber.close() - }) - - it("publish and receive", function (done) { - function listener({ signed: { event } }: EventParams, nostr: Nostr) { - assert.equal(nostr, subscriber) - assert.equal(event.kind, EventKind.TextNote) - assert.equal(event.pubkey.toHex(), pubkey.toHex()) - assert.equal(event.createdAt.toString(), timestamp.toString()) - if (event.kind === EventKind.TextNote) { - assert.equal(event.content, note) - } - - // There is a bug with the nostr relay used for testing where if the publish and - // subscribe happen at the same time, the same event might end up being broadcast twice. - // To prevent reacting to the same event and calling done() twice, remove the callback - // for future events. - subscriber.off("event", listener) - - done() - } - - subscriber.on("event", listener) - subscriber.subscribe([]) - publisher.publish( - { - kind: EventKind.TextNote, - createdAt: timestamp, - content: note, - pubkey, - }, - secret - ) - }) - - it("publish and ok", function (done) { - SignedEvent.sign( - { - kind: EventKind.TextNote, - createdAt: timestamp, - content: note, - pubkey, - }, - secret - ).then((event) => { - publisher.on("ok", (params, nostr) => { - assert.equal(nostr, publisher) - assert.equal(params.eventId.toHex(), event.eventId.toHex()) - assert.equal(params.relay.toString(), url.toString()) - assert.equal(params.ok, true) - done() - }) - publisher.on("error", done) - publisher.publish(event) - }) - }) -}) diff --git a/packages/nostr/test/text-note.ts b/packages/nostr/test/text-note.ts new file mode 100644 index 00000000..217ccfc2 --- /dev/null +++ b/packages/nostr/test/text-note.ts @@ -0,0 +1,75 @@ +import { EventKind, signEvent, Unsigned } from "../src/event" +import { parsePublicKey } from "../src/crypto" +import assert from "assert" +import { setup } from "./setup" +import { createTextNote, TextNote } from "../src/event/text" + +describe("text note", () => { + const note = "hello world" + + // Test that a text note can be published by one client and received by the other. + it("publish and receive", (done) => { + setup( + done, + ({ + publisher, + publisherSecret, + publisherPubkey, + subscriber, + timestamp, + done, + }) => { + // Expect the test event. + subscriber.on( + "event", + ({ event, subscriptionId: actualSubscriptionId }, nostr) => { + assert.strictEqual(nostr, subscriber) + assert.strictEqual(event.kind, EventKind.TextNote) + assert.strictEqual(event.pubkey, parsePublicKey(publisherPubkey)) + assert.strictEqual(event.created_at, timestamp) + assert.strictEqual(event.content, note) + assert.strictEqual(actualSubscriptionId, subscriptionId) + done() + } + ) + + const subscriptionId = subscriber.subscribe([]) + + // After the subscription event sync is done, publish the test event. + subscriber.on("eose", async (id, nostr) => { + assert.strictEqual(nostr, subscriber) + assert.strictEqual(id, subscriptionId) + + // TODO No signEvent, have a convenient way to do this + publisher.publish( + await signEvent( + { + ...createTextNote(note), + created_at: timestamp, + } as Unsigned, + publisherSecret + ) + ) + }) + } + ) + }) + + // Test that a client interprets an "OK" message after publishing a text note. + it("publish and ok", function (done) { + setup(done, ({ publisher, publisherSecret, url, done }) => { + // TODO No signEvent, have a convenient way to do this + signEvent(createTextNote(note), publisherSecret).then((event) => { + publisher.on("ok", (params, nostr) => { + assert.strictEqual(nostr, publisher) + assert.strictEqual(params.eventId, event.id) + assert.strictEqual(params.relay.toString(), url.toString()) + assert.strictEqual(params.ok, true) + done() + }) + + publisher.publish(event) + }) + }) + }) +})