import { unwrap, bech32ToHex } from "@snort/shared"; import { secp256k1 } from "@noble/curves/secp256k1"; import { v4 as uuid } from "uuid"; import debug from "debug"; import { Connection } from "../connection"; import { EventSigner, PrivateKeySigner } from "../signer"; import { NostrEvent } from "../nostr"; import { EventBuilder } from "../event-builder"; import EventKind from "../event-kind"; import { EventEmitter } from "eventemitter3"; const NIP46_KIND = 24_133; // FIXME add all kinds that Snort signs const PERMS = "nip04_encrypt,nip04_decrypt,sign_event:0,sign_event:1,sign_event:3,sign_event:4,sign_event:6,sign_event:7,sign_event:30078"; interface Nip46Metadata { name: string; url?: string; description?: string; icons?: Array; } interface Nip46Request { id: string; method: string; params: Array; } interface Nip46Response { id: string; result: any; error: string; } interface QueueObj { resolve: (o: Nip46Response) => void; reject: (e: Error) => void; authed?: boolean; } interface Nip46Events { oauth: (url: string) => void; } export class Nip46Signer extends EventEmitter implements EventSigner { #conn?: Connection; #relay: string; #localPubkey: string; #remotePubkey?: string; #token?: string; #insideSigner: EventSigner; #commandQueue: Map = new Map(); #log = debug("NIP-46"); #proto: string; #didInit: boolean = false; /** * Start NIP-46 connection * @param config bunker/nostrconnect://{npub/hex-pubkey}?relay={websocket-url}#{token-hex} * @param insideSigner */ constructor(config: string, insideSigner?: EventSigner) { super(); const u = new URL(config); this.#proto = u.protocol; this.#localPubkey = u.hostname || u.pathname.substring(2); if (u.hash.length > 1) { this.#token = u.hash.substring(1); } if (this.#localPubkey.startsWith("npub")) { this.#localPubkey = bech32ToHex(this.#localPubkey); } this.#relay = unwrap(u.searchParams.get("relay")); this.#insideSigner = insideSigner ?? new PrivateKeySigner(secp256k1.utils.randomPrivateKey()); } get supports(): string[] { return ["nip04"]; } get relays() { return [this.#relay]; } get privateKey() { if (this.#insideSigner instanceof PrivateKeySigner) { return this.#insideSigner.privateKey; } } /** * Connect to the bunker relay * @param autoConnect Start connect flow for pubkey * @returns */ async init(autoConnect = true) { const isBunker = this.#proto === "bunker:"; if (isBunker) { this.#remotePubkey = this.#localPubkey; this.#localPubkey = await this.#insideSigner.getPubKey(); } return await new Promise((resolve, reject) => { this.#conn = new Connection(this.#relay, { read: true, write: true }); this.#conn.on("event", async (sub, e) => { await this.#onReply(e); }); this.#conn.on("connected", async () => { this.#conn!.queueReq( [ "REQ", "reply", { kinds: [NIP46_KIND], "#p": [this.#localPubkey], // strfry doesn't always delete ephemeral events since: Math.floor(Date.now() / 1000 - 10), }, ], () => {}, ); if (autoConnect) { if (isBunker) { const rsp = await this.#connect(unwrap(this.#remotePubkey)); if (rsp.result === "ack") { resolve(); } else { reject(rsp.error); } } else { this.#commandQueue.set("connect", { reject, resolve: () => { resolve(); }, }); } } else { resolve(); } }); this.#conn.connect(); this.#didInit = true; }); } async close() { if (this.#conn) { await this.#disconnect(); this.#conn.closeReq("reply"); this.#conn.close(); this.#conn = undefined; this.#didInit = false; } } async describe() { const rsp = await this.#rpc("describe", []); return rsp.result as Array; } async getPubKey() { //const rsp = await this.#rpc("get_public_key", []); //return rsp.result as string; return this.#remotePubkey!; } async nip4Encrypt(content: string, otherKey: string) { const rsp = await this.#rpc("nip04_encrypt", [otherKey, content]); return rsp.result as string; } async nip4Decrypt(content: string, otherKey: string) { const rsp = await this.#rpc("nip04_decrypt", [otherKey, content]); return rsp.result as string; } nip44Encrypt(content: string, key: string): Promise { throw new Error("Method not implemented."); } nip44Decrypt(content: string, otherKey: string): Promise { throw new Error("Method not implemented."); } async sign(ev: NostrEvent) { const rsp = await this.#rpc("sign_event", [JSON.stringify(ev)]); return JSON.parse(rsp.result as string); } /** * NIP-46 oAuth bunker signup * @param name Desired name * @param domain Desired domain * @param email Backup email address * @returns */ async createAccount(name: string, domain: string, email?: string) { await this.init(false); const rsp = await this.#rpc("create_account", [name, domain, email ?? "", PERMS]); if (!rsp.error) { this.#remotePubkey = rsp.result as string; } } async #disconnect() { return await this.#rpc("disconnect", []); } async #connect(pk: string) { const connectParams = [pk, this.#token ?? "", PERMS]; return await this.#rpc("connect", connectParams); } async #onReply(e: NostrEvent) { if (e.kind !== NIP46_KIND) { throw new Error("Unknown event kind"); } const decryptedContent = await this.#insideSigner.nip4Decrypt(e.content, e.pubkey); const reply = JSON.parse(decryptedContent) as Nip46Request | Nip46Response; let id = reply.id; this.#log("Recv: %O", reply); if ("method" in reply && reply.method === "connect") { this.#remotePubkey = reply.params[0]; await this.#sendCommand( { id: reply.id, result: "ack", error: "", }, unwrap(this.#remotePubkey), ); id = "connect"; } const pending = this.#commandQueue.get(id); if (!pending) { throw new Error("No pending command found"); } if ("result" in reply && reply.result === "auth_url") { if (!pending.authed) this.emit("oauth", reply.error); pending.authed = true; } else { const rx = reply as Nip46Response; if (rx.error) { pending.reject(new Error(rx.error)); } else { pending.resolve(rx); } this.#commandQueue.delete(reply.id); } } async #rpc(method: string, params: Array) { if (!this.#didInit) { await this.init(); } if (!this.#conn) throw new Error("Connection error"); const payload = { id: uuid(), method, params, } as Nip46Request; this.#sendCommand(payload, unwrap(this.#remotePubkey)); return await new Promise((resolve, reject) => { this.#commandQueue.set(payload.id, { resolve: async (o: Nip46Response) => { resolve(o); }, reject, }); }); } async #sendCommand(payload: Nip46Request | Nip46Response, target: string) { if (!this.#conn) return; const eb = new EventBuilder(); eb.kind(NIP46_KIND as EventKind) .content(await this.#insideSigner.nip4Encrypt(JSON.stringify(payload), target)) .tag(["p", target]); this.#log("Send: %O", payload); const evCommand = await eb.buildAndSign(this.#insideSigner); await this.#conn.sendEventAsync(evCommand); } }