add direct message event and refactor
This commit is contained in:
parent
0d4394e1e6
commit
a7707af756
@ -309,4 +309,4 @@
|
||||
"zjJZBd": "You're ready!",
|
||||
"zonsdq": "Failed to load LNURL service",
|
||||
"zvCDao": "Automatically show latest notes"
|
||||
}
|
||||
}
|
||||
|
@ -26,6 +26,7 @@
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.2.0",
|
||||
"@noble/secp256k1": "^1.7.1",
|
||||
"base64-js": "^1.5.1",
|
||||
"bech32": "^2.0.0",
|
||||
"events": "^3.3.0",
|
||||
"isomorphic-ws": "^5.0.0",
|
||||
|
@ -120,7 +120,6 @@ export class Conn {
|
||||
return {
|
||||
kind: "event",
|
||||
subscriptionId: new SubscriptionId(json[1]),
|
||||
signed: await SignedEvent.verify(raw),
|
||||
raw,
|
||||
}
|
||||
}
|
||||
@ -175,7 +174,6 @@ export type IncomingKind = "event" | "notice" | "ok"
|
||||
export interface IncomingEvent {
|
||||
kind: "event"
|
||||
subscriptionId: SubscriptionId
|
||||
signed: SignedEvent
|
||||
raw: RawEvent
|
||||
}
|
||||
|
||||
@ -266,18 +264,18 @@ function serializeFilters(filters: Filters[]): RawFilters[] {
|
||||
return [{}]
|
||||
}
|
||||
return filters.map((filter) => ({
|
||||
ids: filter.ids?.map((id) => id.toString()),
|
||||
authors: filter.authors?.map((author) => author.toString()),
|
||||
ids: filter.ids?.map((id) => id.toHex()),
|
||||
authors: filter.authors?.map((author) => author),
|
||||
kinds: filter.kinds?.map((kind) => kind),
|
||||
["#e"]: filter.eventTags?.map((e) => e.toString()),
|
||||
["#p"]: filter.pubkeyTags?.map((p) => p.toString()),
|
||||
["#e"]: filter.eventTags?.map((e) => e.toHex()),
|
||||
["#p"]: filter.pubkeyTags?.map((p) => p.toHex()),
|
||||
since: filter.since !== undefined ? unixTimestamp(filter.since) : undefined,
|
||||
until: filter.until !== undefined ? unixTimestamp(filter.until) : undefined,
|
||||
limit: filter.limit,
|
||||
}))
|
||||
}
|
||||
|
||||
function parseEventData(json: object): RawEvent {
|
||||
function parseEventData(json: { [key: string]: unknown }): RawEvent {
|
||||
if (
|
||||
typeof json["id"] !== "string" ||
|
||||
typeof json["pubkey"] !== "string" ||
|
||||
@ -292,7 +290,7 @@ function parseEventData(json: object): RawEvent {
|
||||
) {
|
||||
throw new ProtocolError(`invalid event: ${JSON.stringify(json)}`)
|
||||
}
|
||||
return json as RawEvent
|
||||
return json as unknown as RawEvent
|
||||
}
|
||||
|
||||
function parseJson(data: string) {
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { ProtocolError } from "../error"
|
||||
import { EventId, Event, EventKind, SignedEvent, RawEvent } from "../event"
|
||||
import { PrivateKey, PublicKey } from "../keypair"
|
||||
import { PrivateKey, PublicKey } from "../crypto"
|
||||
import { Conn } from "./conn"
|
||||
import * as secp from "@noble/secp256k1"
|
||||
import { EventEmitter } from "./emitter"
|
||||
import { defined } from "../util"
|
||||
|
||||
/**
|
||||
* A nostr client.
|
||||
@ -22,6 +23,16 @@ export class Nostr extends EventEmitter {
|
||||
*/
|
||||
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
|
||||
* 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({
|
||||
url: connUrl,
|
||||
// Handle messages on this connection.
|
||||
onMessage: (msg) => {
|
||||
onMessage: async (msg) => {
|
||||
try {
|
||||
if (msg.kind === "event") {
|
||||
this.emit(
|
||||
"event",
|
||||
{
|
||||
signed: msg.signed,
|
||||
signed: await SignedEvent.verify(msg.raw, this.#key),
|
||||
subscriptionId: msg.subscriptionId,
|
||||
raw: msg.raw,
|
||||
},
|
||||
@ -208,7 +219,7 @@ export class Nostr extends EventEmitter {
|
||||
"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")
|
||||
}
|
||||
}
|
||||
@ -218,7 +229,7 @@ export class Nostr extends EventEmitter {
|
||||
continue
|
||||
}
|
||||
if (!(event instanceof SignedEvent) && !("sig" in event)) {
|
||||
event = await SignedEvent.sign(event, key)
|
||||
event = await SignedEvent.sign(event, defined(key))
|
||||
}
|
||||
conn.send({
|
||||
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.
|
||||
*/
|
||||
export interface Filters {
|
||||
// TODO Document the filters, document that for the arrays only one is enough for the message to pass
|
||||
ids?: Prefix<EventId>[]
|
||||
authors?: Prefix<string>[]
|
||||
ids?: EventId[]
|
||||
authors?: string[]
|
||||
kinds?: EventKind[]
|
||||
/**
|
||||
* 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
|
||||
* unexpected data, or otherwise behaves in an unexpected way.
|
||||
|
@ -1,7 +1,14 @@
|
||||
import { ProtocolError } from "./error"
|
||||
import * as secp from "@noble/secp256k1"
|
||||
import { PublicKey, PrivateKey } from "./keypair"
|
||||
import { unixTimestamp } from "./util"
|
||||
import {
|
||||
PublicKey,
|
||||
PrivateKey,
|
||||
sha256,
|
||||
Hex,
|
||||
schnorrSign,
|
||||
schnorrVerify,
|
||||
aesDecryptBase64,
|
||||
} from "./crypto"
|
||||
import { defined, unixTimestamp } from "./util"
|
||||
|
||||
// TODO This file is missing proper documentation
|
||||
// TODO Add remaining event types
|
||||
@ -24,13 +31,19 @@ export enum EventKind {
|
||||
ZapReceipt = 9735, // NIP 57
|
||||
}
|
||||
|
||||
export type Event = SetMetadataEvent | TextNoteEvent | UnknownEvent
|
||||
export type Event =
|
||||
| SetMetadataEvent
|
||||
| TextNoteEvent
|
||||
| DirectMessageEvent
|
||||
| UnknownEvent
|
||||
|
||||
interface EventCommon {
|
||||
pubkey: PublicKey
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
// TODO Refactor: the event names don't need to all end with *Event
|
||||
|
||||
export interface SetMetadataEvent extends EventCommon {
|
||||
kind: EventKind.SetMetadata
|
||||
content: UserMetadata
|
||||
@ -47,13 +60,23 @@ export interface TextNoteEvent extends EventCommon {
|
||||
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 {
|
||||
kind: Exclude<EventKind, EventKind.SetMetadata | EventKind.TextNote>
|
||||
}
|
||||
|
||||
// TODO Doc comment
|
||||
export class EventId {
|
||||
#hex: string
|
||||
#hex: Hex
|
||||
|
||||
static async create(event: Event | RawEvent): Promise<EventId> {
|
||||
// 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(",")}]`)
|
||||
.join(",")}]`
|
||||
const serialized = `[0,"${event.pubkey}",${event.created_at},${event.kind},${serializedTags},"${event.content}"]`
|
||||
const hash = await secp.utils.sha256(
|
||||
Uint8Array.from(charCodes(serialized))
|
||||
)
|
||||
return new EventId(secp.utils.bytesToHex(hash).toLowerCase())
|
||||
const hash = await sha256(Uint8Array.from(charCodes(serialized)))
|
||||
return new EventId(hash)
|
||||
} else {
|
||||
// Not a raw event.
|
||||
const tags = serializeEventTags(event)
|
||||
const content = serializeEventContent(event)
|
||||
const tags = serializeTags(event)
|
||||
const content = serializeContent(event)
|
||||
const serializedTags = `[${tags
|
||||
.map((tag) => `[${tag.map((v) => `"${v}"`).join(",")}]`)
|
||||
.join(",")}]`
|
||||
const serialized = `[0,"${event.pubkey}",${unixTimestamp(
|
||||
event.createdAt
|
||||
)},${event.kind},${serializedTags},"${content}"]`
|
||||
const hash = await secp.utils.sha256(
|
||||
Uint8Array.from(charCodes(serialized))
|
||||
)
|
||||
return new EventId(secp.utils.bytesToHex(hash).toLowerCase())
|
||||
const hash = await sha256(Uint8Array.from(charCodes(serialized)))
|
||||
return new EventId(hash)
|
||||
}
|
||||
}
|
||||
|
||||
constructor(hex: string) {
|
||||
// TODO Validate that this is 32-byte hex
|
||||
this.#hex = hex
|
||||
constructor(hex: string | Uint8Array) {
|
||||
this.#hex = new Hex(hex)
|
||||
}
|
||||
|
||||
toHex(): string {
|
||||
return this.#hex.toString()
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.#hex
|
||||
return this.toHex()
|
||||
}
|
||||
}
|
||||
|
||||
@ -103,7 +125,7 @@ export class EventId {
|
||||
export class SignedEvent {
|
||||
#event: Readonly<Event>
|
||||
#eventId: EventId
|
||||
#signature: string
|
||||
#signature: Hex
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
const id = await EventId.create(event)
|
||||
const sig = secp.utils
|
||||
.bytesToHex(await secp.schnorr.sign(id.toString(), key.hexDangerous()))
|
||||
.toLowerCase()
|
||||
return new SignedEvent(event, id, sig)
|
||||
const sig = await schnorrSign(new Hex(id.toHex()), key)
|
||||
return new SignedEvent(event, id, new Hex(sig))
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the signature of a raw event. Throw a `ProtocolError` if the signature
|
||||
* is invalid.
|
||||
*/
|
||||
static async verify(raw: RawEvent): Promise<SignedEvent> {
|
||||
static async verify(raw: RawEvent, key?: PrivateKey): Promise<SignedEvent> {
|
||||
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}`)
|
||||
}
|
||||
if (!(await secp.schnorr.verify(raw.sig, id.toString(), raw.pubkey))) {
|
||||
throw new ProtocolError(`invalid signature: ${raw.sig}`)
|
||||
const sig = new Hex(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) {
|
||||
this.#event = cloneEvent(event)
|
||||
private constructor(event: Event, eventId: EventId, signature: Hex) {
|
||||
this.#event = deepCopy(event)
|
||||
this.#eventId = eventId
|
||||
this.#signature = signature
|
||||
}
|
||||
@ -149,14 +176,14 @@ export class SignedEvent {
|
||||
* Event data.
|
||||
*/
|
||||
get event(): Event {
|
||||
return cloneEvent(this.#event)
|
||||
return deepCopy(this.#event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Event signature.
|
||||
* Event signature in hex format.
|
||||
*/
|
||||
get signature(): string {
|
||||
return this.#signature
|
||||
return this.#signature.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -164,11 +191,11 @@ export class SignedEvent {
|
||||
*/
|
||||
serialize(): RawEvent {
|
||||
const { event, eventId: id, signature } = this
|
||||
const tags = serializeEventTags(event)
|
||||
const content = serializeEventContent(event)
|
||||
const tags = serializeTags(event)
|
||||
const content = serializeContent(event)
|
||||
return {
|
||||
id: id.toString(),
|
||||
pubkey: event.pubkey.toString(),
|
||||
id: id.toHex(),
|
||||
pubkey: event.pubkey.toHex(),
|
||||
created_at: unixTimestamp(event.createdAt),
|
||||
kind: event.kind,
|
||||
tags,
|
||||
@ -191,7 +218,10 @@ export interface RawEvent {
|
||||
/**
|
||||
* 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 createdAt = new Date(raw.created_at * 1000)
|
||||
const event = {
|
||||
@ -206,7 +236,9 @@ function parseEvent(raw: RawEvent): Event {
|
||||
typeof userMetadata["about"] !== "string" ||
|
||||
typeof userMetadata["picture"] !== "string"
|
||||
) {
|
||||
throw new ProtocolError(`invalid user metadata: ${userMetadata}`)
|
||||
throw new ProtocolError(
|
||||
`invalid user metadata ${userMetadata} in ${JSON.stringify(raw)}`
|
||||
)
|
||||
}
|
||||
return {
|
||||
...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 {
|
||||
...event,
|
||||
kind: raw.kind,
|
||||
}
|
||||
}
|
||||
|
||||
function serializeEventTags(_event: Event): string[][] {
|
||||
function serializeTags(_event: Event): string[][] {
|
||||
// TODO As I add different event kinds, this will change
|
||||
return []
|
||||
}
|
||||
|
||||
function serializeEventContent(event: Event): string {
|
||||
function serializeContent(event: Event): string {
|
||||
if (event.kind === EventKind.SetMetadata) {
|
||||
return JSON.stringify(event.content)
|
||||
} 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 = {
|
||||
createdAt: structuredClone(event.createdAt),
|
||||
pubkey: new PublicKey(event.pubkey.toString()),
|
||||
pubkey: event.pubkey,
|
||||
}
|
||||
if (event.kind === EventKind.SetMetadata) {
|
||||
return {
|
||||
@ -265,6 +341,8 @@ function cloneEvent(event: Event): Event {
|
||||
content: event.content,
|
||||
...common,
|
||||
}
|
||||
} else if (event.kind === EventKind.DirectMessage) {
|
||||
throw new Error("todo")
|
||||
} else {
|
||||
return {
|
||||
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";
|
||||
|
||||
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
|
||||
@ -57,7 +60,7 @@ export class Connection {
|
||||
AwaitingAuth: Map<string, boolean>;
|
||||
Authed: boolean;
|
||||
|
||||
constructor(addr: string, options: RelaySettings, auth: AuthHandler = undefined) {
|
||||
constructor(addr: string, options: RelaySettings, auth?: AuthHandler) {
|
||||
this.Id = uuid();
|
||||
this.Address = addr;
|
||||
this.Socket = null;
|
||||
@ -387,7 +390,7 @@ export class Connection {
|
||||
const authCleanup = () => {
|
||||
this.AwaitingAuth.delete(challenge);
|
||||
};
|
||||
if(!this.Auth) {
|
||||
if (!this.Auth) {
|
||||
throw new Error("Auth hook not registered");
|
||||
}
|
||||
this.AwaitingAuth.set(challenge, true);
|
||||
|
@ -1,6 +1,18 @@
|
||||
import { ProtocolError } from "./error"
|
||||
|
||||
/**
|
||||
* Calculate the unix timestamp (seconds since epoch) of the `Date`.
|
||||
*/
|
||||
export function unixTimestamp(date: Date): number {
|
||||
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 { EventKind, SignedEvent } from "../src/event"
|
||||
import { PrivateKey } from "../src/keypair"
|
||||
import { PrivateKey } from "../src/crypto"
|
||||
import assert from "assert"
|
||||
import { EventParams } from "../src/client/emitter"
|
||||
|
||||
@ -33,7 +33,7 @@ describe("simple communication", function () {
|
||||
function listener({ signed: { event } }: EventParams, nostr: Nostr) {
|
||||
assert.equal(nostr, subscriber)
|
||||
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())
|
||||
if (event.kind === EventKind.TextNote) {
|
||||
assert.equal(event.content, note)
|
||||
@ -73,7 +73,7 @@ describe("simple communication", function () {
|
||||
).then((event) => {
|
||||
publisher.on("ok", (params, nostr) => {
|
||||
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.ok, true)
|
||||
done()
|
||||
|
@ -9,7 +9,9 @@
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noImplicitOverride": true
|
||||
"noImplicitOverride": true,
|
||||
"module": "CommonJS",
|
||||
"strict": true
|
||||
},
|
||||
"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"
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
Loading…
x
Reference in New Issue
Block a user