implement nip-02

This commit is contained in:
ennmichael 2023-03-19 00:25:12 +01:00
parent dc5514bb74
commit 59c4b60a6a
No known key found for this signature in database
GPG Key ID: 6E6E183431A26AF7
11 changed files with 404 additions and 192 deletions

View File

@ -9,10 +9,10 @@ A strongly-typed nostr client for Node and the browser.
The goal of the project is to have all of the following implemented The goal of the project is to have all of the following implemented
and tested against a real-world relay implementation. and tested against a real-world relay implementation.
_Progress: 6/34 (18%)._ _Progress: 7/34 (20%)._
- [X] NIP-01: Basic protocol flow description - [X] NIP-01: Basic protocol flow description
- [ ] NIP-02: Contact List and Petnames - [X] NIP-02: Contact List and Petnames
- [ ] NIP-03: OpenTimestamps Attestations for Events - [ ] NIP-03: OpenTimestamps Attestations for Events
- [X] NIP-04: Encrypted Direct Message - [X] NIP-04: Encrypted Direct Message
- [ ] NIP-05: Mapping Nostr keys to DNS-based internet identifiers - [ ] NIP-05: Mapping Nostr keys to DNS-based internet identifiers

View File

@ -1,4 +1,4 @@
import { NostrError } from "../common" import { NostrError, parseJson } from "../common"
import { SubscriptionId } from "." import { SubscriptionId } from "."
import { EventId, RawEvent } from "../event" import { EventId, RawEvent } from "../event"
import WebSocket from "isomorphic-ws" import WebSocket from "isomorphic-ws"
@ -8,7 +8,7 @@ import { Filters } from "../filters"
* The connection to a relay. This is the lowest layer of the nostr protocol. * The connection to a relay. This is the lowest layer of the nostr protocol.
* The only responsibility of this type is to send and receive * The only responsibility of this type is to send and receive
* well-formatted nostr messages on the underlying websocket. All other details of the protocol * well-formatted nostr messages on the underlying websocket. All other details of the protocol
* are handled by `Nostr`. * are handled by `Nostr`. This type does not know anything about event semantics.
* *
* @see Nostr * @see Nostr
*/ */
@ -321,11 +321,3 @@ function parseEventData(json: { [key: string]: unknown }): RawEvent {
} }
return json as unknown as RawEvent return json as unknown as RawEvent
} }
function parseJson(data: string) {
try {
return JSON.parse(data)
} catch (e) {
throw new NostrError(`invalid event json: ${data}`)
}
}

View File

@ -21,6 +21,17 @@ export function defined<T>(v: T | undefined | null): T {
return v return v
} }
/**
* Parse the JSON and throw a @see {@link NostrError} in case of error.
*/
export function parseJson(data: string) {
try {
return JSON.parse(data)
} catch (e) {
throw new NostrError(`invalid json: ${e}: ${data}`)
}
}
/** /**
* The error thrown by this library. * The error thrown by this library.
*/ */

View File

@ -0,0 +1,83 @@
import { EventKind, RawEvent, Unsigned } from "."
import { NostrError } from "../common"
import { PublicKey } from "../crypto"
/**
* Contact list event.
*
* Related NIPs: NIP-02.
*/
export interface ContactList extends RawEvent {
kind: EventKind.ContactList
/**
* Get the contacts in from the contact list.
*/
getContacts(): Contact[]
}
/**
* A contact from the contact list.
*/
export interface Contact {
pubkey: PublicKey
relay?: URL
petname?: string
}
/**
* Create a contact list event.
*/
export function createContactList(contacts: Contact[]): Unsigned<ContactList> {
return {
kind: EventKind.ContactList,
tags: contacts.map((contact) => [
"p",
contact.pubkey,
contact.relay?.toString() ?? "",
contact.petname ?? "",
]),
content: "",
getContacts,
}
}
export function getContacts(this: ContactList): Contact[] {
return this.tags
.filter((tags) => tags[0] === "p")
.map((tags) => {
// The first element is the pubkey.
const pubkey = tags[1]
if (pubkey === undefined) {
throw new NostrError(
`missing contact pubkey for contact list event: ${JSON.stringify(
this
)}`
)
}
// The second element is the optional relay URL.
let relay: URL | undefined
try {
if (tags[2] !== undefined && tags[2] !== "") {
relay = new URL(tags[2])
}
} catch (e) {
throw new NostrError(
`invalid relay URL for contact list event: ${JSON.stringify(this)}`
)
}
// The third element is the optional petname.
let petname: string | undefined
if (tags[3] !== undefined && tags[3] !== "") {
petname = tags[3]
}
return {
pubkey,
relay,
petname,
}
})
}

