Files
welshman/packages/signer/src/signers/nip46.ts
T
2024-11-20 10:27:11 -08:00

345 lines
8.8 KiB
TypeScript

import {Emitter, sleep, tryCatch, randomId, equals} from "@welshman/lib"
import {createEvent, TrustedEvent, StampedEvent, NOSTR_CONNECT} from "@welshman/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"
export type Nip46Handler = {
relays: string[]
pubkey: string
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
}
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
export class Nip46Broker extends Emitter {
#signer: ISigner
#handler: Nip46Handler
#algorithm: Nip46Algorithm
#closed = false
#processing = false
#connectResponse?: Nip46Response
#queue: Nip46Request[] = []
#window?: Window
#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 nostrconnect = `nostrconnect://${clientPubkey}?${params.toString()}`
const result = new Promise<string | undefined>(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)
}
if (response?.result === 'ack') {
console.warn("Bunker responded to nostrconnect with 'ack', which can lead to session hijacking")
complete(pubkey)
}
},
})
abortController?.signal.addEventListener('abort', () => complete())
})
return {result, params, nostrconnect, clientSecret, clientPubkey}
}
static parseBunkerLink(link: string) {
let token = ""
let pubkey = ""
let relays: string[] = []
try {
const url = new URL(link)
pubkey = url.hostname || url.pathname.replace(/\//g, '')
relays = url.searchParams.getAll("relay") || []
token = url.searchParams.get("secret") || ""
} catch {
// pass
}
return {token, pubkey, relays}
}
static get(params: Nip46BrokerParams) {
if (!singleton?.hasParams(params)) {
singleton?.teardown()
singleton = new Nip46Broker(params)
}
return singleton
}
constructor(private params: Nip46BrokerParams) {
super()
this.#handler = params.handler
this.#algorithm = params.algorithm || 'nip04'
this.#signer = new Nip01Signer(params.secret)
}
hasParams(params: Nip46BrokerParams) {
return equals(this.params, params)
}
#subscribe = async () => {
const pubkey = await this.#signer.getPubkey()
return new Promise<void>(resolve => {
if (this.#sub) {
this.#sub.close()
}
this.#sub = subscribe({
relays: this.#handler.relays,
filters: [{kinds: [NOSTR_CONNECT], "#p": [pubkey]}],
})
this.#sub.emitter.on(SubscriptionEvent.Send, resolve)
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 (!response.id) {
console.error(`Invalid nostr-connect response: ${json}`)
}
// Delay errors in case there's a zombie signer out there clogging things up
if (response.error) {
await sleep(3000)
}
if (response.result === "auth_url") {
this.emit(`auth-${response.id}`, response)
} else {
this.emit(`res-${response.id}`, response)
}
})
this.#sub.emitter.on("complete", () => {
this.#sub = undefined
})
})
}
#processQueue = async () => {
if (this.#processing) {
return
}
this.#processing = true
try {
while (this.#queue.length > 0) {
const [{method, params, resolve}] = this.#queue.splice(0, 1)
try {
const response = await this.request(method, params)
console.log('nip46 response:', {method, params, ...response})
resolve(response)
} catch (error: any) {
console.error(`nip46 error:`, {method, params, ...error})
}
}
} finally {
this.#processing = false
}
}
#getResult = async (promise: Promise<Nip46ResponseWithResult>) => {
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")
}
if (!this.#sub) {
await this.#subscribe()
}
const id = randomId()
const recipient = this.#handler.pubkey
const payload = JSON.stringify({id, method, params})
const content = await this.#signer[this.#algorithm].encrypt(recipient, payload)
const template = createEvent(NOSTR_CONNECT, {content, tags: [["p", recipient]]})
console.log('nip46 request:', {id, method, params})
publish({
relays: this.#handler.relays,
event: await this.#signer.sign(template),
})
this.once(`auth-${id}`, response => {
if (!this.#window || this.#window.closed) {
const w = window.open(response.error, "", "width=600,height=800,popup=yes")
if (w) {
this.#window = w
}
}
})
return new Promise<Nip46ResponseWithResult>((resolve, reject) => {
this.once(`res-${id}`, (response: Nip46Response) => {
if (response.error) {
reject(response as Nip46ResponseWithError)
} else {
resolve(response as Nip46ResponseWithResult)
}
})
})
}
enqueue = (method: string, params: string[]) =>
new Promise<Nip46ResponseWithResult>(resolve => {
this.#queue.push({method, params, resolve})
this.#processQueue()
})
createAccount = (username: string, perms = "") => {
if (!this.#handler.domain) {
throw new Error("Unable to create an account without a handler domain")
}
return this.#getResult(this.enqueue("create_account", [username, this.#handler.domain, "", perms]))
}
connect = async (token = "", perms = "", secret = "") => {
if (!this.#connectResponse) {
const params = ["", token, perms]
this.#connectResponse = await this.enqueue("connect", params)
}
return this.#connectResponse.result === 'ack'
}
getPublicKey = () => this.#getResult(this.enqueue("get_public_key", []))
signEvent = async (event: StampedEvent) =>
JSON.parse(await this.#getResult(this.enqueue("sign_event", [JSON.stringify(event)])))
nip04Encrypt = (pk: string, message: string) =>
this.#getResult(this.enqueue("nip04_encrypt", [pk, message]))
nip04Decrypt = (pk: string, message: string) =>
this.#getResult(this.enqueue("nip04_decrypt", [pk, message]))
nip44Encrypt = (pk: string, message: string) =>
this.#getResult(this.enqueue("nip44_encrypt", [pk, message]))
nip44Decrypt = (pk: string, message: string) =>
this.#getResult(this.enqueue("nip44_decrypt", [pk, message]))
teardown = () => {
this.#closed = true
this.#sub?.close()
}
}
export class Nip46Signer implements ISigner {
#pubkey?: string
constructor(private broker: Nip46Broker) {}
getPubkey = async () => {
if (!this.#pubkey) {
this.#pubkey = await this.broker.getPublicKey()
}
return this.#pubkey
}
sign = async (template: StampedEvent) =>
this.broker.signEvent(hash(own(template, await this.getPubkey())))
nip04 = {
encrypt: this.broker.nip04Encrypt,
decrypt: this.broker.nip04Decrypt,
}
nip44 = {
encrypt: this.broker.nip44Encrypt,
decrypt: this.broker.nip44Decrypt,
}
}