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
and tested against a real-world relay implementation.
_Progress: 6/34 (18%)._
_Progress: 7/34 (20%)._
- [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
- [X] NIP-04: Encrypted Direct Message
- [ ] 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 { EventId, RawEvent } from "../event"
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 only responsibility of this type is to send and receive
* well-formatted nostr messages on the underlying websocket. All other details of the protocol
* are handled by `Nostr`.
* are handled by `Nostr`. This type does not know anything about event semantics.
*
* @see Nostr
*/
@ -321,11 +321,3 @@ function parseEventData(json: { [key: string]: unknown }): 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
}
/**
* 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.
*/

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 {
PublicKey,
PrivateKey,
sha256,
schnorrSign,
schnorrVerify,
parsePublicKey,
aesDecryptBase64,
getPublicKey,
HexOrBechPrivateKey,
parsePrivateKey,
aesEncryptBase64,
} from "./crypto"
import { defined, Timestamp, unixTimestamp, NostrError } from "./common"
} from "../crypto"
import { Timestamp, unixTimestamp, NostrError } from "../common"
import { TextNote } from "./text"
import { getUserMetadata, SetMetadata } from "./set-metadata"
import {
DirectMessage,
getMessage,
getPrevious,
getRecipient,
} from "./direct-message"
import { ContactList, getContacts } from "./contact-list"
// TODO Add remaining event types
// TODO
// Think about this more
// Perhaps the best option is for all these factory methods to have an overload which also accept a private
// key as last parameter and return the event already signed
// Or maybe opts: { sign?: boolean | HexOrBechPrivateKey } setting sign to true should use nip07, setting
// it to a string will use that string as the private key
export enum EventKind {
SetMetadata = 0, // NIP-01
TextNote = 1, // NIP-01
@ -48,50 +60,22 @@ export interface RawEvent {
[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 {
kind: Exclude<
EventKind,
EventKind.SetMetadata | EventKind.TextNote | EventKind.DirectMessage
| EventKind.SetMetadata
| EventKind.TextNote
| EventKind.DirectMessage
| EventKind.ContactList
>
}
export type Event = SetMetadata | TextNote | DirectMessage | Unknown
export interface UserMetadata {
name: string
about: string
picture: string
}
export type Event =
| SetMetadata
| TextNote
| ContactList
| DirectMessage
| Unknown
/**
* Event ID encoded as hex.
@ -110,10 +94,11 @@ export type Unsigned<T extends Event | RawEvent> = {
pubkey?: PublicKey
}
// TODO This doesn't need to be exposed by the lib
/**
* 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,
"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.
*/
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))) {
throw new NostrError(
`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)}`)
}
// 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,
@ -238,6 +177,14 @@ export async function parseEvent(event: RawEvent): Promise<Event> {
}
}
if (event.kind === EventKind.ContactList) {
return {
...event,
kind: EventKind.ContactList,
getContacts,
}
}
return {
...event,
kind: event.kind,
@ -258,72 +205,6 @@ async function serializeEventId(
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> {
for (let i = 0; i < data.length; 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 assert from "assert"
import { setup } from "./setup"
import { createDirectMessage } from "../src/event/direct-message"
describe("dm", () => {
const message = "for your eyes only"
@ -112,12 +113,11 @@ describe("dm", () => {
subscriber.on("eose", async () => {
// TODO No signEvent, do something more convenient
const event = await signEvent(
await createDirectMessage({
const event = await createDirectMessage(
{
message,
recipient: recipientPubkey,
priv: publisherSecret,
}),
},
publisherSecret
)
publisher.publish(event)

View File

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