diff --git a/packages/nostr/package.json b/packages/nostr/package.json index b291a25..caf7b6e 100644 --- a/packages/nostr/package.json +++ b/packages/nostr/package.json @@ -9,5 +9,8 @@ }, "devDependencies": { "typescript": "^4.9.5" + }, + "prettier": { + "semi": false } } diff --git a/packages/nostr/src/error.ts b/packages/nostr/src/error.ts new file mode 100644 index 0000000..4869911 --- /dev/null +++ b/packages/nostr/src/error.ts @@ -0,0 +1,9 @@ +/** + * 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 new file mode 100644 index 0000000..9636844 --- /dev/null +++ b/packages/nostr/src/event.ts @@ -0,0 +1,174 @@ +import { ProtocolError } from "./error" +import { RawEvent } from "./raw" +import * as secp from "@noble/secp256k1" +import { PublicKey } from "./keypair" +import { parseHex } 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 | UnknownEvent + +interface EventCommon { + pubkey: PublicKey + createdAt: Date +} + +export interface SetMetadataEvent extends EventCommon { + kind: EventKind.SetMetadata + userMetadata: UserMetadata +} + +export interface UserMetadata { + name: string + about: string + picture: string +} + +export interface TextNoteEvent extends EventCommon { + kind: EventKind.TextNote + note: string +} + +export interface UnknownEvent extends EventCommon { + kind: Exclude +} + +export async function createEvent(raw: RawEvent): Promise { + const pubkey = new PublicKey(raw.pubkey) + const createdAt = new Date(raw.created_at * 1000) + const event = { + pubkey, + createdAt, + } + await checkSignature(raw, event) + return ( + createSetMetadataEvent(raw, event) ?? + createTextNodeEvent(raw, event) ?? + createUnknownEvent(raw, event) + ) +} + +function createSetMetadataEvent( + raw: RawEvent, + event: EventCommon +): SetMetadataEvent | undefined { + if (raw.kind !== EventKind.SetMetadata) { + return undefined + } + 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}`) + } + return { + ...event, + kind: EventKind.SetMetadata, + userMetadata, + } +} + +function createTextNodeEvent( + raw: RawEvent, + event: EventCommon +): TextNoteEvent | undefined { + if (raw.kind !== EventKind.TextNote) { + return undefined + } + return { + ...event, + kind: EventKind.TextNote, + note: raw.content, + } +} + +function createUnknownEvent(raw: RawEvent, event: EventCommon): UnknownEvent { + return { + ...event, + kind: raw.kind, + } +} + +export class EventId { + #hex: string + + constructor(hex: string | Uint8Array) { + this.#hex = parseHex(hex) + if (this.#hex.length !== 128) { + throw new ProtocolError(`invalid event id: ${this.#hex}`) + } + } + + toString(): string { + return this.#hex + } +} + +async function checkSignature( + raw: RawEvent, + event: EventCommon +): Promise { + const id = serializeId(raw) + const bytes = await secp.schnorr.sign(id.toString(), event.pubkey.toString()) + const hex = secp.utils.bytesToHex(bytes).toLowerCase() + if (hex.toString() !== raw.sig) { + throw new ProtocolError("invalid signature: ${hex}") + } +} + +async function serializeId(raw: RawEvent): Promise { + // It's not defined whether JSON.stringify produces a string with whitespace stripped. + // Building the JSON string manually this way 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 = `[${raw.tags + .map((tag) => `[${tag.map((v) => `"${v}"`).join(",")}]`) + .join(",")}]` + const serialized = `[0,"${raw.pubkey}",${raw.created_at},${raw.kind},${serializedTags},"${raw.content}"]` + const hash = await secp.utils.sha256(Uint8Array.from(charCodes(serialized))) + return new EventId(secp.utils.bytesToHex(hash).toLowerCase()) +} + +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 < length; i++) { + yield data.charCodeAt(i) + } +} + +// TODO This is an example of how this API can be used, remove this later +function isItNice(e: Event): void { + if (e.kind === EventKind.SetMetadata) { + console.log(e.userMetadata) + } else if (e.kind === EventKind.TextNote) { + console.log(e.note) + } +} diff --git a/packages/nostr/src/index.ts b/packages/nostr/src/index.ts index a3ae6c9..c74de3f 100644 --- a/packages/nostr/src/index.ts +++ b/packages/nostr/src/index.ts @@ -1,87 +1,3 @@ -export * from "./System"; -export * from "./Connection"; -export { default as EventKind } from "./EventKind"; -export { Subscriptions } from "./Subscriptions"; -export { default as Event } from "./Event"; -export { default as Tag } from "./Tag"; -export * from "./Links"; +export * from "./legacy" -export type RawEvent = { - id: u256; - pubkey: HexKey; - created_at: number; - kind: number; - tags: string[][]; - content: string; - sig: string; -}; - -export interface TaggedRawEvent extends RawEvent { - /** - * A list of relays this event was seen on - */ - relays: string[]; -} - -/** - * Basic raw key as hex - */ -export type HexKey = string; - -/** - * Optional HexKey - */ -export type MaybeHexKey = HexKey | undefined; - -/** - * A 256bit hex id - */ -export type u256 = string; - -/** - * Raw REQ filter object - */ -export type RawReqFilter = { - ids?: u256[]; - authors?: u256[]; - kinds?: number[]; - "#e"?: u256[]; - "#p"?: u256[]; - "#t"?: string[]; - "#d"?: string[]; - "#r"?: string[]; - search?: string; - since?: number; - until?: number; - limit?: number; -}; - -/** - * Medatadata event content - */ -export type UserMetadata = { - name?: string; - display_name?: string; - about?: string; - picture?: string; - website?: string; - banner?: string; - nip05?: string; - lud06?: string; - lud16?: string; -}; - -/** - * NIP-51 list types - */ -export enum Lists { - Muted = "mute", - Pinned = "pin", - Bookmarked = "bookmark", - Followed = "follow", -} - -export interface FullRelaySettings { - url: string; - settings: { read: boolean; write: boolean }; -} +// TODO This file should only contain re-exports and only re-export what is needed diff --git a/packages/nostr/src/keypair.ts b/packages/nostr/src/keypair.ts new file mode 100644 index 0000000..0a77d1c --- /dev/null +++ b/packages/nostr/src/keypair.ts @@ -0,0 +1,38 @@ +import { ProtocolError } from "./error" +import { parseHex } from "./util" + +/** + * A 32-byte secp256k1 public key. + */ +export class PublicKey { + #hex: string + + constructor(hex: string | Uint8Array) { + this.#hex = parseHex(hex) + if (this.#hex.length !== 64) { + throw new ProtocolError(`invalid pubkey: ${hex}`) + } + } + + toString(): string { + return this.#hex + } +} + +/** + * A 32-byte secp256k1 private key. + */ +export class PrivateKey { + #hex: string + + constructor(hex: string | Uint8Array) { + this.#hex = parseHex(hex) + if (this.#hex.length !== 64) { + throw new ProtocolError(`invalid private key: ${this.#hex}`) + } + } + + toString(): string { + return this.#hex + } +} diff --git a/packages/nostr/src/legacy/.prettierrc b/packages/nostr/src/legacy/.prettierrc new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/packages/nostr/src/legacy/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/packages/nostr/src/Connection.ts b/packages/nostr/src/legacy/Connection.ts similarity index 100% rename from packages/nostr/src/Connection.ts rename to packages/nostr/src/legacy/Connection.ts diff --git a/packages/nostr/src/ConnectionStats.ts b/packages/nostr/src/legacy/ConnectionStats.ts similarity index 100% rename from packages/nostr/src/ConnectionStats.ts rename to packages/nostr/src/legacy/ConnectionStats.ts diff --git a/packages/nostr/src/Const.ts b/packages/nostr/src/legacy/Const.ts similarity index 100% rename from packages/nostr/src/Const.ts rename to packages/nostr/src/legacy/Const.ts diff --git a/packages/nostr/src/Event.ts b/packages/nostr/src/legacy/Event.ts similarity index 100% rename from packages/nostr/src/Event.ts rename to packages/nostr/src/legacy/Event.ts diff --git a/packages/nostr/src/EventKind.ts b/packages/nostr/src/legacy/EventKind.ts similarity index 100% rename from packages/nostr/src/EventKind.ts rename to packages/nostr/src/legacy/EventKind.ts diff --git a/packages/nostr/src/Links.ts b/packages/nostr/src/legacy/Links.ts similarity index 100% rename from packages/nostr/src/Links.ts rename to packages/nostr/src/legacy/Links.ts diff --git a/packages/nostr/src/Nips.ts b/packages/nostr/src/legacy/Nips.ts similarity index 100% rename from packages/nostr/src/Nips.ts rename to packages/nostr/src/legacy/Nips.ts diff --git a/packages/nostr/src/RelayInfo.ts b/packages/nostr/src/legacy/RelayInfo.ts similarity index 100% rename from packages/nostr/src/RelayInfo.ts rename to packages/nostr/src/legacy/RelayInfo.ts diff --git a/packages/nostr/src/Subscriptions.ts b/packages/nostr/src/legacy/Subscriptions.ts similarity index 100% rename from packages/nostr/src/Subscriptions.ts rename to packages/nostr/src/legacy/Subscriptions.ts diff --git a/packages/nostr/src/System.ts b/packages/nostr/src/legacy/System.ts similarity index 100% rename from packages/nostr/src/System.ts rename to packages/nostr/src/legacy/System.ts diff --git a/packages/nostr/src/Tag.ts b/packages/nostr/src/legacy/Tag.ts similarity index 100% rename from packages/nostr/src/Tag.ts rename to packages/nostr/src/legacy/Tag.ts diff --git a/packages/nostr/src/Thread.ts b/packages/nostr/src/legacy/Thread.ts similarity index 100% rename from packages/nostr/src/Thread.ts rename to packages/nostr/src/legacy/Thread.ts diff --git a/packages/nostr/src/Util.ts b/packages/nostr/src/legacy/Util.ts similarity index 100% rename from packages/nostr/src/Util.ts rename to packages/nostr/src/legacy/Util.ts diff --git a/packages/nostr/src/legacy/index.ts b/packages/nostr/src/legacy/index.ts new file mode 100644 index 0000000..a3ae6c9 --- /dev/null +++ b/packages/nostr/src/legacy/index.ts @@ -0,0 +1,87 @@ +export * from "./System"; +export * from "./Connection"; +export { default as EventKind } from "./EventKind"; +export { Subscriptions } from "./Subscriptions"; +export { default as Event } from "./Event"; +export { default as Tag } from "./Tag"; +export * from "./Links"; + +export type RawEvent = { + id: u256; + pubkey: HexKey; + created_at: number; + kind: number; + tags: string[][]; + content: string; + sig: string; +}; + +export interface TaggedRawEvent extends RawEvent { + /** + * A list of relays this event was seen on + */ + relays: string[]; +} + +/** + * Basic raw key as hex + */ +export type HexKey = string; + +/** + * Optional HexKey + */ +export type MaybeHexKey = HexKey | undefined; + +/** + * A 256bit hex id + */ +export type u256 = string; + +/** + * Raw REQ filter object + */ +export type RawReqFilter = { + ids?: u256[]; + authors?: u256[]; + kinds?: number[]; + "#e"?: u256[]; + "#p"?: u256[]; + "#t"?: string[]; + "#d"?: string[]; + "#r"?: string[]; + search?: string; + since?: number; + until?: number; + limit?: number; +}; + +/** + * Medatadata event content + */ +export type UserMetadata = { + name?: string; + display_name?: string; + about?: string; + picture?: string; + website?: string; + banner?: string; + nip05?: string; + lud06?: string; + lud16?: string; +}; + +/** + * NIP-51 list types + */ +export enum Lists { + Muted = "mute", + Pinned = "pin", + Bookmarked = "bookmark", + Followed = "follow", +} + +export interface FullRelaySettings { + url: string; + settings: { read: boolean; write: boolean }; +} diff --git a/packages/nostr/src/nostr.ts b/packages/nostr/src/nostr.ts new file mode 100644 index 0000000..0a9d814 --- /dev/null +++ b/packages/nostr/src/nostr.ts @@ -0,0 +1,134 @@ +import { ProtocolError } from "./error" +import { EventId, Event } from "./event" +import { RawEvent } from "./raw" + +/** + * A nostr client. + */ +export class Nostr { + /** + * Open connections to relays. + */ + #conns: Conn[] = [] + /** + * Is this client closed? + */ + #closed: boolean = false + /** + * Mapping of subscription IDs to corresponding filters. + */ + #subscriptions: Map = new Map() + + #eventCallbacks: EventCallback[] = [] + #noticeCallbacks: NoticeCallback[] = [] + #errorCallbacks: ErrorCallback[] = [] + + /** + * Add a new callback for received events. + */ + onEvent(cb: EventCallback): void { + this.#eventCallbacks.push(cb) + } + + /** + * Add a new callback for received notices. + */ + onNotice(cb: NoticeCallback): void { + this.#noticeCallbacks.push(cb) + } + + /** + * Add a new callback for errors. + */ + onError(cb: ErrorCallback): void { + this.#errorCallbacks.push(cb) + } + + /** + * Connect and start communicating with a relay. This method recreates all existing + * subscriptions on the new relay as well. + */ + async connect(relay: URL | string): Promise { + this.#checkClosed() + throw new Error("todo try to connect and send subscriptions") + } + + /** + * Create a new subscription. + * + * @param subscriptionId An optional subscription ID, otherwise a random subscription ID will be used. + * @returns The subscription ID. + */ + async subscribe( + filters: Filters, + subscriptionId?: SubscriptionId | string + ): Promise { + this.#checkClosed() + throw new Error("todo subscribe to the relays and add the subscription") + } + + /** + * Remove a subscription. + */ + async unsubscribe(subscriptionId: SubscriptionId): Promise { + this.#checkClosed() + throw new Error( + "todo unsubscribe from the relays and remove the subscription" + ) + } + + /** + * Publish an event. + */ + async publish(event: Event): Promise { + this.#checkClosed() + throw new Error("todo") + } + + /** + * Close connections to all relays. This method can only be called once. After the + * connections have been closed, no other methods can be called. + */ + async close(): Promise {} + + #checkClosed() { + if (this.#closed) { + throw new Error("the client has been closed") + } + } +} + +/** + * A string uniquely identifying a client subscription. + */ +export class SubscriptionId { + #id: string + + constructor(subscriptionId: string) { + this.#id = subscriptionId + } + + toString() { + return this.#id + } +} + +/** + * Subscription filters. + */ +export interface Filters {} + +export type EventCallback = (params: EventParams, nostr: Nostr) => unknown +export type NoticeCallback = (notice: string, nostr: Nostr) => unknown +export type ErrorCallback = (error: ProtocolError, nostr: Nostr) => unknown + +export interface EventParams { + event: Event + id: EventId + raw: RawEvent +} + +/** + * The connection to a relay. + */ +class Conn {} diff --git a/packages/nostr/src/raw.ts b/packages/nostr/src/raw.ts new file mode 100644 index 0000000..66df762 --- /dev/null +++ b/packages/nostr/src/raw.ts @@ -0,0 +1,41 @@ +import { ProtocolError } from "./error" + +/** + * Raw event to be transferred over the wire. + */ +export interface RawEvent { + id: string + pubkey: string + created_at: number + kind: number + tags: string[][] + content: string + sig: string +} + +export function parseRawEvent(data: string): RawEvent { + const json = parseJson(data) + if ( + typeof json["id"] !== "string" || + typeof json["pubkey"] !== "string" || + typeof json["created_at"] !== "number" || + typeof json["kind"] !== "number" || + !(json["tags"] instanceof Array) || + !json["tags"].every( + (x) => x instanceof Array && x.every((y) => typeof y === "string") + ) || + typeof json["content"] !== "string" || + typeof json["sig"] !== "string" + ) { + throw new ProtocolError(`invalid event: ${data}`) + } + return json +} + +function parseJson(data: string) { + try { + return JSON.parse(data) + } catch (e) { + throw new ProtocolError(`invalid event json: ${data}`) + } +} diff --git a/packages/nostr/src/util.ts b/packages/nostr/src/util.ts new file mode 100644 index 0000000..6336476 --- /dev/null +++ b/packages/nostr/src/util.ts @@ -0,0 +1,22 @@ +import * as secp from "@noble/secp256k1" +import { ProtocolError } from "./error" + +/** + * Check that the input is a valid lowercase hex string. + */ +export function parseHex(hex: string | Uint8Array): string { + if (typeof hex === "string") { + const valid = "0123456789abcdef" + if (hex.length % 2 != 0) { + throw new ProtocolError(`invalid hex string: ${hex}`) + } + for (const c of hex) { + if (!valid.includes(c)) { + throw new ProtocolError(`invalid hex string: ${hex}`) + } + } + return hex + } else { + return secp.utils.bytesToHex(hex).toLowerCase() + } +}