Merge pull request #474 from v0l/nostr-package-nip05-dns-identifiers

`nostr` package: implement NIP-05
This commit is contained in:
sistemd 2023-04-04 22:06:18 +02:00 committed by GitHub
commit 25c1f48a95
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 324 additions and 104 deletions

View File

@ -321,4 +321,4 @@
"zjJZBd": "You're ready!", "zjJZBd": "You're ready!",
"zonsdq": "Failed to load LNURL service", "zonsdq": "Failed to load LNURL service",
"zvCDao": "Automatically show latest notes" "zvCDao": "Automatically show latest notes"
} }

View File

@ -0,0 +1,2 @@
dist/
src/legacy

View File

@ -9,33 +9,33 @@ 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: 7/34 (20%)._ _Progress: 8/34 (23%)._
- [X] NIP-01: Basic protocol flow description - [x] NIP-01: Basic protocol flow description
- [X] 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 - [x] NIP-05: Mapping Nostr keys to DNS-based internet identifiers
- [ ] NIP-06: Basic key derivation from mnemonic seed phrase - [ ] NIP-06: Basic key derivation from mnemonic seed phrase
- [ ] NIP-07: window.nostr capability for web browsers - [ ] NIP-07: window.nostr capability for web browsers
- [ ] NIP-08: Handling Mentions
- [ ] NIP-09: Event Deletion - [ ] NIP-09: Event Deletion
- [ ] NIP-10: Conventions for clients' use of `e` and `p` tags in text events - [ ] NIP-10: Conventions for clients' use of `e` and `p` tags in text events
- TODO Check if this applies - TODO Check if this applies
- [X] NIP-11: Relay Information Document - [x] NIP-11: Relay Information Document
- [X] NIP-12: Generic Tag Queries - [x] NIP-12: Generic Tag Queries
- [ ] NIP-13: Proof of Work - [ ] NIP-13: Proof of Work
- [ ] NIP-14: Subject tag in text events - [ ] NIP-14: Subject tag in text events
- [X] NIP-15: End of Stored Events Notice - [x] NIP-15: End of Stored Events Notice
- [ ] NIP-19: bech32-encoded entities - [ ] NIP-19: bech32-encoded entities
- [X] `npub` - [x] `npub`
- [X] `nsec` - [x] `nsec`
- [ ] `note`, `nprofile`, `nevent`, `nrelay`, `naddr` - [ ] `note`, `nprofile`, `nevent`, `nrelay`, `naddr`
- [X] NIP-20: Command Results - [x] NIP-20: Command Results
- [ ] NIP-21: `nostr:` URL scheme - [ ] NIP-21: `nostr:` URL scheme
- [ ] NIP-23: Long-form Content - [ ] NIP-23: Long-form Content
- [ ] NIP-25: Reactions - [ ] NIP-25: Reactions
- [ ] NIP-26: Delegated Event Signing - [ ] NIP-26: Delegated Event Signing
- [ ] NIP-27: Text Note References
- [ ] NIP-28: Public Chat - [ ] NIP-28: Public Chat
- [ ] NIP-36: Sensitive Content - [ ] NIP-36: Sensitive Content
- [ ] NIP-39: External Identities in Profiles - [ ] NIP-39: External Identities in Profiles

View File

@ -1,7 +1,12 @@
version: "3.1" version: "3.1"
services: services:
well-known:
build: ./docker/well-known
restart: on-failure
ports:
- 12647:80
relay: relay:
build: ./relay build: ./docker/relay
restart: on-failure restart: on-failure
ports: ports:
- 12648:8080 - 12648:8080

View File

@ -9,4 +9,4 @@ EXPOSE 8000
COPY . . COPY . .
USER $APP_USER USER $APP_USER
RUN yarn RUN yarn
CMD yarn app /bin/bash -c "rm -rf /usr/src/app/db/* && ./nostr-rs-relay --db /usr/src/app/db --config ./config.toml" CMD ["/bin/bash", "-c", "while :; do yarn app /bin/bash -c 'rm -rf /usr/src/app/db/* && ./nostr-rs-relay --db /usr/src/app/db --config ./config.toml'; done;"]

View File

@ -0,0 +1,4 @@
# An nginx server to return a .well-known/nostr.json file for testing.
FROM nginx
RUN mkdir /usr/share/nginx/html/.well-known
COPY nostr.json /usr/share/nginx/html/.well-known/nostr.json

View File

@ -0,0 +1,10 @@
{
"names": {
"bob": "be4be3c0c4f2d18f11215087d7d18fad2c9e0269fc50e19d526bdfb11a522a64"
},
"relays": {
"be4be3c0c4f2d18f11215087d7d18fad2c9e0269fc50e19d526bdfb11a522a64": [
"ws://example.com"
]
}
}

