From 1543d1d84512728c8e853976ff1daad33aed72df Mon Sep 17 00:00:00 2001 From: ennmichael Date: Fri, 14 Apr 2023 17:59:57 +0200 Subject: [PATCH] implement delegation, refactor event types --- packages/nostr/.eslintrc.cjs | 2 +- packages/nostr/src/client/conn.ts | 10 +- packages/nostr/src/client/emitter.ts | 2 +- packages/nostr/src/client/index.ts | 13 +- packages/nostr/src/common.ts | 12 + packages/nostr/src/event/common.ts | 53 ++++ packages/nostr/src/event/contact-list.ts | 89 ------ packages/nostr/src/event/delegation.ts | 239 ++++++++++++++++ packages/nostr/src/event/deletion.ts | 48 ---- packages/nostr/src/event/direct-message.ts | 138 --------- packages/nostr/src/event/index.ts | 267 ++++++++---------- packages/nostr/src/event/kind/contact-list.ts | 115 ++++++++ packages/nostr/src/event/kind/deletion.ts | 74 +++++ .../nostr/src/event/kind/direct-message.ts | 170 +++++++++++ packages/nostr/src/event/kind/set-metadata.ts | 146 ++++++++++ packages/nostr/src/event/kind/text-note.ts | 55 ++++ packages/nostr/src/event/kind/unknown.ts | 62 ++++ packages/nostr/src/event/set-metadata.ts | 123 -------- packages/nostr/src/event/text.ts | 25 -- packages/nostr/src/nostr-object.ts | 7 +- packages/nostr/test/setup.ts | 6 +- packages/nostr/test/test.contact-list.ts | 11 +- packages/nostr/test/test.delegation.ts | 197 +++++++++++++ packages/nostr/test/test.deletion.ts | 31 +- packages/nostr/test/test.direct-message.ts | 34 +-- .../nostr/test/test.internet-identifier.ts | 26 +- packages/nostr/test/test.set-metadata.ts | 20 +- packages/nostr/test/test.text-note.ts | 17 +- 28 files changed, 1335 insertions(+), 657 deletions(-) create mode 100644 packages/nostr/src/event/common.ts delete mode 100644 packages/nostr/src/event/contact-list.ts create mode 100644 packages/nostr/src/event/delegation.ts delete mode 100644 packages/nostr/src/event/deletion.ts delete mode 100644 packages/nostr/src/event/direct-message.ts create mode 100644 packages/nostr/src/event/kind/contact-list.ts create mode 100644 packages/nostr/src/event/kind/deletion.ts create mode 100644 packages/nostr/src/event/kind/direct-message.ts create mode 100644 packages/nostr/src/event/kind/set-metadata.ts create mode 100644 packages/nostr/src/event/kind/text-note.ts create mode 100644 packages/nostr/src/event/kind/unknown.ts delete mode 100644 packages/nostr/src/event/set-metadata.ts delete mode 100644 packages/nostr/src/event/text.ts create mode 100644 packages/nostr/test/test.delegation.ts diff --git a/packages/nostr/.eslintrc.cjs b/packages/nostr/.eslintrc.cjs index b732448c..cb881fa3 100644 --- a/packages/nostr/.eslintrc.cjs +++ b/packages/nostr/.eslintrc.cjs @@ -3,7 +3,7 @@ module.exports = { parser: "@typescript-eslint/parser", plugins: ["@typescript-eslint"], root: true, - ignorePatterns: ["dist/", "src/legacy"], + ignorePatterns: ["dist/", "src/legacy", "webpack.config.js"], env: { browser: true, node: true, diff --git a/packages/nostr/src/client/conn.ts b/packages/nostr/src/client/conn.ts index 4584fbe9..6791faa5 100644 --- a/packages/nostr/src/client/conn.ts +++ b/packages/nostr/src/client/conn.ts @@ -1,6 +1,6 @@ import { NostrError, parseJson } from "../common" import { SubscriptionId } from "." -import { EventId, RawEvent } from "../event" +import { EventId, EventProps } from "../event" import WebSocket from "isomorphic-ws" import { Filters } from "../filters" @@ -127,7 +127,7 @@ export type IncomingKind = "event" | "notice" | "ok" | "eose" | "auth" export interface IncomingEvent { kind: "event" subscriptionId: SubscriptionId - event: RawEvent + event: EventProps } /** @@ -178,7 +178,7 @@ export type OutgoingKind = "event" | "openSubscription" | "closeSubscription" */ export interface OutgoingEvent { kind: "event" - event: RawEvent + event: EventProps } /** @@ -304,7 +304,7 @@ function parseIncomingMessage(data: string): IncomingMessage { throw new NostrError(`unknown incoming message: ${data}`) } -function parseEventData(json: { [key: string]: unknown }): RawEvent { +function parseEventData(json: { [key: string]: unknown }): EventProps { if ( typeof json["id"] !== "string" || typeof json["pubkey"] !== "string" || @@ -319,5 +319,5 @@ function parseEventData(json: { [key: string]: unknown }): RawEvent { ) { throw new NostrError(`invalid event: ${JSON.stringify(json)}`) } - return json as unknown as RawEvent + return json as unknown as EventProps } diff --git a/packages/nostr/src/client/emitter.ts b/packages/nostr/src/client/emitter.ts index 59663f4d..22bdeb76 100644 --- a/packages/nostr/src/client/emitter.ts +++ b/packages/nostr/src/client/emitter.ts @@ -1,6 +1,6 @@ import Base from "events" import { Nostr, SubscriptionId } from "." -import { Event, EventId } from "../event" +import { EventId, Event } from "../event" /** * Overrides providing better types for EventEmitter methods. diff --git a/packages/nostr/src/client/index.ts b/packages/nostr/src/client/index.ts index df11d1bc..bb1fdf09 100644 --- a/packages/nostr/src/client/index.ts +++ b/packages/nostr/src/client/index.ts @@ -1,10 +1,12 @@ import { NostrError } from "../common" -import { RawEvent, parseEvent } from "../event" +import { EventProps, verifySignature } from "../event" import { Conn } from "./conn" import * as secp from "@noble/secp256k1" import { EventEmitter } from "./emitter" import { fetchRelayInfo, ReadyState, Relay } from "./relay" import { Filters } from "../filters" +import { parseEvent } from "../event" +import { verifyDelegation } from "../event/delegation" /** * A nostr client. @@ -82,10 +84,14 @@ export class Nostr extends EventEmitter { // Handle messages on this connection. onMessage: async (msg) => { if (msg.kind === "event") { + await Promise.all([ + verifySignature(msg.event), + verifyDelegation(msg.event), + ]) this.emit( "event", { - event: await parseEvent(msg.event), + event: parseEvent(msg.event), subscriptionId: msg.subscriptionId, }, this @@ -270,7 +276,8 @@ export class Nostr extends EventEmitter { /** * Publish an event. */ - publish(event: RawEvent): void { + async publish(event: EventProps): Promise { + await Promise.all([verifySignature(event), verifyDelegation(event)]) for (const { conn, write } of this.#conns.values()) { if (!write) { continue diff --git a/packages/nostr/src/common.ts b/packages/nostr/src/common.ts index b1c542f6..db20f683 100644 --- a/packages/nostr/src/common.ts +++ b/packages/nostr/src/common.ts @@ -40,3 +40,15 @@ export class NostrError extends Error { super(message) } } + +/** + * Recursive readonly type. + */ +export type DeepReadonly = T extends ( + this: unknown, + ...args: unknown[] +) => unknown + ? T + : { + readonly [P in keyof T]: DeepReadonly + } diff --git a/packages/nostr/src/event/common.ts b/packages/nostr/src/event/common.ts new file mode 100644 index 00000000..e9174ff1 --- /dev/null +++ b/packages/nostr/src/event/common.ts @@ -0,0 +1,53 @@ +import { EventProps, EventKind } from "." +import { Timestamp } from "../common" +import { PublicKey } from "../crypto" +import { Delegation, DelegationConditions, getDelegation } from "./delegation" + +/** + * The base class for all event classes. + */ +export class EventCommon implements EventProps { + readonly id: string + readonly pubkey: PublicKey + readonly created_at: Timestamp + readonly sig: string + readonly kind: EventKind + readonly tags: readonly (readonly string[])[] + readonly content: string + + constructor(props: EventProps) { + // TODO Check the event format, lowercase hex, anything else? + // TODO Check that each tag has at least one element (right?) + this.id = props.id + this.pubkey = props.pubkey + this.created_at = props.created_at + this.sig = props.sig + this.kind = props.kind + this.tags = structuredClone(props.tags) + this.content = props.content + } + + /** + * Get the event author. If the event is delegated, returns the delegator. Otherwise, returns the + * pubkey field of the event. + */ + get author(): PublicKey { + return this.delegation?.delegator ?? this.pubkey + } + + /** + * Get the delegation from the event. If the event is not delegated, return undefined. + */ + get delegation(): + | Delegation< + DelegationConditions & { + /** + * The raw delegation string as it was specified in the event. + */ + str: string + } + > + | undefined { + return getDelegation(this) + } +} diff --git a/packages/nostr/src/event/contact-list.ts b/packages/nostr/src/event/contact-list.ts deleted file mode 100644 index f3b31998..00000000 --- a/packages/nostr/src/event/contact-list.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { EventKind, RawEvent, signEvent } from "." -import { NostrError } from "../common" -import { HexOrBechPrivateKey, 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[], - priv?: HexOrBechPrivateKey -): Promise { - return signEvent( - { - kind: EventKind.ContactList, - tags: contacts.map((contact) => [ - "p", - contact.pubkey, - contact.relay?.toString() ?? "", - contact.petname ?? "", - ]), - content: "", - getContacts, - }, - priv - ) -} - -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/delegation.ts b/packages/nostr/src/event/delegation.ts new file mode 100644 index 00000000..ca539969 --- /dev/null +++ b/packages/nostr/src/event/delegation.ts @@ -0,0 +1,239 @@ +import { NostrError, DeepReadonly, Timestamp } from "../common" +import { + getPublicKey, + HexOrBechPrivateKey, + HexOrBechPublicKey, + parsePrivateKey, + parsePublicKey, + PublicKey, + schnorrSign, + sha256, +} from "../crypto" +import * as secp256k1 from "@noble/secp256k1" +import { EventKind } from "../legacy" +import { EventProps } from "." + +/** + * Event delegation. + * TODO Write a lot more detail about the delegation process, and link other functions + * back to this documentation. + * + * Related NIPs: NIP-26. + */ +export interface Delegation { + delegator: PublicKey + conditions: C + token: string +} + +/** + * The conditions for a delegation. + * TODO Describe the string format + */ +export interface DelegationConditions { + /** + * The kinds of events that can be published with this delegation token. + */ + kinds: EventKind[] + /** + * The time before which events can be published with this delegation token. + */ + before?: Timestamp + /** + * The time after which events can be published with this delegation token. + */ + after?: Timestamp +} + +/** + * Create a delegation that allows delegatee to publish events in the name of priv. + * If no conditions are specified, the delegation will allow the delegatee to publish + * any event. + */ +export async function createDelegation( + delegatee: HexOrBechPublicKey, + priv: HexOrBechPrivateKey, + conditions?: DeepReadonly> +): Promise> { + delegatee = parsePublicKey(delegatee) + priv = parsePrivateKey(priv) + const delegationConditions = formatDelegationConditions(conditions ?? {}) + const token = `nostr:delegation:${delegatee}:${delegationConditions}` + return { + delegator: getPublicKey(priv), + conditions: delegationConditions, + token: await schnorrSign( + await sha256(new TextEncoder().encode(token)), + priv + ), + } +} + +/** + * Parse the delegation conditions string. + */ +export function parseDelegationConditions( + conditions: string +): DelegationConditions & { str: string } { + let before: number | undefined + let after: number | undefined + const kinds: EventKind[] = [] + if (conditions !== "") { + for (let condition of conditions.split("&")) { + if (condition.startsWith("kind=")) { + condition = condition.replace("kind=", "") + const kind = parseInt(condition) + if (Number.isNaN(kind)) { + throw new NostrError("invalid delegation condition") + } + kinds.push(kind) + } else if (condition.startsWith("created_at<")) { + if (before !== undefined) { + throw new NostrError( + `invalid delegation condition ${condition}: created_at< already specified` + ) + } + condition = condition.replace("created_at<", "") + const timestamp = parseInt(condition) + if (Number.isNaN(timestamp)) { + throw new NostrError( + `invalid delegation condition ${condition}: invalid timestamp` + ) + } + before = timestamp + } else if (condition.startsWith("created_at>")) { + if (after !== undefined) { + throw new NostrError( + `invalid delegation condition ${condition}: created_at> already specified` + ) + } + condition = condition.replace("created_at>", "") + const timestamp = parseInt(condition) + if (Number.isNaN(timestamp)) { + throw new NostrError( + `invalid delegation condition ${condition}: invalid timestamp` + ) + } + after = timestamp + } else { + throw new NostrError( + `invalid delegation condition ${condition}: unknown field` + ) + } + } + } + return { + kinds, + before, + after, + str: conditions, + } +} + +/** + * Format the delegation conditions into a string. + */ +export function formatDelegationConditions( + conditions: DeepReadonly> +): string { + const pieces = (conditions.kinds ?? []).map((k) => `kind=${k}`) + if (conditions.before !== undefined) { + pieces.push(`created_at<${conditions.before}`) + } + if (conditions.after !== undefined) { + pieces.push(`created_at>${conditions.after}`) + } + return pieces.join("&") +} + +// TODO Not exposed to the user +/** + * Get the delegation from an event. + */ +export function getDelegation( + event: EventProps +): Delegation | undefined { + // Get the delegation tag. + const delegations = event.tags.filter((t) => t[0] === "delegation") + if (delegations.length === 0) { + return undefined + } + if (delegations.length > 1) { + throw new NostrError("multiple delegations") + } + const delegation = delegations[0] + + // TODO Validate the length, field types, hex keys, check the length of the delegation token + + return { + delegator: delegation[1], + conditions: parseDelegationConditions(delegation[2]), + token: delegation[3], + } +} + +// TODO Not exposed to the user +/** + * Create an event tag which represents the delegation. + */ +export function delegationTag(delegation: Delegation): string[] { + return [ + "delegation", + delegation.delegator, + delegation.conditions, + delegation.token, + ] +} + +// TODO Not exposed to the user +/** + * Verify that the delegation of an event is valid. This includes checking the + * signature in the delegation token, and checking that the conditions are met. + */ +export async function verifyDelegation(event: EventProps): Promise { + const delegation = getDelegation(event) + if (delegation === undefined) { + return + } + + // Check the Schnorr signature inside the delegation token. + if ( + !(await secp256k1.schnorr.verify( + delegation.token, + await sha256( + new TextEncoder().encode( + `nostr:delegation:${event.pubkey}:${delegation.conditions.str}` + ) + ), + delegation.delegator + )) + ) { + throw new NostrError("invalid delegation token: invalid schnorr signature") + } + + // Check the delegation conditions. + if ( + delegation.conditions.kinds.length > 0 && + !delegation.conditions.kinds.includes(event.kind) + ) { + throw new NostrError( + `invalid delegation: event kind ${event.kind} not allowed` + ) + } + if ( + delegation.conditions.before !== undefined && + event.created_at >= delegation.conditions.before + ) { + throw new NostrError( + `invalid delegation: event.created_at ${event.created_at} is not before ${before}` + ) + } + if ( + delegation.conditions.after !== undefined && + event.created_at <= delegation.conditions.after + ) { + throw new NostrError( + `invalid delegation: event.created_at ${event.created_at} is not after ${after}` + ) + } +} diff --git a/packages/nostr/src/event/deletion.ts b/packages/nostr/src/event/deletion.ts deleted file mode 100644 index 1fa8cca7..00000000 --- a/packages/nostr/src/event/deletion.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { EventId, EventKind, RawEvent, signEvent } from "." -import { NostrError } from "../common" -import { HexOrBechPrivateKey } from "../crypto" - -/** - * A deletion event. Used for marking published events as deleted. - * - * Related NIPs: NIP-09. - */ -export interface Deletion extends RawEvent { - kind: EventKind.Deletion - - /** - * The IDs of events to delete. - */ - getEvents(): EventId[] -} - -/** - * Create a deletion event. - */ -export function createDeletion( - { events, content }: { events: EventId[]; content?: string }, - priv?: HexOrBechPrivateKey -): Promise { - return signEvent( - { - kind: EventKind.Deletion, - tags: events.map((id) => ["e", id]), - content: content ?? "", - getEvents, - }, - priv - ) -} - -export function getEvents(this: Deletion): EventId[] { - return this.tags - .filter((tag) => tag[0] === "e") - .map((tag) => { - if (tag[1] === undefined) { - throw new NostrError( - `invalid deletion event tag: ${JSON.stringify(tag)}` - ) - } - return tag[1] - }) -} diff --git a/packages/nostr/src/event/direct-message.ts b/packages/nostr/src/event/direct-message.ts deleted file mode 100644 index e08df8c6..00000000 --- a/packages/nostr/src/event/direct-message.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { EventId, EventKind, RawEvent, signEvent } from "." -import { 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) { - if ( - typeof window === "undefined" || - window.nostr?.nip04?.encrypt === undefined - ) { - throw new NostrError("private key not specified") - } - const content = await window.nostr.nip04.encrypt(recipient, message) - return await signEvent( - { - kind: EventKind.DirectMessage, - tags: [["p", recipient]], - content, - getMessage, - getRecipient, - getPrevious, - }, - priv - ) - } 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: DirectMessage, - 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) { - if ( - typeof window === "undefined" || - window.nostr?.nip04?.decrypt === undefined - ) { - throw new NostrError("private key not specified") - } - return await window.nostr.nip04.decrypt(this.pubkey, this.content) - } else if (getPublicKey(priv) === this.getRecipient()) { - return await aesDecryptBase64(this.pubkey, priv, { data, iv }) - } - return undefined -} - -export function getRecipient(this: DirectMessage): 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: DirectMessage): 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 index 8a3d853f..f6fbf0de 100644 --- a/packages/nostr/src/event/index.ts +++ b/packages/nostr/src/event/index.ts @@ -1,38 +1,25 @@ +import { DeepReadonly, NostrError, Timestamp, unixTimestamp } from "../common" import { - PublicKey, - sha256, - schnorrSign, - schnorrVerify, getPublicKey, HexOrBechPrivateKey, parsePrivateKey, + PublicKey, + schnorrSign, + schnorrVerify, + sha256, } from "../crypto" -import { Timestamp, unixTimestamp, NostrError } from "../common" -import { TextNote } from "./text" -import { - getUserMetadata, - SetMetadata, - verifyInternetIdentifier, -} from "./set-metadata" -import { - DirectMessage, - getMessage, - getPrevious, - getRecipient, -} from "./direct-message" -import { ContactList, getContacts } from "./contact-list" -import { Deletion, getEvents } from "./deletion" +import { ContactList } from "./kind/contact-list" +import { Deletion } from "./kind/deletion" +import { DirectMessage } from "./kind/direct-message" +import { SetMetadata } from "./kind/set-metadata" +import { TextNote } from "./kind/text-note" +import { Unknown } from "./kind/unknown" import "../nostr-object" -// 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 - +/** + * The enumeration of all known event kinds. Used to discriminate between different + * event types. + */ export enum EventKind { SetMetadata = 0, // NIP-01 TextNote = 1, // NIP-01 @@ -52,31 +39,52 @@ export enum EventKind { } /** - * A nostr event in the format that's sent across the wire. + * For an explanation of the fields, see @see EventProps. */ -export interface RawEvent { - id: string - pubkey: PublicKey - created_at: Timestamp +export interface InputEventProps { + tags?: string[][] + content?: string + created_at?: Timestamp +} + +/** + * The fields of an unsigned event. These can be used when building a nonstandard + * event. + * + * TODO Document the fields + */ +export interface UnsignedEventProps { kind: EventKind tags: string[][] content: string - sig: string - - [key: string]: unknown + created_at?: Timestamp } -export interface Unknown extends RawEvent { - kind: Exclude< - EventKind, - | EventKind.SetMetadata - | EventKind.TextNote - | EventKind.ContactList - | EventKind.DirectMessage - | EventKind.Deletion - > +/** + * The fields of a signed event. + * + * This type is strictly readonly because it's signed. Changing any of the fields would + * invalidate the signature. + * + * TODO Document the fields + */ +export interface EventProps extends DeepReadonly { + readonly id: string + readonly pubkey: PublicKey + readonly sig: string + readonly created_at: Timestamp } +/** + * An event. The `kind` field can be used to determine the event type at compile + * time. The event signature and delegation token are verified when the event is + * received or published. The event format is verified at construction time. + * + * Events are immutable because they're signed. Changing them after singing would + * invalidate the signature. + * + * TODO Document how to work with unsigned events and why one would want to do that. + */ export type Event = | SetMetadata | TextNote @@ -91,143 +99,92 @@ export type Event = export type EventId = string /** - * An unsigned event. + * Calculate the id, sig, and pubkey fields of the event. Set created_at to the current timestamp, + * if missing. Return a signed clone of the event. */ -export type Unsigned = { - [Property in keyof UnsignedWithPubkey as Exclude< - Property, - "pubkey" - >]: T[Property] -} & { - pubkey?: PublicKey -} - -/** - * Same as @see {@link Unsigned}, but with the pubkey field. - */ -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, +export async function signEvent( + event: DeepReadonly, priv?: HexOrBechPrivateKey -): Promise { - event.created_at ??= unixTimestamp() +): Promise { + const createdAt = 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 + const pubkey = getPublicKey(priv) + const id = await serializeEventId(event, pubkey, createdAt) + const sig = await schnorrSign(id, priv) + return { + ...structuredClone(event), + id, + sig, + pubkey, + created_at: createdAt, + } } else { if (typeof window === "undefined" || window.nostr === undefined) { - throw new NostrError("no private key provided") - } - // Extensions like nos2x expect to receive only the event data, without any of the methods. - const methods: { [key: string]: unknown } = {} - for (const [key, value] of Object.entries(event)) { - if (typeof value === "function") { - methods[key] = value - delete event[key] - } + throw new NostrError( + "no private key provided and window.nostr is not available" + ) } const signed = await window.nostr.signEvent(event) - return { - ...signed, - ...methods, - } + return structuredClone(signed) } } +// TODO Shouldn't be exposed to the user /** - * Parse an event from its raw format. + * Parse an event from its raw fields. */ -export async function parseEvent(event: RawEvent): Promise { - if (event.id !== (await serializeEventId(event))) { +export function parseEvent(event: EventProps): Event { + if (event.kind === EventKind.TextNote) { + return new TextNote(event) + } + if (event.kind === EventKind.SetMetadata) { + return new SetMetadata(event) + } + if (event.kind === EventKind.DirectMessage) { + return new DirectMessage(event) + } + if (event.kind === EventKind.ContactList) { + return new ContactList(event) + } + if (event.kind === EventKind.Deletion) { + return new Deletion(event) + } + return new Unknown(event) +} + +// TODO Probably shouldn't be exposed to the user +/** + * Verify the signature of the event. If the signature is invalid, throw @see NostrError. + */ +export async function verifySignature(event: EventProps): Promise { + if ( + event.id !== (await serializeEventId(event, event.pubkey, event.created_at)) + ) { throw new NostrError( `invalid id ${event.id} for event ${JSON.stringify( event - )}, expected ${await serializeEventId(event)}` + )}, expected ${await serializeEventId( + event, + event.pubkey, + event.created_at + )}` ) } 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, - verifyInternetIdentifier, - } - } - - if (event.kind === EventKind.DirectMessage) { - return { - ...event, - kind: EventKind.DirectMessage, - getMessage, - getRecipient, - getPrevious, - } - } - - if (event.kind === EventKind.ContactList) { - return { - ...event, - kind: EventKind.ContactList, - getContacts, - } - } - - if (event.kind === EventKind.Deletion) { - return { - ...event, - kind: EventKind.Deletion, - getEvents, - } - } - - return { - ...event, - kind: event.kind, - } } async function serializeEventId( - event: UnsignedWithPubkey + event: DeepReadonly, + pubkey: PublicKey, + createdAt: Timestamp ): Promise { const serialized = JSON.stringify([ 0, - event.pubkey, - event.created_at, + pubkey, + createdAt, event.kind, event.tags, event.content, diff --git a/packages/nostr/src/event/kind/contact-list.ts b/packages/nostr/src/event/kind/contact-list.ts new file mode 100644 index 00000000..58621a7b --- /dev/null +++ b/packages/nostr/src/event/kind/contact-list.ts @@ -0,0 +1,115 @@ +import { + EventKind, + EventProps, + signEvent, + InputEventProps, + verifySignature, +} from ".." +import { DeepReadonly, NostrError } from "../../common" +import { HexOrBechPrivateKey, PublicKey } from "../../crypto" +import { EventCommon } from "../common" +import { Delegation, delegationTag, verifyDelegation } from "../delegation" + +/** + * A contact from the contact list. + */ +export interface Contact { + pubkey: PublicKey + relay?: URL + petname?: string +} + +/** + * Contact list event. + * + * Related NIPs: NIP-02. + */ +export class ContactList extends EventCommon { + override readonly kind: EventKind.ContactList + + constructor(props: EventProps) { + super(props) + if (props.kind !== EventKind.ContactList) { + throw new NostrError("invalid event kind") + } + this.kind = props.kind + } + + /** + * Create a contact list event. + */ + static async create( + opts: DeepReadonly<{ + contacts: Contact[] + base?: InputEventProps + priv?: HexOrBechPrivateKey + delegation?: Delegation + }> + ): Promise { + const tags = structuredClone((opts.base?.tags as string[][]) ?? []) + if (opts.delegation !== undefined) { + tags.push(delegationTag(opts.delegation)) + } + tags.push( + ...opts.contacts.map((contact) => [ + "p", + contact.pubkey, + contact.relay?.toString() ?? "", + contact.petname ?? "", + ]) + ) + const base = { + kind: EventKind.ContactList, + tags, + content: opts.base?.content ?? "", + created_at: opts.base?.created_at, + } + const event = new ContactList(await signEvent(base, opts.priv)) + // Verify the delegation for correctness and verify the signature as a sanity check. + await Promise.all([verifySignature(event), verifyDelegation(event)]) + return event + } + + /** + * Get the contacts from the contact list. + */ + get contacts(): 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/kind/deletion.ts b/packages/nostr/src/event/kind/deletion.ts new file mode 100644 index 00000000..368c9dde --- /dev/null +++ b/packages/nostr/src/event/kind/deletion.ts @@ -0,0 +1,74 @@ +import { + EventKind, + EventProps, + EventId, + InputEventProps, + signEvent, + verifySignature, +} from ".." +import { DeepReadonly, NostrError } from "../../common" +import { HexOrBechPrivateKey } from "../../crypto" +import { EventCommon } from "../common" +import { Delegation, delegationTag, verifyDelegation } from "../delegation" + +/** + * A deletion event. Used for marking published events as deleted. + * + * Related NIPs: NIP-09. + */ +export class Deletion extends EventCommon { + override readonly kind: EventKind.Deletion + + constructor(props: EventProps) { + super(props) + if (props.kind !== EventKind.Deletion) { + throw new NostrError("invalid event kind") + } + this.kind = props.kind + } + + /** + * Create a deletion event. + */ + static async create( + opts: DeepReadonly<{ + events: EventId[] + content?: string + base?: InputEventProps + priv?: HexOrBechPrivateKey + delegation?: Delegation + }> + ): Promise { + const tags = structuredClone((opts.base?.tags as string[][]) ?? []) + if (opts.delegation !== undefined) { + tags.push(delegationTag(opts.delegation)) + } + tags.push(...opts.events.map((id) => ["e", id])) + const base = { + kind: EventKind.Deletion, + tags, + content: opts.base?.content ?? "", + created_at: opts.base?.created_at, + } + const event = new Deletion(await signEvent(base, opts.priv)) + // Verify the delegation for correctness and verify the signature as a sanity check. + await Promise.all([verifySignature(event), verifyDelegation(event)]) + return event + } + + /** + * The IDs of events to delete. + */ + get deletedEvents(): EventId[] { + return this.tags + .filter((tag) => tag[0] === "e") + .map((tag) => { + if (tag[1] === undefined) { + throw new NostrError( + `invalid deletion event tag: ${JSON.stringify(tag)}` + ) + } + return tag[1] + }) + } +} diff --git a/packages/nostr/src/event/kind/direct-message.ts b/packages/nostr/src/event/kind/direct-message.ts new file mode 100644 index 00000000..5f3a2f0b --- /dev/null +++ b/packages/nostr/src/event/kind/direct-message.ts @@ -0,0 +1,170 @@ +import { + EventKind, + EventProps, + EventId, + InputEventProps, + signEvent, + verifySignature, +} from ".." +import { DeepReadonly, NostrError } from "../../common" +import { + PublicKey, + PrivateKey, + parsePublicKey, + parsePrivateKey, + aesEncryptBase64, + HexOrBechPrivateKey, + aesDecryptBase64, + getPublicKey, +} from "../../crypto" +import { EventCommon } from "../common" +import { Delegation, delegationTag, verifyDelegation } from "../delegation" + +/** + * An encrypted direct message event. + * + * Related NIPs: NIP-04. + */ +export class DirectMessage extends EventCommon { + override readonly kind: EventKind.DirectMessage + + constructor(props: EventProps) { + super(props) + if (props.kind !== EventKind.DirectMessage) { + throw new NostrError("invalid event kind") + } + this.kind = props.kind + } + + /** + * Create an encrypted direct message event. + */ + static async create( + opts: DeepReadonly<{ + message: string + recipient: PublicKey + previous?: EventId + base?: InputEventProps + priv?: PrivateKey + delegation?: Delegation + }> + ): Promise { + const recipient = parsePublicKey(opts.recipient) + const tags = structuredClone((opts.base?.tags as string[][]) ?? []) + + // The user may not specify any "p" or "e" tags in the base event, since those are + // reserved for the recipient pubkey and previous event ID. + if (tags.some((tag) => tag[0] === "p")) { + throw new NostrError("cannot specify recipient pubkey in base tags") + } + if (tags.some((tag) => tag[0] === "e")) { + throw new NostrError("cannot specify previous event ID in base tags") + } + + // Build the tags. + if (opts.delegation !== undefined) { + tags.push(delegationTag(opts.delegation)) + } + tags.push(["p", recipient]) + if (opts.previous !== undefined) { + tags.push(["e", opts.previous]) + } + + let event: EventProps + if (opts.priv === undefined) { + // Encrypt the message using window.nostr.nip04. + if ( + typeof window === "undefined" || + window.nostr?.nip04?.encrypt === undefined + ) { + throw new NostrError("private key not specified") + } + const content = await window.nostr.nip04.encrypt(recipient, opts.message) + event = await signEvent({ + kind: EventKind.DirectMessage, + tags, + content, + created_at: opts.base?.created_at, + }) + } else { + // Encrypt the message using the provided private key. + const priv = parsePrivateKey(opts.priv) + const { data, iv } = await aesEncryptBase64(priv, recipient, opts.message) + event = await signEvent( + { + kind: EventKind.DirectMessage, + tags, + content: `${data}?iv=${iv}`, + created_at: opts.base?.created_at, + }, + priv + ) + } + // Verify the delegation for correctness and verify the signature as a sanity check. + await Promise.all([verifySignature(event), verifyDelegation(event)]) + return new DirectMessage(event) + } + + /** + * Get the message plaintext, or undefined if you are not the recipient. + */ + async getMessage(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) { + // Decrypt the message using window.nostr.nip04. + if ( + typeof window === "undefined" || + window.nostr?.nip04?.decrypt === undefined + ) { + throw new NostrError("private key not specified") + } + if ((await window.nostr.getPublicKey()) !== this.recipient) { + // The message is not intended for this user. + return undefined + } + return await window.nostr.nip04.decrypt(this.pubkey, this.content) + } else { + if (getPublicKey(priv) !== this.recipient) { + // The message is not intended for this user. + return undefined + } + return await aesDecryptBase64(this.pubkey, priv, { data, iv }) + } + } + + /** + * Get the recipient pubkey. + */ + get recipient(): PublicKey { + const recipientTag = this.tags.find((tag) => tag[0] === "p") + if (recipientTag?.[1] === undefined) { + throw new NostrError( + `expected "p" tag to be present with two elements in event ${JSON.stringify( + this + )}` + ) + } + return recipientTag[1] + } + + /** + * Get the event ID of the previous message. + */ + get previous(): EventId | undefined { + const previousTag = this.tags.find((tag) => tag[0] === "e") + if (previousTag?.[1] === undefined) { + throw new NostrError( + `expected "e" tag to be present with two elements in event ${JSON.stringify( + this + )}` + ) + } + return previousTag[1] + } +} diff --git a/packages/nostr/src/event/kind/set-metadata.ts b/packages/nostr/src/event/kind/set-metadata.ts new file mode 100644 index 00000000..ad5cfc12 --- /dev/null +++ b/packages/nostr/src/event/kind/set-metadata.ts @@ -0,0 +1,146 @@ +import { + EventKind, + EventProps, + InputEventProps, + signEvent, + verifySignature, +} from ".." +import { DeepReadonly, NostrError, parseJson } from "../../common" +import { HexOrBechPrivateKey } from "../../crypto" +import { EventCommon } from "../common" +import { Delegation, delegationTag, verifyDelegation } from "../delegation" + +export interface UserMetadata { + name: string + about: string + picture: string + nip05?: string +} + +export interface InternetIdentifier { + name: string + relays?: string[] +} + +export interface VerificationOptions { + readonly https?: boolean +} + +/** + * Set metadata event. Used for disseminating use profile information. + * + * Related NIPs: NIP-01. + */ +export class SetMetadata extends EventCommon { + override readonly kind: EventKind.SetMetadata + + constructor(event: EventProps) { + super(event) + if (event.kind !== EventKind.SetMetadata) { + throw new NostrError("invalid event kind") + } + this.kind = event.kind + } + + static async create( + opts: DeepReadonly<{ + userMetadata: UserMetadata + base?: InputEventProps + priv?: HexOrBechPrivateKey + delegation?: Delegation + }> + ): Promise { + const tags = structuredClone((opts.base?.tags as string[][]) ?? []) + if (opts.delegation !== undefined) { + tags.push(delegationTag(opts.delegation)) + } + const event = await signEvent( + { + kind: EventKind.SetMetadata, + tags, + content: JSON.stringify(opts.userMetadata), + created_at: opts.base?.created_at, + }, + opts.priv + ) + // Verify the delegation for correctness and verify the signature as a sanity check. + await Promise.all([verifySignature(event), verifyDelegation(event)]) + return new SetMetadata(event) + } + + /** + * Get the user metadata specified in this event. + */ + get userMetadata(): 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 + } + + /** + * Verify the NIP-05 DNS-based internet identifier associated with the user metadata. + * Throws if the internet identifier is invalid or fails verification. + * + * @param pubkey The public key to use if the event does not specify a pubkey. If the event + * does specify a pubkey + * @return The internet identifier. `undefined` if there is no internet identifier. + * + * Related NIPs: NIP-05. + */ + async verifyInternetIdentifier( + this: SetMetadata, + opts?: VerificationOptions + ): Promise { + const metadata = this.userMetadata + if (metadata.nip05 === undefined) { + return undefined + } + const [name, domain] = metadata.nip05.split("@") + if ( + name === undefined || + domain === undefined || + !/^[a-zA-Z0-9-_]+$/.test(name) + ) { + throw new NostrError( + `invalid NIP-05 internet identifier: ${metadata.nip05}` + ) + } + const res = await fetch( + `${ + opts?.https === false ? "http" : "https" + }://${domain}/.well-known/nostr.json?name=${name}`, + { redirect: "error" } + ) + const wellKnown = await res.json() + // TODO How does this interact with delegation? Instead of using the pubkey field, + // should it use getAuthor()? + const pubkey = wellKnown.names?.[name] + if (pubkey !== this.pubkey) { + throw new NostrError( + `invalid NIP-05 internet identifier: ${ + metadata.nip05 + } pubkey does not match, ${JSON.stringify(wellKnown)}` + ) + } + const relays = wellKnown.relays?.[pubkey] + if ( + relays !== undefined && + (!(relays instanceof Array) || + relays.some((relay) => typeof relay !== "string")) + ) { + throw new NostrError(`invalid NIP-05 relays: ${wellKnown}`) + } + return { + name, + relays, + } + } +} diff --git a/packages/nostr/src/event/kind/text-note.ts b/packages/nostr/src/event/kind/text-note.ts new file mode 100644 index 00000000..a8bb92c1 --- /dev/null +++ b/packages/nostr/src/event/kind/text-note.ts @@ -0,0 +1,55 @@ +import { EventKind, EventProps, signEvent, verifySignature } from ".." +import { DeepReadonly, NostrError, Timestamp } from "../../common" +import { HexOrBechPrivateKey } from "../../crypto" +import { EventCommon } from "../common" +import { Delegation, delegationTag, verifyDelegation } from "../delegation" + +/** + * A text note event. Used for transmitting user posts. + * + * Related NIPs: NIP-01. + */ +export class TextNote extends EventCommon { + override readonly kind: EventKind.TextNote + + constructor(event: EventProps) { + super(event) + if (event.kind !== EventKind.TextNote) { + throw new NostrError("invalid event kind") + } + this.kind = event.kind + } + + static async create( + opts: DeepReadonly<{ + note: string + base?: { + tags?: string[][] + created_at?: Timestamp + } + priv?: HexOrBechPrivateKey + delegation?: Delegation + }> + ): Promise { + const tags = structuredClone((opts.base?.tags as string[][]) ?? []) + if (opts.delegation !== undefined) { + tags.push(delegationTag(opts.delegation)) + } + const event = await signEvent( + { + kind: EventKind.TextNote, + tags, + content: opts.note, + created_at: opts.base?.created_at, + }, + opts.priv + ) + // Verify the delegation for correctness and verify the signature as a sanity check. + await Promise.all([verifySignature(event), verifyDelegation(event)]) + return new TextNote(event) + } + + get note(): string { + return this.content + } +} diff --git a/packages/nostr/src/event/kind/unknown.ts b/packages/nostr/src/event/kind/unknown.ts new file mode 100644 index 00000000..09522ae3 --- /dev/null +++ b/packages/nostr/src/event/kind/unknown.ts @@ -0,0 +1,62 @@ +import { + EventKind, + EventProps, + InputEventProps, + signEvent, + verifySignature, +} from ".." +import { DeepReadonly } from "../../common" +import { HexOrBechPrivateKey } from "../../crypto" +import { EventCommon } from "../common" +import { Delegation, delegationTag, verifyDelegation } from "../delegation" + +export class Unknown extends EventCommon { + override readonly kind: Exclude< + EventKind, + | EventKind.SetMetadata + | EventKind.TextNote + | EventKind.ContactList + | EventKind.DirectMessage + | EventKind.Deletion + > + + constructor(event: EventProps) { + super(event) + if ( + event.kind === EventKind.SetMetadata || + event.kind === EventKind.TextNote || + event.kind === EventKind.ContactList || + event.kind === EventKind.DirectMessage || + event.kind === EventKind.Deletion + ) { + throw new Error("invalid event kind") + } + this.kind = event.kind + } + + static async create( + opts: DeepReadonly<{ + kind: EventKind + base?: InputEventProps + priv?: HexOrBechPrivateKey + delegation?: Delegation + }> + ): Promise { + const tags = structuredClone((opts.base?.tags as string[][]) ?? []) + if (opts.delegation !== undefined) { + tags.push(delegationTag(opts.delegation)) + } + const event = await signEvent( + { + kind: opts.kind, + tags, + content: opts.base?.content ?? "", + created_at: opts.base?.created_at, + }, + opts.priv + ) + // Verify the delegation for correctness and verify the signature as a sanity check. + await Promise.all([verifySignature(event), verifyDelegation(event)]) + return new Unknown(event) + } +} diff --git a/packages/nostr/src/event/set-metadata.ts b/packages/nostr/src/event/set-metadata.ts deleted file mode 100644 index 25fb257d..00000000 --- a/packages/nostr/src/event/set-metadata.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { EventKind, RawEvent, signEvent } from "." -import { NostrError, parseJson } from "../common" -import { HexOrBechPrivateKey } from "../crypto" - -/** - * 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 - /** - * Verify the NIP-05 DNS-based internet identifier associated with the user metadata. - * Throws if the internet identifier is invalid or fails verification. - * @param pubkey The public key to use if the event does not specify a pubkey. If the event - * does specify a pubkey - * @return The internet identifier. `undefined` if there is no internet identifier. - */ - verifyInternetIdentifier( - opts?: VerificationOptions - ): Promise -} - -export interface UserMetadata { - name: string - about: string - picture: string - nip05?: string -} - -/** - * Create a set metadata event. - */ -export function createSetMetadata( - content: UserMetadata, - priv?: HexOrBechPrivateKey -): Promise { - return signEvent( - { - kind: EventKind.SetMetadata, - tags: [], - content: JSON.stringify(content), - getUserMetadata, - verifyInternetIdentifier, - }, - priv - ) -} - -export function getUserMetadata(this: SetMetadata): 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 -} - -export async function verifyInternetIdentifier( - this: SetMetadata, - opts?: VerificationOptions -): Promise { - const metadata = this.getUserMetadata() - if (metadata.nip05 === undefined) { - return undefined - } - const [name, domain] = metadata.nip05.split("@") - if ( - name === undefined || - domain === undefined || - !/^[a-zA-Z0-9-_]+$/.test(name) - ) { - throw new NostrError( - `invalid NIP-05 internet identifier: ${metadata.nip05}` - ) - } - const res = await fetch( - `${ - opts?.https === false ? "http" : "https" - }://${domain}/.well-known/nostr.json?name=${name}`, - { redirect: "error" } - ) - const wellKnown = await res.json() - const pubkey = wellKnown.names?.[name] - if (pubkey !== this.pubkey) { - throw new NostrError( - `invalid NIP-05 internet identifier: ${ - metadata.nip05 - } pubkey does not match, ${JSON.stringify(wellKnown)}` - ) - } - const relays = wellKnown.relays?.[pubkey] - if ( - relays !== undefined && - (!(relays instanceof Array) || - relays.some((relay) => typeof relay !== "string")) - ) { - throw new NostrError(`invalid NIP-05 relays: ${wellKnown}`) - } - return { - name, - relays, - } -} - -export interface InternetIdentifier { - name: string - relays?: string[] -} - -export interface VerificationOptions { - https?: boolean -} diff --git a/packages/nostr/src/event/text.ts b/packages/nostr/src/event/text.ts deleted file mode 100644 index bbe2175a..00000000 --- a/packages/nostr/src/event/text.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { EventKind, RawEvent, signEvent } from "." -import { HexOrBechPrivateKey } from "../crypto" - -/** - * 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, - priv?: HexOrBechPrivateKey -): Promise { - return signEvent( - { - kind: EventKind.TextNote, - tags: [], - content, - }, - priv - ) -} diff --git a/packages/nostr/src/nostr-object.ts b/packages/nostr/src/nostr-object.ts index da74197d..06f9a13c 100644 --- a/packages/nostr/src/nostr-object.ts +++ b/packages/nostr/src/nostr-object.ts @@ -1,11 +1,14 @@ +import { DeepReadonly } from "./common" import { PublicKey } from "./crypto" -import { RawEvent, Unsigned } from "./event" +import { EventProps, UnsignedEventProps } from "./event" declare global { interface Window { nostr?: { getPublicKey: () => Promise - signEvent: (event: Unsigned) => Promise + signEvent: ( + event: DeepReadonly + ) => Promise getRelays?: () => Promise< Record diff --git a/packages/nostr/test/setup.ts b/packages/nostr/test/setup.ts index 25638acf..4ec19139 100644 --- a/packages/nostr/test/setup.ts +++ b/packages/nostr/test/setup.ts @@ -8,8 +8,7 @@ import { parsePublicKey, PublicKey, } from "../src/crypto" -import { RawEvent } from "../src" -import { signEvent, Unsigned } from "../src/event" +import { signEvent } from "../src/event" export const relayUrl = new URL("ws://localhost:12648") @@ -46,8 +45,7 @@ export async function setup( // Mock the global window.nostr object for the publisher. window.nostr = { getPublicKey: () => Promise.resolve(parsePublicKey(publisherPubkey)), - signEvent: (event: Unsigned) => - signEvent(event, publisherSecret), + signEvent: (event) => signEvent(event, publisherSecret), getRelays: () => Promise.resolve({}), diff --git a/packages/nostr/test/test.contact-list.ts b/packages/nostr/test/test.contact-list.ts index 8227af2f..3144cf1b 100644 --- a/packages/nostr/test/test.contact-list.ts +++ b/packages/nostr/test/test.contact-list.ts @@ -1,6 +1,6 @@ import { assert } from "chai" import { EventKind } from "../src/event" -import { createContactList } from "../src/event/contact-list" +import { ContactList } from "../src/event/kind/contact-list" import { setup } from "./setup" describe("contact-list", () => { @@ -37,7 +37,7 @@ describe("contact-list", () => { assert.strictEqual(event.kind, EventKind.ContactList) assert.strictEqual(event.content, "") if (event.kind === EventKind.ContactList) { - assert.deepStrictEqual(event.getContacts(), contacts) + assert.deepStrictEqual(event.contacts, contacts) } done() }) @@ -47,7 +47,12 @@ describe("contact-list", () => { // 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 createContactList(contacts, subscriberSecret)) + await publisher.publish( + await ContactList.create({ + contacts, + priv: subscriberSecret, + }) + ) }) }) }) diff --git a/packages/nostr/test/test.delegation.ts b/packages/nostr/test/test.delegation.ts new file mode 100644 index 00000000..9495d5c1 --- /dev/null +++ b/packages/nostr/test/test.delegation.ts @@ -0,0 +1,197 @@ +import { assert } from "chai" +import { EventKind } from "../src/event" +import { parsePublicKey } from "../src/crypto" +import { setup } from "./setup" +import { TextNote } from "../src/event/kind/text-note" +import { createDelegation, DelegationConditions } from "../src/event/delegation" +import { DeepReadonly, NostrError, unixTimestamp } from "../src/common" + +const note = "hello world" + +const timestamp = unixTimestamp() + +const delegatorPubkey = + "npub1h5hhn5420y6l3yeufv7m83c57x0n58mkx8aqfyetw2t323zc8cwssuj7ne" +const delegatorSecret = + "nsec1cym0pjpf3ev2h2gp3r0fkgr2jn5qudm89yajsk2j2n6scga36wnsxkwt47" + +describe("delegation", () => { + // Test that a text note can be published in the name of the delegator. + it("valid delegation", (done) => valid(done, { kinds: [EventKind.TextNote] })) + + // Test that a text note can be published in the name of the delegator in the case + // where no delegation conditions specified. + it("valid delegation: empty conditions", valid) + + // Test that an invalid delegation token is rejected. + it("invalid delegation: bad token", (done) => + invalid( + done, + { + // Valid conditions... + conditions: "kind=1", + // ...but a bogus token. + token: + "e6bdbcf5c35f2d6b624e5aca7f23d521741c4bc541bda1f6e91f498d17743eebe9d7958fb563faf15780c3e2ef6682ae4a5369b0ef2de689f093505ee4e73c9d", + }, + "signature" + )) + + // Test that an event is rejected if the delegation kind conditions forbid it. + it("invalid delegation: bad kind", (done) => + invalid(done, { kinds: [EventKind.SetMetadata] }, "event kind")) + + // Test that an event is rejected if it's too far in the past because created_at conditions forbid it. + it("invalid delegation: created_at> now", (done) => + invalid( + done, + { kinds: [EventKind.TextNote], after: timestamp }, + "event.created_at" + )) + + // Test that an event is rejected if it's too far in the past because created_at conditions forbid it. + it("invalid delegation: created_at> now + 10", (done) => + invalid( + done, + { kinds: [EventKind.TextNote], after: timestamp + 10 }, + "event.created_at" + )) + + // Test that an event is accepted if it happens in the future. + it("valid delegation: created_at> now - 10", (done) => + valid(done, { kinds: [EventKind.TextNote], after: timestamp - 10 })) + + // Test that an event is accepted if it happens in the future, without any kind conditions. + it("valid delegation: created_at> now - 10 with no kind conditions", (done) => + valid(done, { after: timestamp - 10 })) + + // Test that an event is rejected if it's too far in the future because created_at conditions forbid it. + it("invalid delegation: created_at< now", (done) => + invalid( + done, + { kinds: [EventKind.TextNote], before: timestamp }, + "event.created_at" + )) + + // Test that an event is rejected if it's too far in the future because created_at conditions forbid it. + it("invalid delegation: created_at< now - 10", (done) => + invalid( + done, + { kinds: [EventKind.TextNote], before: timestamp - 10 }, + "event.created_at" + )) + + // Test that an event is accepted if it happens before the delegation deadline. + it("valid delegation: created_at< now + 10", (done) => + valid(done, { kinds: [EventKind.TextNote], before: timestamp + 10 })) + + // Test that an event is accepted if it happens within the time frame in the delegation conditions. + it("valid delegation: created_at> now - 10 and created_at< now + 10", (done) => + valid(done, { after: timestamp - 10, before: timestamp + 10 })) +}) + +/** + * Test that a text note can be published in the name of the delegator with the + * specified conditions. + */ +function valid( + done: jest.DoneCallback, + conditions?: DeepReadonly> +) { + setup( + done, + ({ publisher, publisherSecret, publisherPubkey, subscriber, done }) => { + // Expect the test event. + subscriber.on( + "event", + ({ event, subscriptionId: actualSubscriptionId }, nostr) => { + // The author is delegated. + assert.strictEqual(event.author, parsePublicKey(delegatorPubkey)) + assert.notStrictEqual(event.pubkey, event.author) + + 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) + + await publisher.publish( + await TextNote.create({ + note, + priv: publisherSecret, + base: { created_at: timestamp }, + delegation: await createDelegation( + publisherPubkey, + delegatorSecret, + conditions + ), + }) + ) + }) + } + ) +} + +function invalid( + done: jest.DoneCallback, + opts: + | DeepReadonly> + | DeepReadonly<{ conditions: string; token: string }>, + errorMsg: string +) { + setup(done, ({ publisherSecret, publisherPubkey, subscriber, done }) => { + // Nothing should be published. + subscriber.on("ok", () => { + assert.fail("nothing should be published") + }) + + subscriber.subscribe([]) + + // After the subscription event sync is done, publish the test event. + subscriber.on("eose", async () => { + const delegation = + "conditions" in opts + ? { ...opts, delegator: publisherPubkey } + : await createDelegation(publisherPubkey, delegatorSecret, opts) + // Expect the event creation to fail, since the delegation is invalid. + const err = await error( + TextNote.create({ + note, + priv: publisherSecret, + base: { created_at: timestamp }, + delegation, + }) + ) + + assert.instanceOf(err, NostrError) + assert.include(err?.message, "invalid delegation") + assert.include(err?.message, errorMsg) + + done() + }) + }) +} + +async function error(promise: Promise): Promise { + try { + await promise + } catch (error) { + if (!(error instanceof Error)) { + throw error + } + return error + } + return undefined +} diff --git a/packages/nostr/test/test.deletion.ts b/packages/nostr/test/test.deletion.ts index 490accb7..6beaffe5 100644 --- a/packages/nostr/test/test.deletion.ts +++ b/packages/nostr/test/test.deletion.ts @@ -2,8 +2,8 @@ import { assert } from "chai" import { EventKind } from "../src/event" import { parsePublicKey } from "../src/crypto" import { setup } from "./setup" -import { createTextNote } from "../src/event/text" -import { createDeletion } from "../src/event/deletion" +import { TextNote } from "../src/event/kind/text-note" +import { Deletion } from "../src/event/kind/deletion" describe("deletion", () => { // Test that a deletion event deletes existing events. Test that the deletion event @@ -32,30 +32,35 @@ describe("deletion", () => { assert.strictEqual(event.created_at, timestamp) assert.strictEqual(event.content, "") if (event.kind === EventKind.Deletion) { - assert.deepStrictEqual(event.getEvents(), [textNoteId]) + assert.deepStrictEqual(event.deletedEvents, [textNoteId]) } done() }) - createTextNote("hello world", publisherSecret).then((textNote) => { - textNoteId = textNote.id - publisher.publish({ - ...textNote, + TextNote.create({ + note: "hello world", + priv: publisherSecret, + base: { created_at: timestamp, - }) + }, }) + .then((textNote) => { + textNoteId = textNote.id + return publisher.publish(textNote) + }) + .catch(done) publisher.on("ok", async ({ eventId, ok }) => { assert.strictEqual(ok, true) if (eventId === textNoteId) { // After the text note has been published, delete it. - const deletion = await createDeletion( - { events: [textNoteId] }, - publisherSecret - ) + const deletion = await Deletion.create({ + events: [textNoteId], + priv: publisherSecret, + }) deletionId = deletion.id - publisher.publish({ + await publisher.publish({ ...deletion, created_at: timestamp, }) diff --git a/packages/nostr/test/test.direct-message.ts b/packages/nostr/test/test.direct-message.ts index de1c85ad..6ac98329 100644 --- a/packages/nostr/test/test.direct-message.ts +++ b/packages/nostr/test/test.direct-message.ts @@ -2,7 +2,7 @@ import { assert } from "chai" import { EventKind } from "../src/event" import { parsePublicKey } from "../src/crypto" import { setup } from "./setup" -import { createDirectMessage } from "../src/event/direct-message" +import { DirectMessage } from "../src/event/kind/direct-message" describe("direct-message", () => { const message = "for your eyes only" @@ -33,7 +33,7 @@ describe("direct-message", () => { if (event.kind === EventKind.DirectMessage) { assert.strictEqual( - event.getRecipient(), + event.recipient, parsePublicKey(subscriberPubkey) ) assert.strictEqual( @@ -49,14 +49,12 @@ describe("direct-message", () => { const subscriptionId = subscriber.subscribe([]) subscriber.on("eose", async () => { - const event = await createDirectMessage( - { - message, - recipient: subscriberPubkey, - }, - publisherSecret - ) - publisher.publish(event) + const event = await DirectMessage.create({ + message, + recipient: subscriberPubkey, + priv: publisherSecret, + }) + await publisher.publish(event) }) } ) @@ -91,7 +89,7 @@ describe("direct-message", () => { if (event.kind === EventKind.DirectMessage) { assert.strictEqual( - event.getRecipient(), + event.recipient, parsePublicKey(recipientPubkey) ) assert.strictEqual( @@ -111,14 +109,12 @@ describe("direct-message", () => { subscriber.on("eose", async () => { // TODO No signEvent, do something more convenient - const event = await createDirectMessage( - { - message, - recipient: recipientPubkey, - }, - publisherSecret - ) - publisher.publish(event) + const event = await DirectMessage.create({ + message, + recipient: recipientPubkey, + priv: publisherSecret, + }) + await publisher.publish(event) }) } ) diff --git a/packages/nostr/test/test.internet-identifier.ts b/packages/nostr/test/test.internet-identifier.ts index 39c718f9..f0f435c9 100644 --- a/packages/nostr/test/test.internet-identifier.ts +++ b/packages/nostr/test/test.internet-identifier.ts @@ -1,7 +1,7 @@ import { assert } from "chai" import { defined } from "../src/common" import { EventKind } from "../src/event" -import { createSetMetadata } from "../src/event/set-metadata" +import { SetMetadata } from "../src/event/kind/set-metadata" import { setup } from "./setup" describe("internet-identifier", () => { @@ -26,17 +26,17 @@ describe("internet-identifier", () => { // After the subscription event sync is done, publish the test event. subscriber.on("eose", async () => { - publisher.publish({ - ...(await createSetMetadata( - { + await publisher.publish( + await SetMetadata.create({ + userMetadata: { about: "", name: "", picture: "", nip05: "bob@localhost:12647", }, - publisherSecret - )), - }) + priv: publisherSecret, + }) + ) }) }) }) @@ -59,16 +59,16 @@ describe("internet-identifier", () => { // After the subscription event sync is done, publish the test event. subscriber.on("eose", async () => { - publisher.publish({ - ...(await createSetMetadata( - { + await publisher.publish( + await SetMetadata.create({ + userMetadata: { about: "", name: "", picture: "", }, - publisherSecret - )), - }) + priv: publisherSecret, + }) + ) }) }) }) diff --git a/packages/nostr/test/test.set-metadata.ts b/packages/nostr/test/test.set-metadata.ts index a4ba8e4f..590a9620 100644 --- a/packages/nostr/test/test.set-metadata.ts +++ b/packages/nostr/test/test.set-metadata.ts @@ -2,7 +2,7 @@ import { assert } from "chai" import { EventKind } from "../src/event" import { parsePublicKey } from "../src/crypto" import { setup } from "./setup" -import { createSetMetadata } from "../src/event/set-metadata" +import { SetMetadata } from "../src/event/kind/set-metadata" describe("set metadata", () => { const name = "bob" @@ -25,7 +25,7 @@ describe("set metadata", () => { subscriber.on("event", ({ event }) => { assert.strictEqual(event.kind, EventKind.SetMetadata) if (event.kind === EventKind.SetMetadata) { - const user = event.getUserMetadata() + const user = event.userMetadata assert.strictEqual(event.pubkey, parsePublicKey(publisherPubkey)) assert.strictEqual(event.created_at, timestamp) assert.strictEqual(event.tags.length, 0) @@ -40,13 +40,15 @@ describe("set metadata", () => { // After the subscription event sync is done, publish the test event. subscriber.on("eose", async () => { - publisher.publish({ - ...(await createSetMetadata( - { name, about, picture }, - publisherSecret - )), - created_at: timestamp, - }) + await publisher.publish( + await SetMetadata.create({ + userMetadata: { name, about, picture }, + priv: publisherSecret, + base: { + created_at: timestamp, + }, + }) + ) }) } ) diff --git a/packages/nostr/test/test.text-note.ts b/packages/nostr/test/test.text-note.ts index c1a72e56..36a8b93f 100644 --- a/packages/nostr/test/test.text-note.ts +++ b/packages/nostr/test/test.text-note.ts @@ -2,7 +2,7 @@ import { assert } from "chai" import { EventKind } from "../src/event" import { parsePublicKey } from "../src/crypto" import { setup } from "./setup" -import { createTextNote } from "../src/event/text" +import { TextNote } from "../src/event/kind/text-note" describe("text note", () => { const note = "hello world" @@ -40,10 +40,15 @@ describe("text note", () => { assert.strictEqual(nostr, subscriber) assert.strictEqual(id, subscriptionId) - publisher.publish({ - ...(await createTextNote(note, publisherSecret)), - created_at: timestamp, - }) + publisher.publish( + await TextNote.create({ + note, + priv: publisherSecret, + base: { + created_at: timestamp, + }, + }) + ) }) } ) @@ -52,7 +57,7 @@ describe("text note", () => { // Test that a client interprets an "OK" message after publishing a text note. it("publish and ok", function (done) { setup(done, async ({ publisher, publisherSecret, url, done }) => { - const event = await createTextNote(note, publisherSecret) + const event = await TextNote.create({ note, priv: publisherSecret }) publisher.on("ok", (params, nostr) => { assert.strictEqual(nostr, publisher) assert.strictEqual(params.eventId, event.id)