add direct message event and refactor
This commit is contained in:
parent
0d4394e1e6
commit
a7707af756
@ -309,4 +309,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"
|
||||||
}
|
}
|
||||||
|
@ -26,6 +26,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^1.2.0",
|
"@noble/hashes": "^1.2.0",
|
||||||
"@noble/secp256k1": "^1.7.1",
|
"@noble/secp256k1": "^1.7.1",
|
||||||
|
"base64-js": "^1.5.1",
|
||||||
"bech32": "^2.0.0",
|
"bech32": "^2.0.0",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"isomorphic-ws": "^5.0.0",
|
"isomorphic-ws": "^5.0.0",
|
||||||
|
@ -120,7 +120,6 @@ export class Conn {
|
|||||||
return {
|
return {
|
||||||
kind: "event",
|
kind: "event",
|
||||||
subscriptionId: new SubscriptionId(json[1]),
|
subscriptionId: new SubscriptionId(json[1]),
|
||||||
signed: await SignedEvent.verify(raw),
|
|
||||||
raw,
|
raw,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -175,7 +174,6 @@ export type IncomingKind = "event" | "notice" | "ok"
|
|||||||
export interface IncomingEvent {
|
export interface IncomingEvent {
|
||||||
kind: "event"
|
kind: "event"
|
||||||
subscriptionId: SubscriptionId
|
subscriptionId: SubscriptionId
|
||||||
signed: SignedEvent
|
|
||||||
raw: RawEvent
|
raw: RawEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -266,18 +264,18 @@ function serializeFilters(filters: Filters[]): RawFilters[] {
|
|||||||
return [{}]
|
return [{}]
|
||||||
}
|
}
|
||||||
return filters.map((filter) => ({
|
return filters.map((filter) => ({
|
||||||
ids: filter.ids?.map((id) => id.toString()),
|
ids: filter.ids?.map((id) => id.toHex()),
|
||||||
authors: filter.authors?.map((author) => author.toString()),
|
authors: filter.authors?.map((author) => author),
|
||||||
kinds: filter.kinds?.map((kind) => kind),
|
kinds: filter.kinds?.map((kind) => kind),
|
||||||
["#e"]: filter.eventTags?.map((e) => e.toString()),
|
["#e"]: filter.eventTags?.map((e) => e.toHex()),
|
||||||
["#p"]: filter.pubkeyTags?.map((p) => p.toString()),
|
["#p"]: filter.pubkeyTags?.map((p) => p.toHex()),
|
||||||
since: filter.since !== undefined ? unixTimestamp(filter.since) : undefined,
|
since: filter.since !== undefined ? unixTimestamp(filter.since) : undefined,
|
||||||
until: filter.until !== undefined ? unixTimestamp(filter.until) : undefined,
|
until: filter.until !== undefined ? unixTimestamp(filter.until) : undefined,
|
||||||
limit: filter.limit,
|
limit: filter.limit,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseEventData(json: object): RawEvent {
|
function parseEventData(json: { [key: string]: unknown }): RawEvent {
|
||||||
if (
|
if (
|
||||||
typeof json["id"] !== "string" ||
|
typeof json["id"] !== "string" ||
|
||||||
typeof json["pubkey"] !== "string" ||
|
typeof json["pubkey"] !== "string" ||
|
||||||
@ -292,7 +290,7 @@ function parseEventData(json: object): RawEvent {
|
|||||||
) {
|
) {
|
||||||
throw new ProtocolError(`invalid event: ${JSON.stringify(json)}`)
|
throw new ProtocolError(`invalid event: ${JSON.stringify(json)}`)
|
||||||
}
|
}
|
||||||
return json as RawEvent
|
return json as unknown as RawEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJson(data: string) {
|
function parseJson(data: string) {
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { ProtocolError } from "../error"
|
import { ProtocolError } from "../error"
|
||||||
import { EventId, Event, EventKind, SignedEvent, RawEvent } from "../event"
|
import { EventId, Event, EventKind, SignedEvent, RawEvent } from "../event"
|
||||||
import { PrivateKey, PublicKey } from "../keypair"
|
import { PrivateKey, PublicKey } from "../crypto"
|
||||||
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 { defined } from "../util"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A nostr client.
|
* A nostr client.
|
||||||
@ -22,6 +23,16 @@ export class Nostr extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
readonly #subscriptions: Map<string, Filters[]> = new Map()
|
readonly #subscriptions: Map<string, Filters[]> = new Map()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional client private key.
|
||||||
|
*/
|
||||||
|
readonly #key?: PrivateKey
|
||||||
|
|
||||||
|
constructor(key?: PrivateKey) {
|
||||||
|
super()
|
||||||
|
this.#key = key
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open a connection and start communicating with a relay. This method recreates all existing
|
* Open a connection and start communicating with a relay. This method recreates all existing
|
||||||
* subscriptions on the new relay as well. If there is already an existing connection,
|
* subscriptions on the new relay as well. If there is already an existing connection,
|
||||||
@ -52,13 +63,13 @@ export class Nostr extends EventEmitter {
|
|||||||
const conn = new Conn({
|
const conn = new Conn({
|
||||||
url: connUrl,
|
url: connUrl,
|
||||||
// Handle messages on this connection.
|
// Handle messages on this connection.
|
||||||
onMessage: (msg) => {
|
onMessage: async (msg) => {
|
||||||
try {
|
try {
|
||||||
if (msg.kind === "event") {
|
if (msg.kind === "event") {
|
||||||
this.emit(
|
this.emit(
|
||||||
"event",
|
"event",
|
||||||
{
|
{
|
||||||
signed: msg.signed,
|
signed: await SignedEvent.verify(msg.raw, this.#key),
|
||||||
subscriptionId: msg.subscriptionId,
|
subscriptionId: msg.subscriptionId,
|
||||||
raw: msg.raw,
|
raw: msg.raw,
|
||||||
},
|
},
|
||||||
@ -208,7 +219,7 @@ export class Nostr extends EventEmitter {
|
|||||||
"publish called with an unsigned Event, private key must be specified"
|
"publish called with an unsigned Event, private key must be specified"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (event.pubkey.toString() !== key.pubkey.toString()) {
|
if (event.pubkey.toHex() !== key.pubkey.toHex()) {
|
||||||
throw new Error("invalid private key")
|
throw new Error("invalid private key")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -218,7 +229,7 @@ export class Nostr extends EventEmitter {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (!(event instanceof SignedEvent) && !("sig" in event)) {
|
if (!(event instanceof SignedEvent) && !("sig" in event)) {
|
||||||
event = await SignedEvent.sign(event, key)
|
event = await SignedEvent.sign(event, defined(key))
|
||||||
}
|
}
|
||||||
conn.send({
|
conn.send({
|
||||||
kind: "event",
|
kind: "event",
|
||||||
@ -263,30 +274,13 @@ export class SubscriptionId {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO Rethink this type. Maybe it's not very idiomatic.
|
|
||||||
/**
|
|
||||||
* A prefix filter. These filters match events which have the appropriate prefix.
|
|
||||||
* This also means that exact matches pass the filters. No special syntax is required.
|
|
||||||
*/
|
|
||||||
export class Prefix<T> {
|
|
||||||
#prefix: T
|
|
||||||
|
|
||||||
constructor(prefix: T) {
|
|
||||||
this.#prefix = prefix
|
|
||||||
}
|
|
||||||
|
|
||||||
toString(): string {
|
|
||||||
return this.#prefix.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscription filters. All filters from the fields must pass for a message to get through.
|
* Subscription filters. All filters from the fields must pass for a message to get through.
|
||||||
*/
|
*/
|
||||||
export interface Filters {
|
export interface Filters {
|
||||||
// TODO Document the filters, document that for the arrays only one is enough for the message to pass
|
// TODO Document the filters, document that for the arrays only one is enough for the message to pass
|
||||||
ids?: Prefix<EventId>[]
|
ids?: EventId[]
|
||||||
authors?: Prefix<string>[]
|
authors?: string[]
|
||||||
kinds?: EventKind[]
|
kinds?: EventKind[]
|
||||||
/**
|
/**
|
||||||
* Filters for the "#e" tags.
|
* Filters for the "#e" tags.
|
||||||
|
224
packages/nostr/src/crypto.ts
Normal file
224
packages/nostr/src/crypto.ts
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
import * as secp from "@noble/secp256k1"
|
||||||
|
import { ProtocolError } from "./error"
|
||||||
|
import base64 from "base64-js"
|
||||||
|
import { bech32 } from "bech32"
|
||||||
|
|
||||||
|
// TODO Use toHex as well as toString? Might be more explicit
|
||||||
|
// Or maybe replace toString with toHex
|
||||||
|
// TODO Or maybe always store Uint8Array and properly use the format parameter passed into toString
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A 32-byte secp256k1 public key.
|
||||||
|
*/
|
||||||
|
export class PublicKey {
|
||||||
|
#hex: Hex
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expects the key encoded as an npub-prefixed bech32 string, lowercase hex string, or byte buffer.
|
||||||
|
*/
|
||||||
|
constructor(key: string | Uint8Array) {
|
||||||
|
this.#hex = parseKey(key, "npub1")
|
||||||
|
if (this.#hex.toString().length !== 64) {
|
||||||
|
throw new ProtocolError(`invalid pubkey: ${key}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toHex(): string {
|
||||||
|
return this.#hex.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.toHex()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A 32-byte secp256k1 private key.
|
||||||
|
*/
|
||||||
|
export class PrivateKey {
|
||||||
|
#hex: Hex
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expects the key encoded as an nsec-prefixed bech32 string, lowercase hex string, or byte buffer.
|
||||||
|
*/
|
||||||
|
constructor(key: string | Uint8Array) {
|
||||||
|
this.#hex = parseKey(key, "nsec1")
|
||||||
|
if (this.#hex.toString().length !== 64) {
|
||||||
|
throw new ProtocolError(`invalid private key: ${this.#hex}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get pubkey(): PublicKey {
|
||||||
|
return new PublicKey(secp.schnorr.getPublicKey(this.#hex.toString()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hex representation of the private key. Use with caution!
|
||||||
|
*/
|
||||||
|
toHexDangerous(): string {
|
||||||
|
return this.#hex.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return "PrivateKey"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a public or private key into its hex representation.
|
||||||
|
*/
|
||||||
|
function parseKey(key: string | Uint8Array, bechPrefix: string): Hex {
|
||||||
|
if (typeof key === "string") {
|
||||||
|
// If the key is bech32-encoded, decode it.
|
||||||
|
if (key.startsWith(bechPrefix)) {
|
||||||
|
const { words } = bech32.decode(key)
|
||||||
|
const bytes = Uint8Array.from(bech32.fromWords(words))
|
||||||
|
return new Hex(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Hex(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the SHA256 hash of the data.
|
||||||
|
*/
|
||||||
|
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
|
||||||
|
return await secp.utils.sha256(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign the data using elliptic curve cryptography.
|
||||||
|
*/
|
||||||
|
export async function schnorrSign(
|
||||||
|
data: Hex,
|
||||||
|
key: PrivateKey
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
return secp.schnorr.sign(data.toString(), key.toHexDangerous())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that the elliptic curve signature is correct.
|
||||||
|
*/
|
||||||
|
export async function schnorrVerify(
|
||||||
|
sig: Hex,
|
||||||
|
data: Hex,
|
||||||
|
key: PublicKey
|
||||||
|
): Promise<boolean> {
|
||||||
|
return secp.schnorr.verify(sig.toString(), data.toString(), key.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AesEncryptedBase64 {
|
||||||
|
data: string
|
||||||
|
iv: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function aesEncryptBase64(
|
||||||
|
sender: PrivateKey,
|
||||||
|
recipient: PublicKey,
|
||||||
|
plaintext: string
|
||||||
|
): Promise<AesEncryptedBase64> {
|
||||||
|
const sharedPoint = secp.getSharedSecret(
|
||||||
|
sender.toHexDangerous(),
|
||||||
|
"02" + recipient.toHex()
|
||||||
|
)
|
||||||
|
const sharedKey = sharedPoint.slice(2, 33)
|
||||||
|
if (typeof window === "object") {
|
||||||
|
const key = await window.crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
sharedKey,
|
||||||
|
{ name: "AES-CBC" },
|
||||||
|
false,
|
||||||
|
["encrypt", "decrypt"]
|
||||||
|
)
|
||||||
|
const iv = window.crypto.getRandomValues(new Uint8Array(16))
|
||||||
|
const data = new TextEncoder().encode(plaintext)
|
||||||
|
const encrypted = await window.crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: "AES-CBC",
|
||||||
|
iv,
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
data: base64.fromByteArray(new Uint8Array(encrypted)),
|
||||||
|
iv: base64.fromByteArray(iv),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const crypto = await import("crypto")
|
||||||
|
const iv = crypto.randomFillSync(new Uint8Array(16))
|
||||||
|
const cipher = crypto.createCipheriv(
|
||||||
|
"aes-256-cbc",
|
||||||
|
// TODO If this code is correct, also fix the example code
|
||||||
|
// TODO I also this that the slice() above is incorrect because the author
|
||||||
|
// thought this was hex but it's actually bytes so should take 32 bytes not 64
|
||||||
|
// TODO Actually it's probably cleanest to leave out the end of the slice completely, if possible, and it should be
|
||||||
|
Buffer.from(sharedKey),
|
||||||
|
iv
|
||||||
|
)
|
||||||
|
let encrypted = cipher.update(plaintext, "utf8", "base64")
|
||||||
|
// TODO Could save an allocation here by avoiding the +=
|
||||||
|
encrypted += cipher.final()
|
||||||
|
return {
|
||||||
|
data: encrypted,
|
||||||
|
iv: Buffer.from(iv.buffer).toString("base64"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO
|
||||||
|
export async function aesDecryptBase64(
|
||||||
|
sender: PublicKey,
|
||||||
|
recipient: PrivateKey,
|
||||||
|
{ data, iv }: AesEncryptedBase64
|
||||||
|
): Promise<string> {
|
||||||
|
const sharedPoint = secp.getSharedSecret(
|
||||||
|
recipient.toHexDangerous(),
|
||||||
|
"02" + sender.toHex()
|
||||||
|
)
|
||||||
|
const sharedKey = sharedPoint.slice(2, 33)
|
||||||
|
if (typeof window === "object") {
|
||||||
|
// TODO Can copy this from the legacy code
|
||||||
|
throw new Error("todo")
|
||||||
|
} else {
|
||||||
|
const crypto = await import("crypto")
|
||||||
|
const decipher = crypto.createDecipheriv(
|
||||||
|
"aes-256-cbc",
|
||||||
|
Buffer.from(sharedKey),
|
||||||
|
base64.toByteArray(iv)
|
||||||
|
)
|
||||||
|
const plaintext = decipher.update(data, "base64", "utf8")
|
||||||
|
return plaintext + decipher.final()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A string in lowercase hex. This type is not available to the users of the library.
|
||||||
|
*/
|
||||||
|
export class Hex {
|
||||||
|
#value: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passing a non-lowercase or non-hex string to the constructor
|
||||||
|
* results in an error being thrown.
|
||||||
|
*/
|
||||||
|
constructor(value: string | Uint8Array) {
|
||||||
|
if (value instanceof Uint8Array) {
|
||||||
|
value = secp.utils.bytesToHex(value).toLowerCase()
|
||||||
|
}
|
||||||
|
if (value.length % 2 != 0) {
|
||||||
|
throw new ProtocolError(`invalid lowercase hex string: ${value}`)
|
||||||
|
}
|
||||||
|
const valid = "0123456789abcdef"
|
||||||
|
for (const c of value) {
|
||||||
|
if (!valid.includes(c)) {
|
||||||
|
throw new ProtocolError(`invalid lowercase hex string: ${value}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.#value
|
||||||
|
}
|
||||||
|
}
|
@ -1,3 +1,4 @@
|
|||||||
|
// TODO Rename to NostrError and move to util.ts, always throw NostrError and never throw Error
|
||||||
/**
|
/**
|
||||||
* An error in the protocol. This error is thrown when a relay sends invalid or
|
* An error in the protocol. This error is thrown when a relay sends invalid or
|
||||||
* unexpected data, or otherwise behaves in an unexpected way.
|
* unexpected data, or otherwise behaves in an unexpected way.
|
||||||
|
@ -1,7 +1,14 @@
|
|||||||
import { ProtocolError } from "./error"
|
import { ProtocolError } from "./error"
|
||||||
import * as secp from "@noble/secp256k1"
|
import {
|
||||||
import { PublicKey, PrivateKey } from "./keypair"
|
PublicKey,
|
||||||
import { unixTimestamp } from "./util"
|
PrivateKey,
|
||||||
|
sha256,
|
||||||
|
Hex,
|
||||||
|
schnorrSign,
|
||||||
|
schnorrVerify,
|
||||||
|
aesDecryptBase64,
|
||||||
|
} from "./crypto"
|
||||||
|
import { defined, unixTimestamp } from "./util"
|
||||||
|
|
||||||
// TODO This file is missing proper documentation
|
// TODO This file is missing proper documentation
|
||||||
// TODO Add remaining event types
|
// TODO Add remaining event types
|
||||||
@ -24,13 +31,19 @@ export enum EventKind {
|
|||||||
ZapReceipt = 9735, // NIP 57
|
ZapReceipt = 9735, // NIP 57
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Event = SetMetadataEvent | TextNoteEvent | UnknownEvent
|
export type Event =
|
||||||
|
| SetMetadataEvent
|
||||||
|
| TextNoteEvent
|
||||||
|
| DirectMessageEvent
|
||||||
|
| UnknownEvent
|
||||||
|
|
||||||
interface EventCommon {
|
interface EventCommon {
|
||||||
pubkey: PublicKey
|
pubkey: PublicKey
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO Refactor: the event names don't need to all end with *Event
|
||||||
|
|
||||||
export interface SetMetadataEvent extends EventCommon {
|
export interface SetMetadataEvent extends EventCommon {
|
||||||
kind: EventKind.SetMetadata
|
kind: EventKind.SetMetadata
|
||||||
content: UserMetadata
|
content: UserMetadata
|
||||||
@ -47,13 +60,23 @@ export interface TextNoteEvent extends EventCommon {
|
|||||||
content: string
|
content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DirectMessageEvent extends EventCommon {
|
||||||
|
kind: EventKind.DirectMessage
|
||||||
|
/**
|
||||||
|
* The plaintext message, or undefined if this client is not the recipient.
|
||||||
|
*/
|
||||||
|
message?: string
|
||||||
|
recipient: PublicKey
|
||||||
|
previous?: EventId
|
||||||
|
}
|
||||||
|
|
||||||
export interface UnknownEvent extends EventCommon {
|
export interface UnknownEvent extends EventCommon {
|
||||||
kind: Exclude<EventKind, EventKind.SetMetadata | EventKind.TextNote>
|
kind: Exclude<EventKind, EventKind.SetMetadata | EventKind.TextNote>
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO Doc comment
|
// TODO Doc comment
|
||||||
export class EventId {
|
export class EventId {
|
||||||
#hex: string
|
#hex: Hex
|
||||||
|
|
||||||
static async create(event: Event | RawEvent): Promise<EventId> {
|
static async create(event: Event | RawEvent): Promise<EventId> {
|
||||||
// It's not defined whether JSON.stringify produces a string with whitespace stripped.
|
// It's not defined whether JSON.stringify produces a string with whitespace stripped.
|
||||||
@ -66,34 +89,33 @@ export class EventId {
|
|||||||
.map((tag) => `[${tag.map((v) => `"${v}"`).join(",")}]`)
|
.map((tag) => `[${tag.map((v) => `"${v}"`).join(",")}]`)
|
||||||
.join(",")}]`
|
.join(",")}]`
|
||||||
const serialized = `[0,"${event.pubkey}",${event.created_at},${event.kind},${serializedTags},"${event.content}"]`
|
const serialized = `[0,"${event.pubkey}",${event.created_at},${event.kind},${serializedTags},"${event.content}"]`
|
||||||
const hash = await secp.utils.sha256(
|
const hash = await sha256(Uint8Array.from(charCodes(serialized)))
|
||||||
Uint8Array.from(charCodes(serialized))
|
return new EventId(hash)
|
||||||
)
|
|
||||||
return new EventId(secp.utils.bytesToHex(hash).toLowerCase())
|
|
||||||
} else {
|
} else {
|
||||||
// Not a raw event.
|
// Not a raw event.
|
||||||
const tags = serializeEventTags(event)
|
const tags = serializeTags(event)
|
||||||
const content = serializeEventContent(event)
|
const content = serializeContent(event)
|
||||||
const serializedTags = `[${tags
|
const serializedTags = `[${tags
|
||||||
.map((tag) => `[${tag.map((v) => `"${v}"`).join(",")}]`)
|
.map((tag) => `[${tag.map((v) => `"${v}"`).join(",")}]`)
|
||||||
.join(",")}]`
|
.join(",")}]`
|
||||||
const serialized = `[0,"${event.pubkey}",${unixTimestamp(
|
const serialized = `[0,"${event.pubkey}",${unixTimestamp(
|
||||||
event.createdAt
|
event.createdAt
|
||||||
)},${event.kind},${serializedTags},"${content}"]`
|
)},${event.kind},${serializedTags},"${content}"]`
|
||||||
const hash = await secp.utils.sha256(
|
const hash = await sha256(Uint8Array.from(charCodes(serialized)))
|
||||||
Uint8Array.from(charCodes(serialized))
|
return new EventId(hash)
|
||||||
)
|
|
||||||
return new EventId(secp.utils.bytesToHex(hash).toLowerCase())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(hex: string) {
|
constructor(hex: string | Uint8Array) {
|
||||||
// TODO Validate that this is 32-byte hex
|
this.#hex = new Hex(hex)
|
||||||
this.#hex = hex
|
}
|
||||||
|
|
||||||
|
toHex(): string {
|
||||||
|
return this.#hex.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
toString(): string {
|
toString(): string {
|
||||||
return this.#hex
|
return this.toHex()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,7 +125,7 @@ export class EventId {
|
|||||||
export class SignedEvent {
|
export class SignedEvent {
|
||||||
#event: Readonly<Event>
|
#event: Readonly<Event>
|
||||||
#eventId: EventId
|
#eventId: EventId
|
||||||
#signature: string
|
#signature: Hex
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign an event using the specified private key. The private key must match the
|
* Sign an event using the specified private key. The private key must match the
|
||||||
@ -111,29 +133,34 @@ export class SignedEvent {
|
|||||||
*/
|
*/
|
||||||
static async sign(event: Event, key: PrivateKey): Promise<SignedEvent> {
|
static async sign(event: Event, key: PrivateKey): Promise<SignedEvent> {
|
||||||
const id = await EventId.create(event)
|
const id = await EventId.create(event)
|
||||||
const sig = secp.utils
|
const sig = await schnorrSign(new Hex(id.toHex()), key)
|
||||||
.bytesToHex(await secp.schnorr.sign(id.toString(), key.hexDangerous()))
|
return new SignedEvent(event, id, new Hex(sig))
|
||||||
.toLowerCase()
|
|
||||||
return new SignedEvent(event, id, sig)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify the signature of a raw event. Throw a `ProtocolError` if the signature
|
* Verify the signature of a raw event. Throw a `ProtocolError` if the signature
|
||||||
* is invalid.
|
* is invalid.
|
||||||
*/
|
*/
|
||||||
static async verify(raw: RawEvent): Promise<SignedEvent> {
|
static async verify(raw: RawEvent, key?: PrivateKey): Promise<SignedEvent> {
|
||||||
const id = await EventId.create(raw)
|
const id = await EventId.create(raw)
|
||||||
if (id.toString() !== raw.id) {
|
if (id.toHex() !== raw.id) {
|
||||||
throw new ProtocolError(`invalid event id: ${raw.id}, expected ${id}`)
|
throw new ProtocolError(`invalid event id: ${raw.id}, expected ${id}`)
|
||||||
}
|
}
|
||||||
if (!(await secp.schnorr.verify(raw.sig, id.toString(), raw.pubkey))) {
|
const sig = new Hex(raw.sig)
|
||||||
throw new ProtocolError(`invalid signature: ${raw.sig}`)
|
if (
|
||||||
|
!(await schnorrVerify(
|
||||||
|
sig,
|
||||||
|
new Hex(id.toHex()),
|
||||||
|
new PublicKey(raw.pubkey)
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
throw new ProtocolError(`invalid signature: ${sig}`)
|
||||||
}
|
}
|
||||||
return new SignedEvent(parseEvent(raw), id, raw.sig)
|
return new SignedEvent(await parseEvent(raw, key), id, sig)
|
||||||
}
|
}
|
||||||
|
|
||||||
private constructor(event: Event, eventId: EventId, signature: string) {
|
private constructor(event: Event, eventId: EventId, signature: Hex) {
|
||||||
this.#event = cloneEvent(event)
|
this.#event = deepCopy(event)
|
||||||
this.#eventId = eventId
|
this.#eventId = eventId
|
||||||
this.#signature = signature
|
this.#signature = signature
|
||||||
}
|
}
|
||||||
@ -149,14 +176,14 @@ export class SignedEvent {
|
|||||||
* Event data.
|
* Event data.
|
||||||
*/
|
*/
|
||||||
get event(): Event {
|
get event(): Event {
|
||||||
return cloneEvent(this.#event)
|
return deepCopy(this.#event)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event signature.
|
* Event signature in hex format.
|
||||||
*/
|
*/
|
||||||
get signature(): string {
|
get signature(): string {
|
||||||
return this.#signature
|
return this.#signature.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -164,11 +191,11 @@ export class SignedEvent {
|
|||||||
*/
|
*/
|
||||||
serialize(): RawEvent {
|
serialize(): RawEvent {
|
||||||
const { event, eventId: id, signature } = this
|
const { event, eventId: id, signature } = this
|
||||||
const tags = serializeEventTags(event)
|
const tags = serializeTags(event)
|
||||||
const content = serializeEventContent(event)
|
const content = serializeContent(event)
|
||||||
return {
|
return {
|
||||||
id: id.toString(),
|
id: id.toHex(),
|
||||||
pubkey: event.pubkey.toString(),
|
pubkey: event.pubkey.toHex(),
|
||||||
created_at: unixTimestamp(event.createdAt),
|
created_at: unixTimestamp(event.createdAt),
|
||||||
kind: event.kind,
|
kind: event.kind,
|
||||||
tags,
|
tags,
|
||||||
@ -191,7 +218,10 @@ export interface RawEvent {
|
|||||||
/**
|
/**
|
||||||
* Parse an event from its raw format.
|
* Parse an event from its raw format.
|
||||||
*/
|
*/
|
||||||
function parseEvent(raw: RawEvent): Event {
|
async function parseEvent(
|
||||||
|
raw: RawEvent,
|
||||||
|
key: PrivateKey | undefined
|
||||||
|
): Promise<Event> {
|
||||||
const pubkey = new PublicKey(raw.pubkey)
|
const pubkey = new PublicKey(raw.pubkey)
|
||||||
const createdAt = new Date(raw.created_at * 1000)
|
const createdAt = new Date(raw.created_at * 1000)
|
||||||
const event = {
|
const event = {
|
||||||
@ -206,7 +236,9 @@ function parseEvent(raw: RawEvent): Event {
|
|||||||
typeof userMetadata["about"] !== "string" ||
|
typeof userMetadata["about"] !== "string" ||
|
||||||
typeof userMetadata["picture"] !== "string"
|
typeof userMetadata["picture"] !== "string"
|
||||||
) {
|
) {
|
||||||
throw new ProtocolError(`invalid user metadata: ${userMetadata}`)
|
throw new ProtocolError(
|
||||||
|
`invalid user metadata ${userMetadata} in ${JSON.stringify(raw)}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...event,
|
...event,
|
||||||
@ -223,18 +255,59 @@ function parseEvent(raw: RawEvent): Event {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (raw.kind === EventKind.DirectMessage) {
|
||||||
|
// Parse the tag identifying the recipient.
|
||||||
|
const recipientTag = raw.tags.find((tag) => tag[0] === "p")
|
||||||
|
if (typeof recipientTag?.[1] !== "string") {
|
||||||
|
throw new ProtocolError(
|
||||||
|
`expected "p" tag to be of type string, but got ${
|
||||||
|
recipientTag?.[1]
|
||||||
|
} in ${JSON.stringify(raw)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const recipient = new PublicKey(recipientTag[1])
|
||||||
|
|
||||||
|
// Parse the tag identifying the optional previous message.
|
||||||
|
const previousTag = raw.tags.find((tag) => tag[0] === "e")
|
||||||
|
if (typeof recipientTag[1] !== "string") {
|
||||||
|
throw new ProtocolError(
|
||||||
|
`expected "e" tag to be of type string, but got ${
|
||||||
|
previousTag?.[1]
|
||||||
|
} in ${JSON.stringify(raw)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const previous = new EventId(defined(previousTag?.[1]))
|
||||||
|
|
||||||
|
// Decrypt the message content.
|
||||||
|
const [data, iv] = raw.content.split("?iv=")
|
||||||
|
if (data === undefined || iv === undefined) {
|
||||||
|
throw new ProtocolError(`invalid direct message content ${raw.content}`)
|
||||||
|
}
|
||||||
|
let message: string | undefined
|
||||||
|
if (key?.pubkey?.toHex() === recipient.toHex()) {
|
||||||
|
message = await aesDecryptBase64(event.pubkey, key, { data, iv })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...event,
|
||||||
|
kind: EventKind.DirectMessage,
|
||||||
|
message,
|
||||||
|
recipient,
|
||||||
|
previous,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...event,
|
...event,
|
||||||
kind: raw.kind,
|
kind: raw.kind,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeEventTags(_event: Event): string[][] {
|
function serializeTags(_event: Event): string[][] {
|
||||||
// TODO As I add different event kinds, this will change
|
// TODO As I add different event kinds, this will change
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeEventContent(event: Event): string {
|
function serializeContent(event: Event): string {
|
||||||
if (event.kind === EventKind.SetMetadata) {
|
if (event.kind === EventKind.SetMetadata) {
|
||||||
return JSON.stringify(event.content)
|
return JSON.stringify(event.content)
|
||||||
} else if (event.kind === EventKind.TextNote) {
|
} else if (event.kind === EventKind.TextNote) {
|
||||||
@ -244,10 +317,13 @@ function serializeEventContent(event: Event): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloneEvent(event: Event): Event {
|
/**
|
||||||
|
* Create a deep copy of the event.
|
||||||
|
*/
|
||||||
|
function deepCopy(event: Event): Event {
|
||||||
const common = {
|
const common = {
|
||||||
createdAt: structuredClone(event.createdAt),
|
createdAt: structuredClone(event.createdAt),
|
||||||
pubkey: new PublicKey(event.pubkey.toString()),
|
pubkey: event.pubkey,
|
||||||
}
|
}
|
||||||
if (event.kind === EventKind.SetMetadata) {
|
if (event.kind === EventKind.SetMetadata) {
|
||||||
return {
|
return {
|
||||||
@ -265,6 +341,8 @@ function cloneEvent(event: Event): Event {
|
|||||||
content: event.content,
|
content: event.content,
|
||||||
...common,
|
...common,
|
||||||
}
|
}
|
||||||
|
} else if (event.kind === EventKind.DirectMessage) {
|
||||||
|
throw new Error("todo")
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
kind: event.kind,
|
kind: event.kind,
|
||||||
|
@ -1,79 +0,0 @@
|
|||||||
import { bech32 } from "bech32"
|
|
||||||
import { ProtocolError } from "./error"
|
|
||||||
import * as secp from "@noble/secp256k1"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A 32-byte secp256k1 public key.
|
|
||||||
*/
|
|
||||||
export class PublicKey {
|
|
||||||
#hex: string
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expects the key encoded as an npub-prefixed bech32 string, hex string, or byte buffer.
|
|
||||||
*/
|
|
||||||
constructor(key: string | Uint8Array) {
|
|
||||||
this.#hex = parseKey(key, "npub1")
|
|
||||||
if (this.#hex.length !== 64) {
|
|
||||||
throw new ProtocolError(`invalid pubkey: ${key}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toString(): string {
|
|
||||||
return this.#hex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A 32-byte secp256k1 private key.
|
|
||||||
*/
|
|
||||||
export class PrivateKey {
|
|
||||||
#hex: string
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expects the key encoded as an nsec-prefixed bech32 string, hex string, or byte buffer.
|
|
||||||
*/
|
|
||||||
constructor(key: string | Uint8Array) {
|
|
||||||
this.#hex = parseKey(key, "nsec1")
|
|
||||||
if (this.#hex.length !== 64) {
|
|
||||||
throw new ProtocolError(`invalid private key: ${this.#hex}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get pubkey(): PublicKey {
|
|
||||||
return new PublicKey(secp.schnorr.getPublicKey(this.#hex))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The hex representation of the private key. Use with caution!
|
|
||||||
*/
|
|
||||||
hexDangerous(): string {
|
|
||||||
return this.#hex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a key into its hex representation.
|
|
||||||
*/
|
|
||||||
function parseKey(key: string | Uint8Array, bechPrefix: string): string {
|
|
||||||
if (typeof key === "string") {
|
|
||||||
// Is the key encoded in bech32?
|
|
||||||
if (key.startsWith(bechPrefix)) {
|
|
||||||
const { words } = bech32.decode(key)
|
|
||||||
const bytes = Uint8Array.from(bech32.fromWords(words))
|
|
||||||
return secp.utils.bytesToHex(bytes).toLowerCase()
|
|
||||||
}
|
|
||||||
// If not, it must be lowercase hex.
|
|
||||||
const valid = "0123456789abcdef"
|
|
||||||
if (key.length % 2 != 0) {
|
|
||||||
throw new ProtocolError(`invalid lowercase hex string: ${key}`)
|
|
||||||
}
|
|
||||||
for (const c of key) {
|
|
||||||
if (!valid.includes(c)) {
|
|
||||||
throw new ProtocolError(`invalid lowercase hex string: ${key}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return key
|
|
||||||
} else {
|
|
||||||
return secp.utils.bytesToHex(key).toLowerCase()
|
|
||||||
}
|
|
||||||
}
|
|
@ -11,7 +11,10 @@ import Nips from "./Nips";
|
|||||||
import { unwrap } from "./Util";
|
import { unwrap } from "./Util";
|
||||||
|
|
||||||
export type CustomHook = (state: Readonly<StateSnapshot>) => void;
|
export type CustomHook = (state: Readonly<StateSnapshot>) => void;
|
||||||
export type AuthHandler = (challenge: string, relay: string) => Promise<NEvent | undefined>;
|
export type AuthHandler = (
|
||||||
|
challenge: string,
|
||||||
|
relay: string
|
||||||
|
) => Promise<NEvent | undefined>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Relay settings
|
* Relay settings
|
||||||
@ -57,7 +60,7 @@ export class Connection {
|
|||||||
AwaitingAuth: Map<string, boolean>;
|
AwaitingAuth: Map<string, boolean>;
|
||||||
Authed: boolean;
|
Authed: boolean;
|
||||||
|
|
||||||
constructor(addr: string, options: RelaySettings, auth: AuthHandler = undefined) {
|
constructor(addr: string, options: RelaySettings, auth?: AuthHandler) {
|
||||||
this.Id = uuid();
|
this.Id = uuid();
|
||||||
this.Address = addr;
|
this.Address = addr;
|
||||||
this.Socket = null;
|
this.Socket = null;
|
||||||
@ -387,7 +390,7 @@ export class Connection {
|
|||||||
const authCleanup = () => {
|
const authCleanup = () => {
|
||||||
this.AwaitingAuth.delete(challenge);
|
this.AwaitingAuth.delete(challenge);
|
||||||
};
|
};
|
||||||
if(!this.Auth) {
|
if (!this.Auth) {
|
||||||
throw new Error("Auth hook not registered");
|
throw new Error("Auth hook not registered");
|
||||||
}
|
}
|
||||||
this.AwaitingAuth.set(challenge, true);
|
this.AwaitingAuth.set(challenge, true);
|
||||||
|
@ -1,6 +1,18 @@
|
|||||||
|
import { ProtocolError } from "./error"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate the unix timestamp (seconds since epoch) of the `Date`.
|
* Calculate the unix timestamp (seconds since epoch) of the `Date`.
|
||||||
*/
|
*/
|
||||||
export function unixTimestamp(date: Date): number {
|
export function unixTimestamp(date: Date): number {
|
||||||
return Math.floor(date.getTime() / 1000)
|
return Math.floor(date.getTime() / 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throw if the parameter is null or undefined. Return the parameter otherwise.
|
||||||
|
*/
|
||||||
|
export function defined<T>(v: T | undefined | null): T {
|
||||||
|
if (v === undefined || v === null) {
|
||||||
|
throw new ProtocolError("bug: unexpected undefined")
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Nostr } from "../src/client"
|
import { Nostr } from "../src/client"
|
||||||
import { EventKind, SignedEvent } from "../src/event"
|
import { EventKind, SignedEvent } from "../src/event"
|
||||||
import { PrivateKey } from "../src/keypair"
|
import { PrivateKey } from "../src/crypto"
|
||||||
import assert from "assert"
|
import assert from "assert"
|
||||||
import { EventParams } from "../src/client/emitter"
|
import { EventParams } from "../src/client/emitter"
|
||||||
|
|
||||||
@ -33,7 +33,7 @@ describe("simple communication", function () {
|
|||||||
function listener({ signed: { event } }: EventParams, nostr: Nostr) {
|
function listener({ signed: { event } }: EventParams, nostr: Nostr) {
|
||||||
assert.equal(nostr, subscriber)
|
assert.equal(nostr, subscriber)
|
||||||
assert.equal(event.kind, EventKind.TextNote)
|
assert.equal(event.kind, EventKind.TextNote)
|
||||||
assert.equal(event.pubkey.toString(), pubkey.toString())
|
assert.equal(event.pubkey.toHex(), pubkey.toHex())
|
||||||
assert.equal(event.createdAt.toString(), timestamp.toString())
|
assert.equal(event.createdAt.toString(), timestamp.toString())
|
||||||
if (event.kind === EventKind.TextNote) {
|
if (event.kind === EventKind.TextNote) {
|
||||||
assert.equal(event.content, note)
|
assert.equal(event.content, note)
|
||||||
@ -73,7 +73,7 @@ describe("simple communication", function () {
|
|||||||
).then((event) => {
|
).then((event) => {
|
||||||
publisher.on("ok", (params, nostr) => {
|
publisher.on("ok", (params, nostr) => {
|
||||||
assert.equal(nostr, publisher)
|
assert.equal(nostr, publisher)
|
||||||
assert.equal(params.eventId.toString(), event.eventId.toString())
|
assert.equal(params.eventId.toHex(), event.eventId.toHex())
|
||||||
assert.equal(params.relay.toString(), url.toString())
|
assert.equal(params.relay.toString(), url.toString())
|
||||||
assert.equal(params.ok, true)
|
assert.equal(params.ok, true)
|
||||||
done()
|
done()
|
||||||
|
@ -9,7 +9,9 @@
|
|||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noImplicitOverride": true
|
"noImplicitOverride": true,
|
||||||
|
"module": "CommonJS",
|
||||||
|
"strict": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
@ -3143,7 +3143,7 @@ base32-decode@^1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/base32-decode/-/base32-decode-1.0.0.tgz#2a821d6a664890c872f20aa9aca95a4b4b80e2a7"
|
resolved "https://registry.yarnpkg.com/base32-decode/-/base32-decode-1.0.0.tgz#2a821d6a664890c872f20aa9aca95a4b4b80e2a7"
|
||||||
integrity sha512-KNWUX/R7wKenwE/G/qFMzGScOgVntOmbE27vvc6GrniDGYb6a5+qWcuoXl8WIOQL7q0TpK7nZDm1Y04Yi3Yn5g==
|
integrity sha512-KNWUX/R7wKenwE/G/qFMzGScOgVntOmbE27vvc6GrniDGYb6a5+qWcuoXl8WIOQL7q0TpK7nZDm1Y04Yi3Yn5g==
|
||||||
|
|
||||||
base64-js@^1.3.1:
|
base64-js@^1.3.1, base64-js@^1.5.1:
|
||||||
version "1.5.1"
|
version "1.5.1"
|
||||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
||||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||||
|
Loading…
x
Reference in New Issue
Block a user