diff --git a/packages/lib/src/Tools.ts b/packages/lib/src/Tools.ts index bfaa56b..0dcc3e7 100644 --- a/packages/lib/src/Tools.ts +++ b/packages/lib/src/Tools.ts @@ -9,6 +9,8 @@ export const isNil = (x: any) => [null, undefined].includes(x) export type Maybe = T | undefined +export const ifLet = (x: T | undefined, f: (x: T) => void) => x === undefined ? undefined : f(x) + // Regular old utils export const noop = (...args: unknown[]) => undefined diff --git a/packages/signer/src/signers/nip46.ts b/packages/signer/src/signers/nip46.ts index 7d3d11a..fec2b3b 100644 --- a/packages/signer/src/signers/nip46.ts +++ b/packages/signer/src/signers/nip46.ts @@ -1,7 +1,7 @@ -import {Emitter, sleep, tryCatch, randomId, equals, now} from "@welshman/lib" +import {Emitter, normalizeUrl, tryCatch, randomId, equals} from "@welshman/lib" import {createEvent, TrustedEvent, StampedEvent, NOSTR_CONNECT} from "@welshman/util" -import {subscribe, publish, Subscription} from "@welshman/net" -import {ISigner, decrypt, hash, own} from '../util' +import {subscribe, publish, Subscription, SubscriptionEvent} from "@welshman/net" +import {ISigner, decrypt, hash, own, makeSecret, getPubkey} from '../util' import {Nip01Signer} from './nip01' export type Nip46Algorithm = "nip04" | "nip44" @@ -12,22 +12,47 @@ export type Nip46Handler = { domain?: string } +export type Nip46InitiateParams = { + url: string + name: string + image: string + perms: string + relays: string[] + abortController?: AbortController +} + export type Nip46BrokerParams = { secret: string handler: Nip46Handler algorithm?: Nip46Algorithm } +export type Nip46Request = { + method: string + params: string[] + resolve: (result: Nip46ResponseWithResult) => void +} + export type Nip46Response = { id: string + url: string + event: TrustedEvent error?: string result?: string } -type Request = { - method: string - params: string[] - resolve: (result: string) => void +export type Nip46ResponseWithResult = { + id: string + url: string + event: TrustedEvent + result: string +} + +export type Nip46ResponseWithError = { + id: string + url: string + event: TrustedEvent + error: string } let singleton: Nip46Broker @@ -38,10 +63,62 @@ export class Nip46Broker extends Emitter { #algorithm: Nip46Algorithm #closed = false #processing = false - #connectResult?: string - #queue: Request[] = [] + #connectResponse?: Nip46Response + #queue: Nip46Request[] = [] #sub?: Subscription + static initiate({url, name, image, perms, relays, abortController}: Nip46InitiateParams) { + const secret = Math.random().toString(36).substring(7) + const clientSecret = makeSecret() + const clientPubkey = getPubkey(clientSecret) + const clientSigner = new Nip01Signer(clientSecret) + const params = new URLSearchParams({secret, url, name, image, perms}) + + for (const relay of relays) { + params.append('relay', relay) + } + + const result = new Promise(resolve => { + const complete = (pubkey?: string) => { + sub.close() + resolve(pubkey) + } + + const sub = subscribe({ + relays, + filters: [{kinds: [NOSTR_CONNECT], "#p": [clientPubkey]}], + onEvent: async ({pubkey, content}: TrustedEvent) => { + const response = await tryCatch( + async () => JSON.parse( + await decrypt(clientSigner, pubkey, content) + ) + ) + + if (response?.result === secret) { + complete(pubkey) + } + }, + }) + + abortController?.signal.addEventListener('abort', () => complete()) + }) + + return { + result, + params, + clientSecret, + clientPubkey, + getLink: (template: string) => { + const temp = normalizeUrl(template) + const uri = `nostrconnect://${clientPubkey}?${params.toString()}` + + return temp.includes('') + ? temp.replace('', uri) + : new URL(temp).origin + '/' + uri + } + } + } + static parseBunkerLink(link: string) { let token = "" let pubkey = "" @@ -91,25 +168,25 @@ export class Nip46Broker extends Emitter { this.#sub = subscribe({ relays: this.#handler.relays, - filters: [{since: now() - 30, kinds: [NOSTR_CONNECT], "#p": [pubkey]}], + filters: [{kinds: [NOSTR_CONNECT], "#p": [pubkey]}], }) - this.#sub.emitter.on('send', resolve) + this.#sub.emitter.on(SubscriptionEvent.Send, resolve) - this.#sub.emitter.on("event", async (url: string, e: TrustedEvent) => { - const json = await decrypt(this.#signer, e.pubkey, e.content) - const res = await tryCatch(() => JSON.parse(json)) + this.#sub.emitter.on(SubscriptionEvent.Event, async (url: string, event: TrustedEvent) => { + const json = await decrypt(this.#signer, event.pubkey, event.content) + const response = tryCatch(() => JSON.parse(json)) - if (!res.id) { + if (!response.id) { console.error(`Invalid nostr-connect response: ${json}`) } - console.log('nip46 response:', res) + console.log('nip46 response:', response) - if (res.result === "auth_url") { - this.emit(`auth-${res.id}`, res) + if (response.result === "auth_url") { + this.emit(`auth-${response.id}`, {...response, url, event}) } else { - this.emit(`res-${res.id}`, res) + this.emit(`res-${response.id}`, {...response, url, event}) } }) @@ -130,20 +207,19 @@ export class Nip46Broker extends Emitter { while (this.#queue.length > 0) { const [{method, params, resolve}] = this.#queue.splice(0, 1) - // Throttle requests to the signer so the user isn't overwhelmed by dialogs, but time - // out and move on to other requests if they're ignored - // Note: currenlty throttle is too low to help with dialogs, but blocking prevents - // important user actions - await Promise.race([ - this.request(method, params).then(resolve), - sleep(15), - ]) + this.request(method, params).then(resolve) } } finally { this.#processing = false } } + #getResult = async (promise: Promise) => { + const {result} = await promise + + return result + } + request = async (method: string, params: string[]) => { if (this.#closed) { throw new Error("Attempted to make a nip46 request with a closed broker") @@ -166,23 +242,23 @@ export class Nip46Broker extends Emitter { event: await this.#signer.sign(template), }) - this.once(`auth-${id}`, res => { - window.open(res.error, "Coracle", "width=600,height=800,popup=yes") + this.once(`auth-${id}`, response => { + window.open(response.error, "", "width=600,height=800,popup=yes") }) - return new Promise((resolve, reject) => { - this.once(`res-${id}`, ({result, error}: Nip46Response) => { - if (error) { - reject(error as string) + return new Promise((resolve, reject) => { + this.once(`res-${id}`, (response: Nip46Response) => { + if (response.error) { + reject(response as Nip46ResponseWithError) } else { - resolve(result as string) + resolve(response as Nip46ResponseWithResult) } }) }) } enqueue = (method: string, params: string[]) => - new Promise(resolve => { + new Promise(resolve => { this.#queue.push({method, params, resolve}) this.#processQueue() }) @@ -195,37 +271,32 @@ export class Nip46Broker extends Emitter { return this.enqueue("create_account", [username, this.#handler.domain, "", perms]) } - connect = async (token = "", perms = "") => { - if (!this.#connectResult) { + connect = async (token = "", perms = "", secret = "") => { + if (!this.#connectResponse) { const params = ["", token, perms] - this.#connectResult = await this.enqueue("connect", params) + this.#connectResponse = await this.enqueue("connect", params) } - return this.#connectResult === "ack" + return this.#connectResponse.result === 'ack' } - getPublicKey = () => this.enqueue("get_public_key", []) + getPublicKey = () => this.#getResult(this.enqueue("get_public_key", [])) - signEvent = async (event: StampedEvent) => { - return JSON.parse(await this.enqueue("sign_event", [JSON.stringify(event)]) as string) - } + signEvent = async (event: StampedEvent) => + JSON.parse(await this.#getResult(this.enqueue("sign_event", [JSON.stringify(event)]))) - nip04Encrypt = (pk: string, message: string) => { - return this.enqueue("nip04_encrypt", [pk, message]) - } + nip04Encrypt = (pk: string, message: string) => + this.#getResult(this.enqueue("nip04_encrypt", [pk, message])) - nip04Decrypt = (pk: string, message: string) => { - return this.enqueue("nip04_decrypt", [pk, message]) - } + nip04Decrypt = (pk: string, message: string) => + this.#getResult(this.enqueue("nip04_decrypt", [pk, message])) - nip44Encrypt = (pk: string, message: string) => { - return this.enqueue("nip44_encrypt", [pk, message]) - } + nip44Encrypt = (pk: string, message: string) => + this.#getResult(this.enqueue("nip44_encrypt", [pk, message])) - nip44Decrypt = (pk: string, message: string) => { - return this.enqueue("nip44_decrypt", [pk, message]) - } + nip44Decrypt = (pk: string, message: string) => + this.#getResult(this.enqueue("nip44_decrypt", [pk, message])) teardown = () => { this.#closed = true