376 lines
9.9 KiB
TypeScript
376 lines
9.9 KiB
TypeScript
import * as secp from "@noble/curves/secp256k1";
|
|
import * as utils from "@noble/curves/abstract/utils";
|
|
import { unwrap } from "@snort/shared";
|
|
|
|
import {
|
|
decodeEncryptionPayload,
|
|
EventKind,
|
|
EventSigner,
|
|
FullRelaySettings,
|
|
HexKey,
|
|
MessageEncryptorVersion,
|
|
NostrEvent,
|
|
NostrLink,
|
|
NotSignedNostrEvent,
|
|
PowMiner,
|
|
PrivateKeySigner,
|
|
RelaySettings,
|
|
settingsToRelayTag,
|
|
SignerSupports,
|
|
TaggedNostrEvent,
|
|
ToNostrEventTag,
|
|
u256,
|
|
UserMetadata,
|
|
} from ".";
|
|
|
|
import { EventBuilder } from "./event-builder";
|
|
import { findTag } from "./utils";
|
|
import { Nip7Signer } from "./impl/nip7";
|
|
import { base64 } from "@scure/base";
|
|
import { Nip10 } from "./impl/nip10";
|
|
|
|
type EventBuilderHook = (ev: EventBuilder) => EventBuilder;
|
|
|
|
export class EventPublisher {
|
|
#pubKey: string;
|
|
#signer: EventSigner;
|
|
#pow?: number;
|
|
#miner?: PowMiner;
|
|
|
|
constructor(signer: EventSigner, pubKey: string) {
|
|
this.#signer = signer;
|
|
this.#pubKey = pubKey;
|
|
}
|
|
|
|
get signer() {
|
|
return this.#signer;
|
|
}
|
|
|
|
/**
|
|
* Create a NIP-07 EventPublisher
|
|
*/
|
|
static async nip7() {
|
|
if ("nostr" in window) {
|
|
const signer = new Nip7Signer();
|
|
const pubkey = await signer.getPubKey();
|
|
if (pubkey) {
|
|
return new EventPublisher(signer, pubkey);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create an EventPublisher for a private key
|
|
*/
|
|
static privateKey(privateKey: string) {
|
|
const signer = new PrivateKeySigner(privateKey);
|
|
return new EventPublisher(signer, signer.getPubKey());
|
|
}
|
|
|
|
supports(t: SignerSupports) {
|
|
return this.#signer.supports.includes(t);
|
|
}
|
|
|
|
get pubKey() {
|
|
return this.#pubKey;
|
|
}
|
|
|
|
/**
|
|
* Create a copy of this publisher with PoW
|
|
*/
|
|
pow(target: number, miner?: PowMiner) {
|
|
const ret = new EventPublisher(this.#signer, this.#pubKey);
|
|
ret.#pow = target;
|
|
ret.#miner = miner;
|
|
return ret;
|
|
}
|
|
|
|
#eb(k: EventKind) {
|
|
const eb = new EventBuilder();
|
|
return eb.pubKey(this.#pubKey).kind(k);
|
|
}
|
|
|
|
async #sign(eb: EventBuilder) {
|
|
return await (this.#pow ? eb.pow(this.#pow, this.#miner) : eb).buildAndSign(this.#signer);
|
|
}
|
|
|
|
async nip4Encrypt(content: string, otherKey: string) {
|
|
return await this.#signer.nip4Encrypt(content, otherKey);
|
|
}
|
|
|
|
async nip4Decrypt(content: string, otherKey: string) {
|
|
return await this.#signer.nip4Decrypt(content, otherKey);
|
|
}
|
|
|
|
async nip42Auth(challenge: string, relay: string) {
|
|
const eb = this.#eb(EventKind.Auth);
|
|
eb.tag(["relay", relay]);
|
|
eb.tag(["challenge", challenge]);
|
|
return await this.#sign(eb);
|
|
}
|
|
|
|
/**
|
|
* Build a mute list event using lists of pubkeys
|
|
* @param pub Public mute list
|
|
* @param priv Private mute list
|
|
*/
|
|
async muted(pub: Array<string>, priv: Array<string>) {
|
|
const eb = this.#eb(EventKind.MuteList);
|
|
pub.forEach(p => {
|
|
eb.tag(["p", p]);
|
|
});
|
|
if (priv.length > 0) {
|
|
const ps = priv.map(p => ["p", p]);
|
|
const plaintext = JSON.stringify(ps);
|
|
eb.content(await this.nip4Encrypt(plaintext, this.#pubKey));
|
|
}
|
|
return await this.#sign(eb);
|
|
}
|
|
|
|
/**
|
|
* Build a pin list event using lists of event links
|
|
*/
|
|
async pinned(notes: Array<ToNostrEventTag>) {
|
|
const eb = this.#eb(EventKind.PinList);
|
|
notes.forEach(n => {
|
|
eb.tag(unwrap(n.toEventTag()));
|
|
});
|
|
return await this.#sign(eb);
|
|
}
|
|
|
|
/**
|
|
* Build a categorized bookmarks event with a given label
|
|
* @param notes List of bookmarked links
|
|
*/
|
|
async bookmarks(notes: Array<ToNostrEventTag>) {
|
|
const eb = this.#eb(EventKind.BookmarksList);
|
|
notes.forEach(n => {
|
|
eb.tag(unwrap(n.toEventTag()));
|
|
});
|
|
return await this.#sign(eb);
|
|
}
|
|
|
|
async metadata(obj: UserMetadata) {
|
|
const eb = this.#eb(EventKind.SetMetadata);
|
|
eb.content(JSON.stringify(obj));
|
|
return await this.#sign(eb);
|
|
}
|
|
|
|
/**
|
|
* Create a basic text note
|
|
*/
|
|
async note(msg: string, fnExtra?: EventBuilderHook) {
|
|
const eb = this.#eb(EventKind.TextNote);
|
|
eb.content(msg);
|
|
eb.processContent();
|
|
fnExtra?.(eb);
|
|
return await this.#sign(eb);
|
|
}
|
|
|
|
/**
|
|
* Create a zap request event for a given target event/profile
|
|
* @param amount Millisats amout!
|
|
* @param author Author pubkey to tag in the zap
|
|
* @param note Note Id to tag in the zap
|
|
* @param msg Custom message to be included in the zap
|
|
*/
|
|
async zap(
|
|
amount: number,
|
|
author: HexKey,
|
|
relays: Array<string>,
|
|
note?: NostrLink,
|
|
msg?: string,
|
|
fnExtra?: EventBuilderHook,
|
|
) {
|
|
const eb = this.#eb(EventKind.ZapRequest);
|
|
eb.content(msg?.trim() ?? "");
|
|
if (note) {
|
|
// HACK: remove relay tag, some zap services dont like relay tags
|
|
eb.tag(unwrap(note.toEventTag()).slice(0, 2));
|
|
}
|
|
eb.tag(["p", author]);
|
|
eb.tag(["relays", ...relays.map(a => a.trim())]);
|
|
eb.tag(["amount", amount.toString()]);
|
|
eb.processContent();
|
|
fnExtra?.(eb);
|
|
return await this.#sign(eb);
|
|
}
|
|
|
|
/**
|
|
* Reply to a note
|
|
*/
|
|
async reply(replyTo: TaggedNostrEvent, msg: string, fnExtra?: EventBuilderHook) {
|
|
const eb = this.#eb(EventKind.TextNote);
|
|
eb.content(msg);
|
|
|
|
Nip10.replyTo(replyTo, eb);
|
|
eb.processContent();
|
|
fnExtra?.(eb);
|
|
return await this.#sign(eb);
|
|
}
|
|
|
|
async react(evRef: NostrEvent, content = "+") {
|
|
const eb = this.#eb(EventKind.Reaction);
|
|
eb.content(content);
|
|
eb.tag(unwrap(NostrLink.fromEvent(evRef).toEventTag()));
|
|
eb.tag(["p", evRef.pubkey]);
|
|
return await this.#sign(eb);
|
|
}
|
|
|
|
async relayList(relays: Array<FullRelaySettings> | Record<string, RelaySettings>) {
|
|
if (!Array.isArray(relays)) {
|
|
relays = Object.entries(relays).map(([k, v]) => ({
|
|
url: k,
|
|
settings: v,
|
|
}));
|
|
}
|
|
const eb = this.#eb(EventKind.Relays);
|
|
for (const rx of relays) {
|
|
const tag = settingsToRelayTag(rx);
|
|
if (tag) {
|
|
eb.tag(tag);
|
|
}
|
|
}
|
|
return await this.#sign(eb);
|
|
}
|
|
|
|
async contactList(tags: Array<[string, string]>, relays?: Record<string, RelaySettings>) {
|
|
const eb = this.#eb(EventKind.ContactList);
|
|
tags.forEach(a => eb.tag(a));
|
|
if (relays) {
|
|
eb.content(JSON.stringify(relays));
|
|
}
|
|
return await this.#sign(eb);
|
|
}
|
|
|
|
/**
|
|
* Delete an event (NIP-09)
|
|
*/
|
|
async delete(id: u256) {
|
|
const eb = this.#eb(EventKind.Deletion);
|
|
eb.tag(["e", id]);
|
|
return await this.#sign(eb);
|
|
}
|
|
|
|
/**
|
|
* Repost a note (NIP-18)
|
|
*/
|
|
async repost(note: NostrEvent) {
|
|
const eb = this.#eb(EventKind.Repost);
|
|
eb.tag(unwrap(NostrLink.fromEvent(note).toEventTag()));
|
|
eb.tag(["p", note.pubkey]);
|
|
return await this.#sign(eb);
|
|
}
|
|
|
|
/**
|
|
* Generic decryption using NIP-23 payload scheme
|
|
*/
|
|
async decryptGeneric(content: string, from: string) {
|
|
const pl = decodeEncryptionPayload(content);
|
|
switch (pl.v) {
|
|
case MessageEncryptorVersion.Nip4: {
|
|
const nip4Payload = `${base64.encode(pl.ciphertext)}?iv=${base64.encode(pl.nonce)}`;
|
|
return await this.#signer.nip4Decrypt(nip4Payload, from);
|
|
}
|
|
case MessageEncryptorVersion.XChaCha20: {
|
|
return await this.#signer.nip44Decrypt(content, from);
|
|
}
|
|
}
|
|
throw new Error("Not supported version");
|
|
}
|
|
|
|
async decryptDm(note: NostrEvent) {
|
|
if (note.kind === EventKind.SealedRumor) {
|
|
const unseal = await this.unsealRumor(note);
|
|
return unseal.content;
|
|
}
|
|
if (
|
|
note.kind === EventKind.DirectMessage &&
|
|
note.pubkey !== this.#pubKey &&
|
|
!note.tags.some(a => a[1] === this.#pubKey)
|
|
) {
|
|
throw new Error("Can't decrypt, DM does not belong to this user");
|
|
}
|
|
const otherPubKey = note.pubkey === this.#pubKey ? unwrap(note.tags.find(a => a[0] === "p")?.[1]) : note.pubkey;
|
|
return await this.nip4Decrypt(note.content, otherPubKey);
|
|
}
|
|
|
|
async sendDm(content: string, to: HexKey) {
|
|
const eb = this.#eb(EventKind.DirectMessage);
|
|
eb.content(await this.nip4Encrypt(content, to));
|
|
eb.tag(["p", to]);
|
|
return await this.#sign(eb);
|
|
}
|
|
|
|
async generic(fnHook: EventBuilderHook) {
|
|
const eb = new EventBuilder();
|
|
eb.pubKey(this.#pubKey);
|
|
fnHook(eb);
|
|
return await this.#sign(eb);
|
|
}
|
|
|
|
async appData(data: object, id: string) {
|
|
const eb = this.#eb(EventKind.AppData);
|
|
eb.content(await this.nip4Encrypt(JSON.stringify(data), this.#pubKey));
|
|
eb.tag(["d", id]);
|
|
return await this.#sign(eb);
|
|
}
|
|
|
|
/**
|
|
* NIP-59 Gift Wrap event with ephemeral key
|
|
*/
|
|
async giftWrap(inner: NostrEvent, explicitP?: string, powTarget?: number, powMiner?: PowMiner) {
|
|
const secret = utils.bytesToHex(secp.secp256k1.utils.randomPrivateKey());
|
|
const signer = new PrivateKeySigner(secret);
|
|
|
|
const pTag = explicitP ?? findTag(inner, "p");
|
|
if (!pTag) throw new Error("Inner event must have a p tag");
|
|
|
|
const eb = new EventBuilder();
|
|
eb.pubKey(signer.getPubKey());
|
|
eb.kind(EventKind.GiftWrap);
|
|
eb.tag(["p", pTag]);
|
|
if (powTarget) {
|
|
eb.pow(powTarget, powMiner);
|
|
}
|
|
eb.content(await signer.nip44Encrypt(JSON.stringify(inner), pTag));
|
|
|
|
return await eb.buildAndSign(secret);
|
|
}
|
|
|
|
async unwrapGift(gift: NostrEvent) {
|
|
const body = await this.#signer.nip44Decrypt(gift.content, gift.pubkey);
|
|
return JSON.parse(body) as NostrEvent;
|
|
}
|
|
|
|
/**
|
|
* Create an unsigned gossip message
|
|
*/
|
|
createUnsigned(kind: EventKind, content: string, fnHook: EventBuilderHook) {
|
|
const eb = new EventBuilder();
|
|
eb.pubKey(this.pubKey);
|
|
eb.kind(kind);
|
|
eb.content(content);
|
|
fnHook(eb);
|
|
return eb.build() as NotSignedNostrEvent;
|
|
}
|
|
|
|
/**
|
|
* Create sealed rumor
|
|
*/
|
|
async sealRumor(inner: NotSignedNostrEvent, toKey: string) {
|
|
const eb = this.#eb(EventKind.SealedRumor);
|
|
eb.content(await this.#signer.nip44Encrypt(JSON.stringify(inner), toKey));
|
|
return await this.#sign(eb);
|
|
}
|
|
|
|
/**
|
|
* Unseal rumor
|
|
*/
|
|
async unsealRumor(inner: NostrEvent) {
|
|
if (inner.kind !== EventKind.SealedRumor) throw new Error("Not a sealed rumor event");
|
|
const body = await this.#signer.nip44Decrypt(inner.content, inner.pubkey);
|
|
return JSON.parse(body) as NostrEvent;
|
|
}
|
|
}
|