View File

@ -1,6 +1,6 @@
import { EventKind, RawEvent, Unsigned } from "." import { EventKind, RawEvent, signEvent } from "."
import { NostrError } from "../common" import { NostrError } from "../common"
import { PublicKey } from "../crypto" import { HexOrBechPrivateKey, PublicKey } from "../crypto"
/** /**
* Contact list event. * Contact list event.
@ -28,18 +28,24 @@ export interface Contact {
/** /**
* Create a contact list event. * Create a contact list event.
*/ */
export function createContactList(contacts: Contact[]): Unsigned<ContactList> { export function createContactList(
return { contacts: Contact[],
kind: EventKind.ContactList, priv?: HexOrBechPrivateKey
tags: contacts.map((contact) => [ ): Promise<ContactList> {
"p", return signEvent(
contact.pubkey, {
contact.relay?.toString() ?? "", kind: EventKind.ContactList,
contact.petname ?? "", tags: contacts.map((contact) => [
]), "p",
content: "", contact.pubkey,
getContacts, contact.relay?.toString() ?? "",
} contact.petname ?? "",
]),
content: "",
getContacts,
},
priv
)
} }
export function getContacts(this: ContactList): Contact[] { export function getContacts(this: ContactList): Contact[] {

View File

@ -1,12 +1,5 @@
import { import { EventId, EventKind, RawEvent, signEvent } from "."
EventId, import { NostrError } from "../common"
EventKind,
RawEvent,
signEvent,
Unsigned,
UnsignedWithPubkey,
} from "."
import { defined, NostrError } from "../common"
import { import {
aesDecryptBase64, aesDecryptBase64,
aesEncryptBase64, aesEncryptBase64,
@ -77,7 +70,7 @@ export async function createDirectMessage(
} }
export async function getMessage( export async function getMessage(
this: UnsignedWithPubkey<DirectMessage>, this: DirectMessage,
priv?: HexOrBechPrivateKey priv?: HexOrBechPrivateKey
): Promise<string | undefined> { ): Promise<string | undefined> {
if (priv !== undefined) { if (priv !== undefined) {
@ -96,7 +89,7 @@ export async function getMessage(
return undefined return undefined
} }
export function getRecipient(this: Unsigned<RawEvent>): PublicKey { export function getRecipient(this: DirectMessage): PublicKey {
const recipientTag = this.tags.find((tag) => tag[0] === "p") const recipientTag = this.tags.find((tag) => tag[0] === "p")
if (typeof recipientTag?.[1] !== "string") { if (typeof recipientTag?.[1] !== "string") {
throw new NostrError( throw new NostrError(
@ -108,7 +101,7 @@ export function getRecipient(this: Unsigned<RawEvent>): PublicKey {
return recipientTag[1] return recipientTag[1]
} }
export function getPrevious(this: Unsigned<RawEvent>): EventId | undefined { export function getPrevious(this: DirectMessage): EventId | undefined {
const previousTag = this.tags.find((tag) => tag[0] === "e") const previousTag = this.tags.find((tag) => tag[0] === "e")
if (previousTag === undefined) { if (previousTag === undefined) {
return undefined return undefined

View File

@ -9,7 +9,11 @@ import {
} from "../crypto" } from "../crypto"
import { Timestamp, unixTimestamp, NostrError } from "../common" import { Timestamp, unixTimestamp, NostrError } from "../common"
import { TextNote } from "./text" import { TextNote } from "./text"
import { getUserMetadata, SetMetadata } from "./set-metadata" import {
getUserMetadata,
SetMetadata,
verifyInternetIdentifier,
} from "./set-metadata"
import { import {
DirectMessage, DirectMessage,
getMessage, getMessage,
@ -94,11 +98,10 @@ 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.
*/ */
export type UnsignedWithPubkey<T extends Event | RawEvent> = { 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"
@ -113,7 +116,7 @@ export type UnsignedWithPubkey<T extends Event | RawEvent> = {
* Add the "id," "sig," and "pubkey" fields to the event. Set "created_at" to the current timestamp * Add the "id," "sig," and "pubkey" fields to the event. Set "created_at" to the current timestamp
* if missing. Return the event. * if missing. Return the event.
*/ */
export async function signEvent<T extends Event | RawEvent>( export async function signEvent<T extends RawEvent>(
event: Unsigned<T>, event: Unsigned<T>,
priv?: HexOrBechPrivateKey priv?: HexOrBechPrivateKey
): Promise<T> { ): Promise<T> {
@ -164,6 +167,7 @@ export async function parseEvent(event: RawEvent): Promise<Event> {
...event, ...event,
kind: EventKind.SetMetadata, kind: EventKind.SetMetadata,
getUserMetadata, getUserMetadata,
verifyInternetIdentifier,
} }
} }
@ -194,14 +198,14 @@ export async function parseEvent(event: RawEvent): Promise<Event> {
async function serializeEventId( async function serializeEventId(
event: UnsignedWithPubkey<RawEvent> event: UnsignedWithPubkey<RawEvent>
): Promise<EventId> { ): Promise<EventId> {
// It's not defined whether JSON.stringify produces a string with whitespace stripped. const serialized = JSON.stringify([
// Building the JSON string manually as follows ensures that there's no whitespace. 0,
// In hindsight using JSON as a data format for hashing and signing is not the best event.pubkey,
// design decision. event.created_at,
const serializedTags = `[${event.tags event.kind,
.map((tag) => `[${tag.map((v) => `"${v}"`).join(",")}]`) event.tags,
.join(",")}]` event.content,
const serialized = `[0,"${event.pubkey}",${event.created_at},${event.kind},${serializedTags},"${event.content}"]` ])
return await sha256(Uint8Array.from(charCodes(serialized))) return await sha256(Uint8Array.from(charCodes(serialized)))
} }

View File

@ -1,5 +1,6 @@
import { EventKind, RawEvent, Unsigned } from "." import { EventKind, RawEvent, signEvent } from "."
import { NostrError, parseJson } from "../common" import { NostrError, parseJson } from "../common"
import { HexOrBechPrivateKey } from "../crypto"
/** /**
* Set metadata event. Used for disseminating use profile information. * Set metadata event. Used for disseminating use profile information.
@ -13,29 +14,45 @@ export interface SetMetadata extends RawEvent {
* Get the user metadata specified in this event. * Get the user metadata specified in this event.
*/ */
getUserMetadata(): UserMetadata 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 { export interface UserMetadata {
name: string name: string
about: string about: string
picture: string picture: string
nip05?: string
} }
/** /**
* Create a set metadata event. * Create a set metadata event.
*/ */
export function createSetMetadata( export function createSetMetadata(
content: UserMetadata content: UserMetadata,
): Unsigned<SetMetadata> { priv?: HexOrBechPrivateKey
return { ): Promise<SetMetadata> {
kind: EventKind.SetMetadata, return signEvent(
tags: [], {
content: JSON.stringify(content), kind: EventKind.SetMetadata,
getUserMetadata, tags: [],
} content: JSON.stringify(content),
getUserMetadata,
verifyInternetIdentifier,
},
priv
)
} }
export function getUserMetadata(this: Unsigned<RawEvent>): UserMetadata { export function getUserMetadata(this: SetMetadata): UserMetadata {
const userMetadata = parseJson(this.content) const userMetadata = parseJson(this.content)
if ( if (
typeof userMetadata.name !== "string" || typeof userMetadata.name !== "string" ||
@ -48,3 +65,59 @@ export function getUserMetadata(this: Unsigned<RawEvent>): UserMetadata {
} }
return userMetadata 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,4 +1,5 @@
import { EventKind, RawEvent, Unsigned } from "." import { EventKind, RawEvent, signEvent } from "."
import { HexOrBechPrivateKey } from "../crypto"
/** /**
* A text note event. Used for transmitting user posts. * A text note event. Used for transmitting user posts.
@ -9,10 +10,16 @@ export interface TextNote extends RawEvent {
kind: EventKind.TextNote kind: EventKind.TextNote
} }
export function createTextNote(content: string): Unsigned<TextNote> { export function createTextNote(
return { content: string,
kind: EventKind.TextNote, priv?: HexOrBechPrivateKey
tags: [], ): Promise<TextNote> {
content, return signEvent(
} {
kind: EventKind.TextNote,
tags: [],
content,
},
priv
)
} }

View File

@ -1,5 +1,5 @@
import assert from "assert" import assert from "assert"
import { EventKind, signEvent } from "../src/event" import { EventKind } from "../src/event"
import { createContactList } from "../src/event/contact-list" import { createContactList } from "../src/event/contact-list"
import { setup } from "./setup" import { setup } from "./setup"
@ -47,9 +47,7 @@ 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( publisher.publish(await createContactList(contacts, subscriberSecret))
await signEvent(createContactList(contacts), subscriberSecret)
)
}) })
}) })
}) })

View File

@ -1,4 +1,4 @@
import { EventKind, signEvent } from "../src/event" import { EventKind } 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"
@ -49,13 +49,11 @@ describe("dm", () => {
const subscriptionId = subscriber.subscribe([]) const subscriptionId = subscriber.subscribe([])
subscriber.on("eose", async () => { subscriber.on("eose", async () => {
// TODO No signEvent, do something more convenient const event = await createDirectMessage(
const event = await signEvent( {
await createDirectMessage({
message, message,
recipient: subscriberPubkey, recipient: subscriberPubkey,
priv: publisherSecret, },
}),
publisherSecret publisherSecret
) )
publisher.publish(event) publisher.publish(event)

View File

@ -0,0 +1,75 @@
import assert from "assert"
import { defined } from "../src/common"
import { EventKind } from "../src/event"
import { createSetMetadata } from "../src/event/set-metadata"
import { setup } from "./setup"
describe("internet-identifier", () => {
it("present", (done) => {
setup(done, ({ publisher, publisherSecret, subscriber, done }) => {
subscriber.on("event", async ({ event }) => {
// Assert that the internet identifier can be verified.
assert.strictEqual(event.kind, EventKind.SetMetadata)
if (event.kind === EventKind.SetMetadata) {
const identifier = await event.verifyInternetIdentifier({
https: false,
})
assert.ok(identifier)
const { name, relays } = defined(identifier)
assert.strictEqual(name, "bob")
assert.deepStrictEqual(relays, ["ws://example.com"])
}
done()
})
subscriber.subscribe([])
// After the subscription event sync is done, publish the test event.
subscriber.on("eose", async () => {
publisher.publish({
...(await createSetMetadata(
{
about: "",
name: "",
picture: "",
nip05: "bob@localhost:12647",
},
publisherSecret
)),
})
})
})
})
it("missing", (done) => {
setup(done, ({ publisher, publisherSecret, subscriber, done }) => {
subscriber.on("event", async ({ event }) => {
// Assert that undefined is returned if the internet identifier is missing.
assert.strictEqual(event.kind, EventKind.SetMetadata)
if (event.kind === EventKind.SetMetadata) {
const identifier = await event.verifyInternetIdentifier({
https: false,
})
assert.strictEqual(identifier, undefined)
}
done()
})
subscriber.subscribe([])
// After the subscription event sync is done, publish the test event.
subscriber.on("eose", async () => {
publisher.publish({
...(await createSetMetadata(
{
about: "",
name: "",
picture: "",
},
publisherSecret
)),
})
})
})
})
})

View File

@ -0,0 +1,54 @@
import { EventKind } from "../src/event"
import { parsePublicKey } from "../src/crypto"
import assert from "assert"
import { setup } from "./setup"
import { createSetMetadata } from "../src/event/set-metadata"
describe("set metadata", () => {
const name = "bob"
const about = "this is bob"
const picture = "https://example.com/bob.jpg"
// Test that a set metadata event can be published by one client and received by the other.
it("publish and receive", (done) => {
setup(
done,
({
publisher,
publisherSecret,
publisherPubkey,
subscriber,
timestamp,
done,
}) => {
// Expect the test event.
subscriber.on("event", ({ event }) => {
assert.strictEqual(event.kind, EventKind.SetMetadata)
if (event.kind === EventKind.SetMetadata) {
const user = event.getUserMetadata()
assert.strictEqual(event.pubkey, parsePublicKey(publisherPubkey))
assert.strictEqual(event.created_at, timestamp)
assert.strictEqual(event.tags.length, 0)
assert.strictEqual(user.name, name)
assert.strictEqual(user.about, about)
assert.strictEqual(user.picture, picture)
}
done()
})
subscriber.subscribe([])
// After the subscription event sync is done, publish the test event.
subscriber.on("eose", async () => {
publisher.publish({
...(await createSetMetadata(
{ name, about, picture },
publisherSecret
)),
created_at: timestamp,
})
})
}
)
})
})

View File

@ -1,8 +1,8 @@
import { EventKind, signEvent, Unsigned } from "../src/event" import { EventKind } 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" import { createTextNote } from "../src/event/text"
describe("text note", () => { describe("text note", () => {
const note = "hello world" const note = "hello world"
@ -40,16 +40,10 @@ describe("text note", () => {
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 publisher.publish({
publisher.publish( ...(await createTextNote(note, publisherSecret)),
await signEvent( created_at: timestamp,
{ })
...createTextNote(note),
created_at: timestamp,
} as Unsigned<TextNote>,
publisherSecret
)
)
}) })
} }
) )
@ -57,19 +51,16 @@ 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, ({ publisher, publisherSecret, url, done }) => { setup(done, async ({ publisher, publisherSecret, url, done }) => {
// TODO No signEvent, have a convenient way to do this const event = await createTextNote(note, publisherSecret)
signEvent(createTextNote(note), publisherSecret).then((event) => { 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) assert.strictEqual(params.relay.toString(), url.toString())
assert.strictEqual(params.relay.toString(), url.toString()) assert.strictEqual(params.ok, true)
assert.strictEqual(params.ok, true) done()
done()
})
publisher.publish(event)
}) })
publisher.publish(event)
}) })
}) })
}) })