View File

@ -0,0 +1,124 @@
import {
EventId,
EventKind,
RawEvent,
signEvent,
Unsigned,
UnsignedWithPubkey,
} from "."
import { defined, NostrError } from "../common"
import {
aesDecryptBase64,
aesEncryptBase64,
getPublicKey,
HexOrBechPrivateKey,
parsePrivateKey,
parsePublicKey,
PrivateKey,
PublicKey,
} from "../crypto"
/**
* An encrypted direct message event.
*
* Related NIPs: NIP-04.
*/
export interface DirectMessage extends RawEvent {
kind: EventKind.DirectMessage
/**
* Get the message plaintext, or undefined if you are not the recipient.
*/
getMessage(priv?: HexOrBechPrivateKey): Promise<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) {
// TODO Use NIP-07
throw new NostrError("todo")
} else {
priv = parsePrivateKey(priv)
const { data, iv } = await aesEncryptBase64(priv, recipient, message)
return await signEvent(
{
kind: EventKind.DirectMessage,
tags: [["p", recipient]],
content: `${data}?iv=${iv}`,
getMessage,
getRecipient,
getPrevious,
},
priv
)
}
}
export async function getMessage(
this: UnsignedWithPubkey<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) {
// TODO Try to use NIP-07
throw new NostrError("todo")
} else if (getPublicKey(priv) === this.getRecipient()) {
return await aesDecryptBase64(this.pubkey, priv, { data, iv })
}
return undefined
}
export function getRecipient(this: Unsigned<RawEvent>): PublicKey {
const recipientTag = this.tags.find((tag) => tag[0] === "p")
if (typeof recipientTag?.[1] !== "string") {
throw new NostrError(
`expected "p" tag to be of type string, but got ${
recipientTag?.[1]
} in ${JSON.stringify(this)}`
)
}
return recipientTag[1]
}
export function getPrevious(this: Unsigned<RawEvent>): 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,20 +1,32 @@
import { import {
PublicKey, PublicKey,
PrivateKey,
sha256, sha256,
schnorrSign, schnorrSign,
schnorrVerify, schnorrVerify,
parsePublicKey,
aesDecryptBase64,
getPublicKey, getPublicKey,
HexOrBechPrivateKey, HexOrBechPrivateKey,
parsePrivateKey, parsePrivateKey,
aesEncryptBase64, } from "../crypto"
} from "./crypto" import { Timestamp, unixTimestamp, NostrError } from "../common"
import { defined, Timestamp, unixTimestamp, NostrError } from "./common" import { TextNote } from "./text"
import { getUserMetadata, SetMetadata } from "./set-metadata"
import {
DirectMessage,
getMessage,
getPrevious,
getRecipient,
} from "./direct-message"
import { ContactList, getContacts } from "./contact-list"
// TODO Add remaining event types // TODO Add remaining event types
// TODO
// Think about this more
// Perhaps the best option is for all these factory methods to have an overload which also accept a private
// key as last parameter and return the event already signed
// Or maybe opts: { sign?: boolean | HexOrBechPrivateKey } setting sign to true should use nip07, setting
// it to a string will use that string as the private key
export enum EventKind { export enum EventKind {
SetMetadata = 0, // NIP-01 SetMetadata = 0, // NIP-01
TextNote = 1, // NIP-01 TextNote = 1, // NIP-01
@ -48,50 +60,22 @@ export interface RawEvent {
[key: string]: unknown [key: string]: unknown
} }
interface SetMetadata extends RawEvent {
kind: EventKind.SetMetadata
/**
* Get the user metadata specified in this event.
*/
getUserMetadata(): UserMetadata
}
export interface TextNote extends RawEvent {
kind: EventKind.TextNote
}
interface DirectMessage extends RawEvent {
kind: EventKind.DirectMessage
/**
* Get the message plaintext, or undefined if this client is 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
}
export interface Unknown extends RawEvent { export interface Unknown extends RawEvent {
kind: Exclude< kind: Exclude<
EventKind, EventKind,
EventKind.SetMetadata | EventKind.TextNote | EventKind.DirectMessage | EventKind.SetMetadata
| EventKind.TextNote
| EventKind.DirectMessage
| EventKind.ContactList
> >
} }
export type Event = SetMetadata | TextNote | DirectMessage | Unknown export type Event =
| SetMetadata
export interface UserMetadata { | TextNote
name: string | ContactList
about: string | DirectMessage
picture: string | Unknown
}
/** /**
* Event ID encoded as hex. * Event ID encoded as hex.
@ -110,10 +94,11 @@ export type Unsigned<T extends Event | RawEvent> = {
pubkey?: PublicKey pubkey?: PublicKey
} }
// TODO This doesn't need to be exposed by the lib
/** /**
* Same as @see {@link Unsigned}, but with the pubkey field. * Same as @see {@link Unsigned}, but with the pubkey field.
*/ */
type UnsignedWithPubkey<T extends Event | RawEvent> = { export type UnsignedWithPubkey<T extends Event | RawEvent> = {
[Property in keyof T as Exclude< [Property in keyof T as Exclude<
Property, Property,
"id" | "sig" | "created_at" "id" | "sig" | "created_at"
@ -149,59 +134,10 @@ export async function signEvent<T extends Event | RawEvent>(
} }
} }
export function createTextNote(content: string): Unsigned<TextNote> {
return {
kind: EventKind.TextNote,
tags: [],
content,
}
}
export function createSetMetadata(
content: UserMetadata
): Unsigned<SetMetadata> {
return {
kind: EventKind.SetMetadata,
tags: [],
content: JSON.stringify(content),
getUserMetadata,
}
}
// TODO This is incomplete
// TODO Since you already have the private key, maybe this should return the message already signed?
// 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, whereas for this method that would be
// mandatory
// E.g. opts: { sign?: boolean | HexOrBechPrivateKey } setting sign to true should use nip07
export async function createDirectMessage({
message,
recipient,
priv,
}: {
message: string
recipient: PublicKey
priv: PrivateKey
}): Promise<Unsigned<DirectMessage>> {
recipient = parsePublicKey(recipient)
priv = parsePrivateKey(priv)
const { data, iv } = await aesEncryptBase64(priv, recipient, message)
return {
kind: EventKind.DirectMessage,
tags: [["p", recipient]],
content: `${data}?iv=${iv}`,
getMessage,
getRecipient,
getPrevious,
}
}
/** /**
* Parse an event from its raw format. * Parse an event from its raw format.
*/ */
export async function parseEvent(event: RawEvent): Promise<Event> { export async function parseEvent(event: RawEvent): Promise<Event> {
// TODO Validate all the fields. Lowercase hex fields, etc. Make sure everything is correct.
if (event.id !== (await serializeEventId(event))) { if (event.id !== (await serializeEventId(event))) {
throw new NostrError( throw new NostrError(
`invalid id ${event.id} for event ${JSON.stringify( `invalid id ${event.id} for event ${JSON.stringify(
@ -213,6 +149,9 @@ export async function parseEvent(event: RawEvent): Promise<Event> {
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) { if (event.kind === EventKind.TextNote) {
return { return {
...event, ...event,
@ -238,6 +177,14 @@ export async function parseEvent(event: RawEvent): Promise<Event> {
} }
} }
if (event.kind === EventKind.ContactList) {
return {
...event,
kind: EventKind.ContactList,
getContacts,
}
}
return { return {
...event, ...event,
kind: event.kind, kind: event.kind,
@ -258,72 +205,6 @@ async function serializeEventId(
return await sha256(Uint8Array.from(charCodes(serialized))) return await sha256(Uint8Array.from(charCodes(serialized)))
} }
function getUserMetadata(this: Unsigned<RawEvent>): 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
}
async function getMessage(
this: UnsignedWithPubkey<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) {
// TODO Try to use NIP-07
throw new NostrError("todo")
} else if (getPublicKey(priv) === this.getRecipient()) {
return await aesDecryptBase64(this.pubkey, priv, { data, iv })
}
return undefined
}
function getRecipient(this: Unsigned<RawEvent>): 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]
}
function getPrevious(this: Unsigned<RawEvent>): EventId | undefined {
const previousTag = this.tags.find((tag) => tag[0] === "e")
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 defined(previousTag?.[1])
}
function parseJson(data: string) {
try {
return JSON.parse(data)
} catch (e) {
throw new NostrError(`invalid json: ${e}: ${data}`)
}
}
function* charCodes(data: string): Iterable<number> { function* charCodes(data: string): Iterable<number> {
for (let i = 0; i < data.length; i++) { for (let i = 0; i < data.length; i++) {
yield data.charCodeAt(i) yield data.charCodeAt(i)

View File

@ -0,0 +1,50 @@
import { EventKind, RawEvent, Unsigned } from "."
import { NostrError, parseJson } from "../common"
/**
* Set metadata event. Used for disseminating use profile information.
*
* Related NIPs: NIP-01.
*/
export interface SetMetadata extends RawEvent {
kind: EventKind.SetMetadata
/**
* Get the user metadata specified in this event.
*/
getUserMetadata(): UserMetadata
}
export interface UserMetadata {
name: string
about: string
picture: string
}
/**
* Create a set metadata event.
*/
export function createSetMetadata(
content: UserMetadata
): Unsigned<SetMetadata> {
return {
kind: EventKind.SetMetadata,
tags: [],
content: JSON.stringify(content),
getUserMetadata,
}
}
export function getUserMetadata(this: Unsigned<RawEvent>): 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
}

View File

@ -0,0 +1,18 @@
import { EventKind, RawEvent, Unsigned } from "."
/**
* A text note event. Used for transmitting user posts.
*
* Related NIPs: NIP-01.
*/
export interface TextNote extends RawEvent {
kind: EventKind.TextNote
}
export function createTextNote(content: string): Unsigned<TextNote> {
return {
kind: EventKind.TextNote,
tags: [],
content,
}
}

View File

@ -0,0 +1,56 @@
import assert from "assert"
import { EventKind, signEvent } from "../src/event"
import { createContactList } from "../src/event/contact-list"
import { setup } from "./setup"
describe("contact-list", () => {
it("publish and receive the contact list", (done) => {
setup(done, ({ publisher, subscriber, subscriberSecret, done }) => {
const contacts = [
{
pubkey:
"db9df52f7fcaf30b2718ad17e4c5521058bb20b95073b5c4ff53221b36447c4f",
relay: undefined,
petname: undefined,
},
{
pubkey:
"94d5ce4cb06f67cab69a2f6e28e0a795222a74ac6a1dd6223743913cc99eaf37",
relay: new URL("ws://example.com"),
petname: undefined,
},
{
pubkey:
"e6e9a25dbf3e931c991f43c97378e294c25f59e88adc91eda11ed17249a00c20",
relay: undefined,
petname: "john",
},
{
pubkey:
"13d629a3a879f2157199491408711ff5e1450002a9f9d8b0ad750f1c6b96661d",
relay: new URL("ws://example2.com"),
petname: "jack",
},
]
subscriber.on("event", ({ event }) => {
assert.strictEqual(event.kind, EventKind.ContactList)
assert.strictEqual(event.content, "")
if (event.kind === EventKind.ContactList) {
assert.deepStrictEqual(event.getContacts(), contacts)
}
done()
})
subscriber.subscribe([])
// After the subscription event sync is done, publish the test event.
subscriber.on("eose", async () => {
// TODO No signEvent, have a convenient way to do this
publisher.publish(
await signEvent(createContactList(contacts), subscriberSecret)
)
})
})
})
})

View File

@ -1,7 +1,8 @@
import { createDirectMessage, EventKind, signEvent } from "../src/event" import { EventKind, signEvent } from "../src/event"
import { parsePublicKey } from "../src/crypto" import { parsePublicKey } from "../src/crypto"
import assert from "assert" import assert from "assert"
import { setup } from "./setup" import { setup } from "./setup"
import { createDirectMessage } from "../src/event/direct-message"
describe("dm", () => { describe("dm", () => {
const message = "for your eyes only" const message = "for your eyes only"
@ -112,12 +113,11 @@ describe("dm", () => {
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 signEvent( const event = await createDirectMessage(
await createDirectMessage({ {
message, message,
recipient: recipientPubkey, recipient: recipientPubkey,
priv: publisherSecret, },
}),
publisherSecret publisherSecret
) )
publisher.publish(event) publisher.publish(event)

View File

@ -1,13 +1,8 @@
import { import { EventKind, signEvent, Unsigned } from "../src/event"
createTextNote,
EventKind,
signEvent,
TextNote,
Unsigned,
} from "../src/event"
import { parsePublicKey } from "../src/crypto" import { parsePublicKey } from "../src/crypto"
import assert from "assert" import assert from "assert"
import { setup } from "./setup" import { setup } from "./setup"
import { createTextNote, TextNote } from "../src/event/text"
describe("text note", () => { describe("text note", () => {
const note = "hello world" const note = "hello world"
@ -41,18 +36,20 @@ describe("text note", () => {
const subscriptionId = subscriber.subscribe([]) const subscriptionId = subscriber.subscribe([])
// 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", (id, nostr) => { subscriber.on("eose", async (id, nostr) => {
assert.strictEqual(nostr, subscriber) assert.strictEqual(nostr, subscriber)
assert.strictEqual(id, subscriptionId) assert.strictEqual(id, subscriptionId)
// TODO No signEvent, have a convenient way to do this // TODO No signEvent, have a convenient way to do this
signEvent( publisher.publish(
await signEvent(
{ {
...createTextNote(note), ...createTextNote(note),
created_at: timestamp, created_at: timestamp,
} as Unsigned<TextNote>, } as Unsigned<TextNote>,
publisherSecret publisherSecret
).then((event) => publisher.publish(event)) )
)
}) })
} }
) )