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!",
"zonsdq": "Failed to load LNURL service",
"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
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-02: Contact List and Petnames
- [x] NIP-01: Basic protocol flow description
- [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
- [x] NIP-04: Encrypted Direct Message
- [x] NIP-05: Mapping Nostr keys to DNS-based internet identifiers
- [ ] NIP-06: Basic key derivation from mnemonic seed phrase
- [ ] NIP-07: window.nostr capability for web browsers
- [ ] NIP-08: Handling Mentions
- [ ] NIP-09: Event Deletion
- [ ] NIP-10: Conventions for clients' use of `e` and `p` tags in text events
- TODO Check if this applies
- [X] NIP-11: Relay Information Document
- [X] NIP-12: Generic Tag Queries
- [x] NIP-11: Relay Information Document
- [x] NIP-12: Generic Tag Queries
- [ ] NIP-13: Proof of Work
- [ ] 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
- [X] `npub`
- [X] `nsec`
- [x] `npub`
- [x] `nsec`
- [ ] `note`, `nprofile`, `nevent`, `nrelay`, `naddr`
- [X] NIP-20: Command Results
- [x] NIP-20: Command Results
- [ ] NIP-21: `nostr:` URL scheme
- [ ] NIP-23: Long-form Content
- [ ] NIP-25: Reactions
- [ ] NIP-26: Delegated Event Signing
- [ ] NIP-27: Text Note References
- [ ] NIP-28: Public Chat
- [ ] NIP-36: Sensitive Content
- [ ] NIP-39: External Identities in Profiles

View File

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

View File

@ -9,4 +9,4 @@ EXPOSE 8000
COPY . .
USER $APP_USER
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 { PublicKey } from "../crypto"
import { HexOrBechPrivateKey, PublicKey } from "../crypto"
/**
* Contact list event.
@ -28,18 +28,24 @@ export interface Contact {
/**
* 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 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[] {

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import assert from "assert"
import { EventKind, signEvent } from "../src/event"
import { EventKind } from "../src/event"
import { createContactList } from "../src/event/contact-list"
import { setup } from "./setup"
@ -47,9 +47,7 @@ describe("contact-list", () => {
// After the subscription event sync is done, publish the test event.
subscriber.on("eose", async () => {
// TODO No signEvent, have a convenient way to do this
publisher.publish(
await signEvent(createContactList(contacts), subscriberSecret)
)
publisher.publish(await 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 assert from "assert"
import { setup } from "./setup"
@ -49,13 +49,11 @@ describe("dm", () => {
const subscriptionId = subscriber.subscribe([])
subscriber.on("eose", async () => {
// TODO No signEvent, do something more convenient
const event = await signEvent(
await createDirectMessage({
const event = await createDirectMessage(
{
message,
recipient: subscriberPubkey,
priv: publisherSecret,
}),
},
publisherSecret
)
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 assert from "assert"
import { setup } from "./setup"
import { createTextNote, TextNote } from "../src/event/text"
import { createTextNote } from "../src/event/text"
describe("text note", () => {
const note = "hello world"
@ -40,16 +40,10 @@ describe("text note", () => {
assert.strictEqual(nostr, subscriber)
assert.strictEqual(id, subscriptionId)
// TODO No signEvent, have a convenient way to do this
publisher.publish(
await signEvent(
{
...createTextNote(note),
created_at: timestamp,
} as Unsigned<TextNote>,
publisherSecret
)
)
publisher.publish({
...(await createTextNote(note, publisherSecret)),
created_at: timestamp,
})
})
}
)
@ -57,19 +51,16 @@ describe("text note", () => {
// Test that a client interprets an "OK" message after publishing a text note.
it("publish and ok", function (done) {
setup(done, ({ publisher, publisherSecret, url, done }) => {
// TODO No signEvent, have a convenient way to do this
signEvent(createTextNote(note), publisherSecret).then((event) => {
publisher.on("ok", (params, nostr) => {
assert.strictEqual(nostr, publisher)
assert.strictEqual(params.eventId, event.id)
assert.strictEqual(params.relay.toString(), url.toString())
assert.strictEqual(params.ok, true)
done()
})
publisher.publish(event)
setup(done, async ({ publisher, publisherSecret, url, done }) => {
const event = await createTextNote(note, publisherSecret)
publisher.on("ok", (params, nostr) => {
assert.strictEqual(nostr, publisher)
assert.strictEqual(params.eventId, event.id)
assert.strictEqual(params.relay.toString(), url.toString())
assert.strictEqual(params.ok, true)
done()
})
publisher.publish(event)
})
})
})