nostr-package-delegation #549

Closed
Kieran wants to merge 2 commits from nostr-package-delegation into main
31 changed files with 1339 additions and 661 deletions

View File

@ -1,6 +1,6 @@
import "./FollowButton.css"; import "./FollowButton.css";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { HexKey } from "@snort/nostr"; import { HexKey, RawEvent } from "@snort/nostr";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Feed/EventPublisher";
import { parseId } from "Util"; import { parseId } from "Util";

View File

@ -2,7 +2,7 @@ import { ReactNode } from "react";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Feed/EventPublisher";
import { HexKey } from "@snort/nostr"; import { HexKey, RawEvent } from "@snort/nostr";
import ProfilePreview from "Element/ProfilePreview"; import ProfilePreview from "Element/ProfilePreview";
import messages from "./messages"; import messages from "./messages";

View File

@ -72,14 +72,14 @@ export class EventPublisher {
return eb.pubKey(this.#pubKey).kind(k); return eb.pubKey(this.#pubKey).kind(k);
} }
async #sign(eb: EventBuilder) { async #sign(eb: EventBuilder): Promise<RawEvent> {
if (this.#hasNip07 && !this.#privateKey) { if (this.#hasNip07 && !this.#privateKey) {
const nip7PubKey = await barrierNip07(() => unwrap(window.nostr).getPublicKey()); const nip7PubKey = await barrierNip07(() => unwrap(window.nostr).getPublicKey());
if (nip7PubKey !== this.#pubKey) { if (nip7PubKey !== this.#pubKey) {
throw new Error("Can't sign event, NIP-07 pubkey does not match"); throw new Error("Can't sign event, NIP-07 pubkey does not match");
} }
const ev = eb.build(); const ev = eb.build();
return await barrierNip07(() => unwrap(window.nostr).signEvent(ev)); return (await barrierNip07(() => unwrap(window.nostr).signEvent(ev))) as RawEvent;
} else if (this.#privateKey) { } else if (this.#privateKey) {
return await eb.buildAndSign(this.#privateKey); return await eb.buildAndSign(this.#privateKey);
} else { } else {

View File

@ -3,7 +3,7 @@ module.exports = {
parser: "@typescript-eslint/parser", parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"], plugins: ["@typescript-eslint"],
root: true, root: true,
ignorePatterns: ["dist/", "src/legacy"], ignorePatterns: ["dist/", "src/legacy", "webpack.config.js"],
env: { env: {
browser: true, browser: true,
node: true, node: true,

View File

@ -1,6 +1,6 @@
import { NostrError, parseJson } from "../common" import { NostrError, parseJson } from "../common"
import { SubscriptionId } from "." import { SubscriptionId } from "."
import { EventId, RawEvent } from "../event" import { EventId, EventProps } from "../event"
import WebSocket from "isomorphic-ws" import WebSocket from "isomorphic-ws"
import { Filters } from "../filters" import { Filters } from "../filters"
@ -127,7 +127,7 @@ export type IncomingKind = "event" | "notice" | "ok" | "eose" | "auth"
export interface IncomingEvent { export interface IncomingEvent {
kind: "event" kind: "event"
subscriptionId: SubscriptionId subscriptionId: SubscriptionId
event: RawEvent event: EventProps
} }
/** /**
@ -178,7 +178,7 @@ export type OutgoingKind = "event" | "openSubscription" | "closeSubscription"
*/ */
export interface OutgoingEvent { export interface OutgoingEvent {
kind: "event" kind: "event"
event: RawEvent event: EventProps
} }
/** /**
@ -304,7 +304,7 @@ function parseIncomingMessage(data: string): IncomingMessage {
throw new NostrError(`unknown incoming message: ${data}`) throw new NostrError(`unknown incoming message: ${data}`)
} }
function parseEventData(json: { [key: string]: unknown }): RawEvent { function parseEventData(json: { [key: string]: unknown }): EventProps {
if ( if (
typeof json["id"] !== "string" || typeof json["id"] !== "string" ||
typeof json["pubkey"] !== "string" || typeof json["pubkey"] !== "string" ||
@ -319,5 +319,5 @@ function parseEventData(json: { [key: string]: unknown }): RawEvent {
) { ) {
throw new NostrError(`invalid event: ${JSON.stringify(json)}`) throw new NostrError(`invalid event: ${JSON.stringify(json)}`)
} }
return json as unknown as RawEvent return json as unknown as EventProps
} }

View File

@ -1,6 +1,6 @@
import Base from "events" import Base from "events"
import { Nostr, SubscriptionId } from "." import { Nostr, SubscriptionId } from "."
import { Event, EventId } from "../event" import { EventId, Event } from "../event"
/** /**
* Overrides providing better types for EventEmitter methods. * Overrides providing better types for EventEmitter methods.

View File

@ -1,10 +1,12 @@
import { NostrError } from "../common" import { NostrError } from "../common"
import { RawEvent, parseEvent } from "../event" import { EventProps, verifySignature } from "../event"
import { Conn } from "./conn" import { Conn } from "./conn"
import * as secp from "@noble/secp256k1" import * as secp from "@noble/secp256k1"
import { EventEmitter } from "./emitter" import { EventEmitter } from "./emitter"
import { fetchRelayInfo, ReadyState, Relay } from "./relay" import { fetchRelayInfo, ReadyState, Relay } from "./relay"
import { Filters } from "../filters" import { Filters } from "../filters"
import { parseEvent } from "../event"
import { verifyDelegation } from "../event/delegation"
/** /**
* A nostr client. * A nostr client.
@ -82,10 +84,14 @@ export class Nostr extends EventEmitter {
// Handle messages on this connection. // Handle messages on this connection.
onMessage: async (msg) => { onMessage: async (msg) => {
if (msg.kind === "event") { if (msg.kind === "event") {
await Promise.all([
verifySignature(msg.event),
verifyDelegation(msg.event),
])
this.emit( this.emit(
"event", "event",
{ {
event: await parseEvent(msg.event), event: parseEvent(msg.event),
subscriptionId: msg.subscriptionId, subscriptionId: msg.subscriptionId,
}, },
this this
@ -270,7 +276,8 @@ export class Nostr extends EventEmitter {
/** /**
* Publish an event. * Publish an event.
*/ */
publish(event: RawEvent): void { async publish(event: EventProps): Promise<void> {
await Promise.all([verifySignature(event), verifyDelegation(event)])
for (const { conn, write } of this.#conns.values()) { for (const { conn, write } of this.#conns.values()) {
if (!write) { if (!write) {
continue continue

View File

@ -40,3 +40,15 @@ export class NostrError extends Error {
super(message) super(message)
} }
} }
/**
* Recursive readonly type.
*/
export type DeepReadonly<T> = T extends (
this: unknown,
...args: unknown[]
) => unknown
? T
: {
readonly [P in keyof T]: DeepReadonly<T[P]>
}

View File

@ -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)
}
}

View File

@ -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<ContactList> {
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,
}
})
}

View File

@ -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<C extends string | DelegationConditions> {
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<Partial<DelegationConditions>>
): Promise<Delegation<string>> {
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<Partial<DelegationConditions>>
): 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<DelegationConditions & { str: string }> | 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>): 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<void> {
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}`
)
}
}

View File

@ -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<Deletion> {
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]
})
}

View File

@ -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<string | undefined>
/**
* 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<DirectMessage> {
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<string | undefined> {
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]
}

View File

@ -1,38 +1,25 @@
import { DeepReadonly, NostrError, Timestamp, unixTimestamp } from "../common"
import { import {
PublicKey,
sha256,
schnorrSign,
schnorrVerify,
getPublicKey, getPublicKey,
HexOrBechPrivateKey, HexOrBechPrivateKey,
parsePrivateKey, parsePrivateKey,
PublicKey,
schnorrSign,
schnorrVerify,
sha256,
} from "../crypto" } from "../crypto"
import { Timestamp, unixTimestamp, NostrError } from "../common" import { ContactList } from "./kind/contact-list"
import { TextNote } from "./text" import { Deletion } from "./kind/deletion"
import { import { DirectMessage } from "./kind/direct-message"
getUserMetadata, import { SetMetadata } from "./kind/set-metadata"
SetMetadata, import { TextNote } from "./kind/text-note"
verifyInternetIdentifier, import { Unknown } from "./kind/unknown"
} from "./set-metadata"
import {
DirectMessage,
getMessage,
getPrevious,
getRecipient,
} from "./direct-message"
import { ContactList, getContacts } from "./contact-list"
import { Deletion, getEvents } from "./deletion"
import "../nostr-object" import "../nostr-object"
// TODO Add remaining event types /**
* The enumeration of all known event kinds. Used to discriminate between different
// TODO * event types.
// 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 { export enum EventKind {
SetMetadata = 0, // NIP-01 SetMetadata = 0, // NIP-01
TextNote = 1, // 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 { export interface InputEventProps {
id: string tags?: string[][]
pubkey: PublicKey content?: string
created_at: Timestamp 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 kind: EventKind
tags: string[][] tags: string[][]
content: string content: string
sig: string created_at?: Timestamp
[key: string]: unknown
} }
export interface Unknown extends RawEvent { /**
kind: Exclude< * The fields of a signed event.
EventKind, *
| EventKind.SetMetadata * This type is strictly readonly because it's signed. Changing any of the fields would
| EventKind.TextNote * invalidate the signature.
| EventKind.ContactList *
| EventKind.DirectMessage * TODO Document the fields
| EventKind.Deletion */
> export interface EventProps extends DeepReadonly<UnsignedEventProps> {
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 = export type Event =
| SetMetadata | SetMetadata
| TextNote | TextNote
@ -91,143 +99,92 @@ export type Event =
export type EventId = string 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<T extends Event | RawEvent> = { export async function signEvent(
[Property in keyof UnsignedWithPubkey<T> as Exclude< event: DeepReadonly<UnsignedEventProps>,
Property,
"pubkey"
>]: T[Property]
} & {
pubkey?: PublicKey
}
/**
* Same as @see {@link Unsigned}, but with the pubkey field.
*/
type UnsignedWithPubkey<T extends Event | RawEvent> = {
[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<T extends RawEvent>(
event: Unsigned<T>,
priv?: HexOrBechPrivateKey priv?: HexOrBechPrivateKey
): Promise<T> { ): Promise<EventProps> {
event.created_at ??= unixTimestamp() const createdAt = event.created_at ?? unixTimestamp()
if (priv !== undefined) { if (priv !== undefined) {
priv = parsePrivateKey(priv) priv = parsePrivateKey(priv)
event.pubkey = getPublicKey(priv) const pubkey = getPublicKey(priv)
const id = await serializeEventId( const id = await serializeEventId(event, pubkey, createdAt)
// This conversion is safe because the pubkey field is set above. const sig = await schnorrSign(id, priv)
event as unknown as UnsignedWithPubkey<T> return {
) ...structuredClone(event),
event.id = id id,
event.sig = await schnorrSign(id, priv) sig,
return event as T pubkey,
created_at: createdAt,
}
} else { } else {
if (typeof window === "undefined" || window.nostr === undefined) { if (typeof window === "undefined" || window.nostr === undefined) {
throw new NostrError("no private key provided") throw new NostrError(
} "no private key provided and window.nostr is not available"
// 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]
}
} }
const signed = await window.nostr.signEvent(event) const signed = await window.nostr.signEvent(event)
return { return structuredClone(signed)
...signed,
...methods,
}
} }
} }
// 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<Event> { export function parseEvent(event: EventProps): Event {
if (event.id !== (await serializeEventId(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<void> {
if (
event.id !== (await serializeEventId(event, event.pubkey, event.created_at))
) {
throw new NostrError( throw new NostrError(
`invalid id ${event.id} for event ${JSON.stringify( `invalid id ${event.id} for event ${JSON.stringify(
event event
)}, expected ${await serializeEventId(event)}` )}, expected ${await serializeEventId(
event,
event.pubkey,
event.created_at
)}`
) )
} }
if (!(await schnorrVerify(event.sig, event.id, event.pubkey))) { if (!(await schnorrVerify(event.sig, event.id, event.pubkey))) {
throw new NostrError(`invalid signature for event ${JSON.stringify(event)}`) 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( async function serializeEventId(
event: UnsignedWithPubkey<RawEvent> event: DeepReadonly<UnsignedEventProps>,
pubkey: PublicKey,
createdAt: Timestamp
): Promise<EventId> { ): Promise<EventId> {
const serialized = JSON.stringify([ const serialized = JSON.stringify([
0, 0,
event.pubkey, pubkey,
event.created_at, createdAt,
event.kind, event.kind,
event.tags, event.tags,
event.content, event.content,

View File

@ -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<string>
}>
): Promise<ContactList> {
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,
}
})
}
}

View File

@ -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<string>
}>
): Promise<Deletion> {
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]
})
}
}

View File

@ -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<string>
}>
): Promise<DirectMessage> {
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<string | undefined> {
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]
}
}

View File

@ -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<string>
}>
): Promise<SetMetadata> {
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<InternetIdentifier | undefined> {
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,
}
}
}

View File

@ -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<string>
}>
): Promise<TextNote> {
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
}
}

View File

@ -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<string>
}>
): Promise<Unknown> {
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)
}
}

View File

@ -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<InternetIdentifier | undefined>
}
export interface UserMetadata {
name: string
about: string
picture: string
nip05?: string
}
/**
* Create a set metadata event.
*/
export function createSetMetadata(
content: UserMetadata,
priv?: HexOrBechPrivateKey
): Promise<SetMetadata> {
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<InternetIdentifier | undefined> {
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
}

View File

@ -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<TextNote> {
return signEvent(
{
kind: EventKind.TextNote,
tags: [],
content,
},
priv
)
}

View File

@ -1,11 +1,14 @@
import { DeepReadonly } from "./common"
import { PublicKey } from "./crypto" import { PublicKey } from "./crypto"
import { RawEvent, Unsigned } from "./event" import { EventProps, UnsignedEventProps } from "./event"
declare global { declare global {
interface Window { interface Window {
nostr?: { nostr?: {
getPublicKey: () => Promise<PublicKey> getPublicKey: () => Promise<PublicKey>
signEvent: <T extends RawEvent>(event: Unsigned<T>) => Promise<T> signEvent: (
event: DeepReadonly<UnsignedEventProps>
) => Promise<EventProps>
getRelays?: () => Promise< getRelays?: () => Promise<
Record<string, { read: boolean; write: boolean }> Record<string, { read: boolean; write: boolean }>

View File

@ -8,8 +8,7 @@ import {
parsePublicKey, parsePublicKey,
PublicKey, PublicKey,
} from "../src/crypto" } from "../src/crypto"
import { RawEvent } from "../src" import { signEvent } from "../src/event"
import { signEvent, Unsigned } from "../src/event"
export const relayUrl = new URL("ws://localhost:12648") 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. // Mock the global window.nostr object for the publisher.
window.nostr = { window.nostr = {
getPublicKey: () => Promise.resolve(parsePublicKey(publisherPubkey)), getPublicKey: () => Promise.resolve(parsePublicKey(publisherPubkey)),
signEvent: <T extends RawEvent>(event: Unsigned<T>) => signEvent: (event) => signEvent(event, publisherSecret),
signEvent(event, publisherSecret),
getRelays: () => Promise.resolve({}), getRelays: () => Promise.resolve({}),

View File

@ -1,6 +1,6 @@
import { assert } from "chai" import { assert } from "chai"
import { EventKind } from "../src/event" import { EventKind } from "../src/event"
import { createContactList } from "../src/event/contact-list" import { ContactList } from "../src/event/kind/contact-list"
import { setup } from "./setup" import { setup } from "./setup"
describe("contact-list", () => { describe("contact-list", () => {
@ -37,7 +37,7 @@ describe("contact-list", () => {
assert.strictEqual(event.kind, EventKind.ContactList) assert.strictEqual(event.kind, EventKind.ContactList)
assert.strictEqual(event.content, "") assert.strictEqual(event.content, "")
if (event.kind === EventKind.ContactList) { if (event.kind === EventKind.ContactList) {
assert.deepStrictEqual(event.getContacts(), contacts) assert.deepStrictEqual(event.contacts, contacts)
} }
done() done()
}) })
@ -47,7 +47,12 @@ describe("contact-list", () => {
// After the subscription event sync is done, publish the test event. // After the subscription event sync is done, publish the test event.
subscriber.on("eose", async () => { subscriber.on("eose", async () => {
// TODO No signEvent, have a convenient way to do this // 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,
})
)
}) })
}) })
}) })

View File

@ -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<Partial<DelegationConditions>>
) {
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<Partial<DelegationConditions>>
| 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<unknown>): Promise<Error | undefined> {
try {
await promise
} catch (error) {
if (!(error instanceof Error)) {
throw error
}
return error
}
return undefined
}

View File

@ -2,8 +2,8 @@ import { assert } from "chai"
import { EventKind } from "../src/event" import { EventKind } from "../src/event"
import { parsePublicKey } from "../src/crypto" import { parsePublicKey } from "../src/crypto"
import { setup } from "./setup" import { setup } from "./setup"
import { createTextNote } from "../src/event/text" import { TextNote } from "../src/event/kind/text-note"
import { createDeletion } from "../src/event/deletion" import { Deletion } from "../src/event/kind/deletion"
describe("deletion", () => { describe("deletion", () => {
// Test that a deletion event deletes existing events. Test that the deletion event // 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.created_at, timestamp)
assert.strictEqual(event.content, "") assert.strictEqual(event.content, "")
if (event.kind === EventKind.Deletion) { if (event.kind === EventKind.Deletion) {
assert.deepStrictEqual(event.getEvents(), [textNoteId]) assert.deepStrictEqual(event.deletedEvents, [textNoteId])
} }
done() done()
}) })
createTextNote("hello world", publisherSecret).then((textNote) => { TextNote.create({
textNoteId = textNote.id note: "hello world",
publisher.publish({ priv: publisherSecret,
...textNote, base: {
created_at: timestamp, created_at: timestamp,
}) },
}) })
.then((textNote) => {
textNoteId = textNote.id
return publisher.publish(textNote)
})
.catch(done)
publisher.on("ok", async ({ eventId, ok }) => { publisher.on("ok", async ({ eventId, ok }) => {
assert.strictEqual(ok, true) assert.strictEqual(ok, true)
if (eventId === textNoteId) { if (eventId === textNoteId) {
// After the text note has been published, delete it. // After the text note has been published, delete it.
const deletion = await createDeletion( const deletion = await Deletion.create({
{ events: [textNoteId] }, events: [textNoteId],
publisherSecret priv: publisherSecret,
) })
deletionId = deletion.id deletionId = deletion.id
publisher.publish({ await publisher.publish({
...deletion, ...deletion,
created_at: timestamp, created_at: timestamp,
}) })

View File

@ -2,7 +2,7 @@ import { assert } from "chai"
import { EventKind } from "../src/event" import { EventKind } from "../src/event"
import { parsePublicKey } from "../src/crypto" import { parsePublicKey } from "../src/crypto"
import { setup } from "./setup" import { setup } from "./setup"
import { createDirectMessage } from "../src/event/direct-message" import { DirectMessage } from "../src/event/kind/direct-message"
describe("direct-message", () => { describe("direct-message", () => {
const message = "for your eyes only" const message = "for your eyes only"
@ -33,7 +33,7 @@ describe("direct-message", () => {
if (event.kind === EventKind.DirectMessage) { if (event.kind === EventKind.DirectMessage) {
assert.strictEqual( assert.strictEqual(
event.getRecipient(), event.recipient,
parsePublicKey(subscriberPubkey) parsePublicKey(subscriberPubkey)
) )
assert.strictEqual( assert.strictEqual(
@ -49,14 +49,12 @@ describe("direct-message", () => {
const subscriptionId = subscriber.subscribe([]) const subscriptionId = subscriber.subscribe([])
subscriber.on("eose", async () => { subscriber.on("eose", async () => {
const event = await createDirectMessage( const event = await DirectMessage.create({
{ message,
message, recipient: subscriberPubkey,
recipient: subscriberPubkey, priv: publisherSecret,
}, })
publisherSecret await publisher.publish(event)
)
publisher.publish(event)
}) })
} }
) )
@ -91,7 +89,7 @@ describe("direct-message", () => {
if (event.kind === EventKind.DirectMessage) { if (event.kind === EventKind.DirectMessage) {
assert.strictEqual( assert.strictEqual(
event.getRecipient(), event.recipient,
parsePublicKey(recipientPubkey) parsePublicKey(recipientPubkey)
) )
assert.strictEqual( assert.strictEqual(
@ -111,14 +109,12 @@ describe("direct-message", () => {
subscriber.on("eose", async () => { subscriber.on("eose", async () => {
// TODO No signEvent, do something more convenient // TODO No signEvent, do something more convenient
const event = await createDirectMessage( const event = await DirectMessage.create({
{ message,
message, recipient: recipientPubkey,
recipient: recipientPubkey, priv: publisherSecret,
}, })
publisherSecret await publisher.publish(event)
)
publisher.publish(event)
}) })
} }
) )

View File

@ -1,7 +1,7 @@
import { assert } from "chai" import { assert } from "chai"
import { defined } from "../src/common" import { defined } from "../src/common"
import { EventKind } from "../src/event" import { EventKind } from "../src/event"
import { createSetMetadata } from "../src/event/set-metadata" import { SetMetadata } from "../src/event/kind/set-metadata"
import { setup } from "./setup" import { setup } from "./setup"
describe("internet-identifier", () => { describe("internet-identifier", () => {
@ -26,17 +26,17 @@ describe("internet-identifier", () => {
// After the subscription event sync is done, publish the test event. // After the subscription event sync is done, publish the test event.
subscriber.on("eose", async () => { subscriber.on("eose", async () => {
publisher.publish({ await publisher.publish(
...(await createSetMetadata( await SetMetadata.create({
{ userMetadata: {
about: "", about: "",
name: "", name: "",
picture: "", picture: "",
nip05: "bob@localhost:12647", 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. // After the subscription event sync is done, publish the test event.
subscriber.on("eose", async () => { subscriber.on("eose", async () => {
publisher.publish({ await publisher.publish(
...(await createSetMetadata( await SetMetadata.create({
{ userMetadata: {
about: "", about: "",
name: "", name: "",
picture: "", picture: "",
}, },
publisherSecret priv: publisherSecret,
)), })
}) )
}) })
}) })
}) })

View File

@ -2,7 +2,7 @@ import { assert } from "chai"
import { EventKind } from "../src/event" import { EventKind } from "../src/event"
import { parsePublicKey } from "../src/crypto" import { parsePublicKey } from "../src/crypto"
import { setup } from "./setup" import { setup } from "./setup"
import { createSetMetadata } from "../src/event/set-metadata" import { SetMetadata } from "../src/event/kind/set-metadata"
describe("set metadata", () => { describe("set metadata", () => {
const name = "bob" const name = "bob"
@ -25,7 +25,7 @@ describe("set metadata", () => {
subscriber.on("event", ({ event }) => { subscriber.on("event", ({ event }) => {
assert.strictEqual(event.kind, EventKind.SetMetadata) assert.strictEqual(event.kind, EventKind.SetMetadata)
if (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.pubkey, parsePublicKey(publisherPubkey))
assert.strictEqual(event.created_at, timestamp) assert.strictEqual(event.created_at, timestamp)
assert.strictEqual(event.tags.length, 0) assert.strictEqual(event.tags.length, 0)
@ -40,13 +40,15 @@ describe("set metadata", () => {
// After the subscription event sync is done, publish the test event. // After the subscription event sync is done, publish the test event.
subscriber.on("eose", async () => { subscriber.on("eose", async () => {
publisher.publish({ await publisher.publish(
...(await createSetMetadata( await SetMetadata.create({
{ name, about, picture }, userMetadata: { name, about, picture },
publisherSecret priv: publisherSecret,
)), base: {
created_at: timestamp, created_at: timestamp,
}) },
})
)
}) })
} }
) )

View File

@ -2,7 +2,7 @@ import { assert } from "chai"
import { EventKind } from "../src/event" import { EventKind } from "../src/event"
import { parsePublicKey } from "../src/crypto" import { parsePublicKey } from "../src/crypto"
import { setup } from "./setup" import { setup } from "./setup"
import { createTextNote } from "../src/event/text" import { TextNote } from "../src/event/kind/text-note"
describe("text note", () => { describe("text note", () => {
const note = "hello world" const note = "hello world"
@ -40,10 +40,15 @@ describe("text note", () => {
assert.strictEqual(nostr, subscriber) assert.strictEqual(nostr, subscriber)
assert.strictEqual(id, subscriptionId) assert.strictEqual(id, subscriptionId)
publisher.publish({ publisher.publish(
...(await createTextNote(note, publisherSecret)), await TextNote.create({
created_at: timestamp, 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. // Test that a client interprets an "OK" message after publishing a text note.
it("publish and ok", function (done) { it("publish and ok", function (done) {
setup(done, async ({ publisher, publisherSecret, url, 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) => { publisher.on("ok", (params, nostr) => {
assert.strictEqual(nostr, publisher) assert.strictEqual(nostr, publisher)
assert.strictEqual(params.eventId, event.id) assert.strictEqual(params.eventId, event.id)