bunker.signer.ts raw

   1  /**
   2   * NIP-46 Bunker Signer
   3   *
   4   * Implements remote signing via NIP-46 protocol.
   5   * The signer connects to a bunker WebSocket and
   6   * requests signing operations.
   7   */
   8  
   9  import { ISigner, TDraftEvent } from '@/types'
  10  import * as utils from '@noble/curves/abstract/utils'
  11  import { secp256k1 } from '@noble/curves/secp256k1'
  12  import { Event, VerifiedEvent, getPublicKey as nGetPublicKey, nip04, finalizeEvent } from 'nostr-tools'
  13  
  14  // NIP-46 methods
  15  const NIP46_METHOD = {
  16    CONNECT: 'connect',
  17    GET_PUBLIC_KEY: 'get_public_key',
  18    SIGN_EVENT: 'sign_event',
  19    NIP04_ENCRYPT: 'nip04_encrypt',
  20    NIP04_DECRYPT: 'nip04_decrypt',
  21    NIP44_ENCRYPT: 'nip44_encrypt',
  22    NIP44_DECRYPT: 'nip44_decrypt',
  23    PING: 'ping'
  24  } as const
  25  
  26  type NIP46Method = (typeof NIP46_METHOD)[keyof typeof NIP46_METHOD]
  27  
  28  // NIP-46 request format
  29  interface NIP46Request {
  30    id: string
  31    method: NIP46Method
  32    params: string[]
  33  }
  34  
  35  // NIP-46 response format
  36  interface NIP46Response {
  37    id: string
  38    result?: string
  39    error?: string
  40  }
  41  
  42  // Pending request tracker
  43  interface PendingRequest {
  44    resolve: (value: string) => void
  45    reject: (error: Error) => void
  46    timeout: ReturnType<typeof setTimeout>
  47  }
  48  
  49  /**
  50   * Generate a random request ID.
  51   */
  52  function generateRequestId(): string {
  53    const bytes = crypto.getRandomValues(new Uint8Array(16))
  54    return utils.bytesToHex(bytes)
  55  }
  56  
  57  /**
  58   * Parse a bunker URL (bunker://<pubkey>?relay=<url>&secret=<secret>).
  59   */
  60  export function parseBunkerUrl(url: string): {
  61    pubkey: string
  62    relays: string[]
  63    secret?: string
  64  } {
  65    if (!url.startsWith('bunker://')) {
  66      throw new Error('Invalid bunker URL: must start with bunker://')
  67    }
  68  
  69    const withoutPrefix = url.slice('bunker://'.length)
  70    const [pubkeyPart, queryPart] = withoutPrefix.split('?')
  71  
  72    if (!pubkeyPart || pubkeyPart.length !== 64) {
  73      throw new Error('Invalid bunker URL: missing or invalid pubkey')
  74    }
  75  
  76    const params = new URLSearchParams(queryPart || '')
  77    const relays = params.getAll('relay')
  78    const secret = params.get('secret') || undefined
  79  
  80    if (relays.length === 0) {
  81      throw new Error('Invalid bunker URL: no relay specified')
  82    }
  83  
  84    return {
  85      pubkey: pubkeyPart,
  86      relays,
  87      secret
  88    }
  89  }
  90  
  91  /**
  92   * Parse a nostr+connect URL (nostr+connect://<relay-url>?pubkey=<client-pubkey>&secret=<secret>).
  93   * This is the format that signers (like Amber) scan to connect to a client.
  94   */
  95  export function parseNostrConnectUrl(url: string): {
  96    relay: string
  97    pubkey?: string
  98    secret?: string
  99  } {
 100    if (!url.startsWith('nostr+connect://')) {
 101      throw new Error('Invalid nostr+connect URL: must start with nostr+connect://')
 102    }
 103  
 104    const withoutPrefix = url.slice('nostr+connect://'.length)
 105    const [relayPart, queryPart] = withoutPrefix.split('?')
 106  
 107    if (!relayPart) {
 108      throw new Error('Invalid nostr+connect URL: missing relay')
 109    }
 110  
 111    const params = new URLSearchParams(queryPart || '')
 112    const pubkey = params.get('pubkey') || undefined
 113    const secret = params.get('secret') || undefined
 114  
 115    return {
 116      relay: relayPart,
 117      pubkey,
 118      secret
 119    }
 120  }
 121  
 122  /**
 123   * Build a nostr+connect URL for signers to scan.
 124   * @param relay - The relay URL (without ws:// prefix, will be added)
 125   * @param pubkey - The client's ephemeral pubkey for this session
 126   * @param secret - Optional secret for the handshake
 127   */
 128  export function buildNostrConnectUrl(relay: string, pubkey: string, secret?: string): string {
 129    // Ensure relay URL uses the relay host without protocol
 130    let relayHost = relay
 131      .replace('wss://', '')
 132      .replace('ws://', '')
 133      .replace('https://', '')
 134      .replace('http://', '')
 135      .replace(/\/$/, '')
 136  
 137    const params = new URLSearchParams()
 138    params.set('pubkey', pubkey)
 139    if (secret) {
 140      params.set('secret', secret)
 141    }
 142    return `nostr+connect://${relayHost}?${params.toString()}`
 143  }
 144  
 145  /**
 146   * Build a bunker URL from components.
 147   */
 148  export function buildBunkerUrl(pubkey: string, relays: string[], secret?: string): string {
 149    const params = new URLSearchParams()
 150    relays.forEach((relay) => params.append('relay', relay))
 151    if (secret) {
 152      params.set('secret', secret)
 153    }
 154    return `bunker://${pubkey}?${params.toString()}`
 155  }
 156  
 157  export class BunkerSigner implements ISigner {
 158    private bunkerPubkey: string
 159    private relayUrls: string[]
 160    private connectionSecret?: string
 161    private localPrivkey: Uint8Array
 162    private localPubkey: string
 163    private remotePubkey: string | null = null
 164    private ws: WebSocket | null = null
 165    private pendingRequests = new Map<string, PendingRequest>()
 166    private connected = false
 167    private requestTimeout = 30000 // 30 seconds
 168  
 169    // Whether we're waiting for signer to connect (reverse flow)
 170    private awaitingConnection = false
 171    private connectionResolve: ((pubkey: string) => void) | null = null
 172  
 173    /**
 174     * Create a BunkerSigner.
 175     * @param bunkerPubkey - The bunker's public key (hex)
 176     * @param relayUrls - Relay URLs to connect to
 177     * @param connectionSecret - Optional connection secret for initial handshake
 178     */
 179    constructor(bunkerPubkey: string, relayUrls: string[], connectionSecret?: string) {
 180      this.bunkerPubkey = bunkerPubkey
 181      this.relayUrls = relayUrls
 182      this.connectionSecret = connectionSecret
 183  
 184      // Generate local ephemeral keypair for NIP-46 communication
 185      this.localPrivkey = secp256k1.utils.randomPrivateKey()
 186      this.localPubkey = nGetPublicKey(this.localPrivkey)
 187    }
 188  
 189    /**
 190     * Create a BunkerSigner that waits for a signer (like Amber) to connect.
 191     * Returns the nostr+connect URL to display as QR code and a promise for the connected signer.
 192     *
 193     * @param relayUrl - The relay URL for the connection
 194     * @param secret - Optional secret for the handshake
 195     * @param timeout - Connection timeout in ms (default 120000 = 2 minutes)
 196     */
 197    static async awaitSignerConnection(
 198      relayUrl: string,
 199      secret?: string,
 200      timeout = 120000
 201    ): Promise<{ connectUrl: string; signer: Promise<BunkerSigner> }> {
 202      // Generate ephemeral keypair for this session
 203      const localPrivkey = secp256k1.utils.randomPrivateKey()
 204      const localPubkey = nGetPublicKey(localPrivkey)
 205  
 206      // Generate secret if not provided
 207      const connectionSecret = secret || generateRequestId()
 208  
 209      // Build the nostr+connect URL for signer to scan
 210      const connectUrl = buildNostrConnectUrl(relayUrl, localPubkey, connectionSecret)
 211  
 212      // Create signer instance (bunkerPubkey will be set when signer connects)
 213      const signer = new BunkerSigner('', [relayUrl], connectionSecret)
 214      signer.localPrivkey = localPrivkey
 215      signer.localPubkey = localPubkey
 216      signer.awaitingConnection = true
 217  
 218      // Return URL immediately, signer promise resolves when connected
 219      const signerPromise = new Promise<BunkerSigner>((resolve, reject) => {
 220        signer.connectionResolve = (signerPubkey: string) => {
 221          signer.bunkerPubkey = signerPubkey
 222          // Do NOT set remotePubkey here - it must be fetched via get_public_key
 223          // The signerPubkey from the connect event is the bunker's communication pubkey,
 224          // not necessarily the user's signing pubkey
 225          signer.awaitingConnection = false
 226          resolve(signer)
 227        }
 228        // Set timeout
 229        setTimeout(() => {
 230          if (signer.awaitingConnection) {
 231            signer.disconnect()
 232            reject(new Error('Connection timeout waiting for signer'))
 233          }
 234        }, timeout)
 235  
 236        // Connect to relay and wait
 237        signer.connectAndWait(relayUrl).catch(reject)
 238      })
 239  
 240      return { connectUrl, signer: signerPromise }
 241    }
 242  
 243    /**
 244     * Connect to relay and wait for signer to initiate connection.
 245     */
 246    private async connectAndWait(relayUrl: string): Promise<void> {
 247      await this.connectToRelayAndListen(relayUrl)
 248    }
 249  
 250    /**
 251     * Connect to relay and listen for incoming connect requests.
 252     */
 253    private async connectToRelayAndListen(relayUrl: string): Promise<void> {
 254      return new Promise((resolve, reject) => {
 255        let wsUrl = relayUrl
 256        if (relayUrl.startsWith('http://')) {
 257          wsUrl = 'ws://' + relayUrl.slice(7)
 258        } else if (relayUrl.startsWith('https://')) {
 259          wsUrl = 'wss://' + relayUrl.slice(8)
 260        } else if (!relayUrl.startsWith('ws://') && !relayUrl.startsWith('wss://')) {
 261          wsUrl = 'wss://' + relayUrl
 262        }
 263  
 264        const ws = new WebSocket(wsUrl)
 265  
 266        const timeout = setTimeout(() => {
 267          ws.close()
 268          reject(new Error('Connection timeout'))
 269        }, 10000)
 270  
 271        ws.onopen = () => {
 272          clearTimeout(timeout)
 273          this.ws = ws
 274          this.connected = true
 275  
 276          // Subscribe to events for our local pubkey
 277          const subId = generateRequestId()
 278          ws.send(
 279            JSON.stringify([
 280              'REQ',
 281              subId,
 282              {
 283                kinds: [24133],
 284                '#p': [this.localPubkey],
 285                since: Math.floor(Date.now() / 1000) - 60
 286              }
 287            ])
 288          )
 289  
 290          resolve()
 291        }
 292  
 293        ws.onerror = () => {
 294          clearTimeout(timeout)
 295          reject(new Error('WebSocket error'))
 296        }
 297  
 298        ws.onclose = () => {
 299          this.connected = false
 300          this.ws = null
 301        }
 302  
 303        ws.onmessage = (event) => {
 304          this.handleMessage(event.data)
 305        }
 306      })
 307    }
 308  
 309    /**
 310     * Get the local public key (for displaying in nostr+connect URL).
 311     */
 312    getLocalPubkey(): string {
 313      return this.localPubkey
 314    }
 315  
 316    /**
 317     * Initialize connection to the bunker.
 318     */
 319    async init(): Promise<void> {
 320      // Connect to first available relay
 321      for (const relayUrl of this.relayUrls) {
 322        try {
 323          await this.connectToRelay(relayUrl)
 324          break
 325        } catch (err) {
 326          console.warn(`Failed to connect to ${relayUrl}:`, err)
 327        }
 328      }
 329  
 330      if (!this.connected) {
 331        throw new Error('Failed to connect to any bunker relay')
 332      }
 333  
 334      // Perform NIP-46 connect handshake
 335      await this.connect()
 336    }
 337  
 338    /**
 339     * Connect to a relay WebSocket.
 340     */
 341    private async connectToRelay(relayUrl: string): Promise<void> {
 342      return new Promise((resolve, reject) => {
 343        // Convert ws:// or wss:// URL
 344        let wsUrl = relayUrl
 345        if (relayUrl.startsWith('http://')) {
 346          wsUrl = 'ws://' + relayUrl.slice(7)
 347        } else if (relayUrl.startsWith('https://')) {
 348          wsUrl = 'wss://' + relayUrl.slice(8)
 349        } else if (!relayUrl.startsWith('ws://') && !relayUrl.startsWith('wss://')) {
 350          wsUrl = 'wss://' + relayUrl
 351        }
 352  
 353        const ws = new WebSocket(wsUrl)
 354  
 355        const timeout = setTimeout(() => {
 356          ws.close()
 357          reject(new Error('Connection timeout'))
 358        }, 10000)
 359  
 360        ws.onopen = () => {
 361          clearTimeout(timeout)
 362          this.ws = ws
 363          this.connected = true
 364  
 365          // Subscribe to responses for our local pubkey
 366          const subId = generateRequestId()
 367          ws.send(
 368            JSON.stringify([
 369              'REQ',
 370              subId,
 371              {
 372                kinds: [24133], // NIP-46 response kind
 373                '#p': [this.localPubkey],
 374                since: Math.floor(Date.now() / 1000) - 60
 375              }
 376            ])
 377          )
 378  
 379          resolve()
 380        }
 381  
 382        ws.onerror = () => {
 383          clearTimeout(timeout)
 384          reject(new Error('WebSocket error'))
 385        }
 386  
 387        ws.onclose = () => {
 388          this.connected = false
 389          this.ws = null
 390        }
 391  
 392        ws.onmessage = (event) => {
 393          this.handleMessage(event.data)
 394        }
 395      })
 396    }
 397  
 398    /**
 399     * Handle incoming WebSocket messages.
 400     */
 401    private async handleMessage(data: string): Promise<void> {
 402      try {
 403        const msg = JSON.parse(data)
 404        if (!Array.isArray(msg)) return
 405  
 406        const [type, ...rest] = msg
 407  
 408        if (type === 'EVENT') {
 409          const [, event] = rest as [string, Event]
 410          if (event.kind === 24133) {
 411            await this.handleNIP46Response(event)
 412          }
 413        } else if (type === 'OK') {
 414          // Event published confirmation
 415        } else if (type === 'NOTICE') {
 416          console.warn('Relay notice:', rest[0])
 417        }
 418      } catch (err) {
 419        console.error('Failed to parse message:', err)
 420      }
 421    }
 422  
 423    /**
 424     * Handle NIP-46 response event.
 425     */
 426    private async handleNIP46Response(event: Event): Promise<void> {
 427      try {
 428        // Decrypt the content with NIP-04
 429        const decrypted = await nip04.decrypt(this.localPrivkey, event.pubkey, event.content)
 430        const parsed = JSON.parse(decrypted)
 431  
 432        // Check if this is an incoming connect request (signer initiating connection)
 433        if (this.awaitingConnection && parsed.method === 'connect') {
 434          const request = parsed as NIP46Request
 435          console.log('Received connect request from signer:', event.pubkey)
 436  
 437          // Verify secret if we have one
 438          if (this.connectionSecret) {
 439            const providedSecret = request.params[1] // Second param is the secret
 440            if (providedSecret !== this.connectionSecret) {
 441              console.warn('Connect request has wrong secret, ignoring')
 442              return
 443            }
 444          }
 445  
 446          // Send ack response
 447          const response: NIP46Response = {
 448            id: request.id,
 449            result: 'ack'
 450          }
 451          const encrypted = await nip04.encrypt(this.localPrivkey, event.pubkey, JSON.stringify(response))
 452          const responseEvent: TDraftEvent = {
 453            kind: 24133,
 454            created_at: Math.floor(Date.now() / 1000),
 455            content: encrypted,
 456            tags: [['p', event.pubkey]]
 457          }
 458          const signedResponse = finalizeEvent(responseEvent, this.localPrivkey)
 459          this.ws?.send(JSON.stringify(['EVENT', signedResponse]))
 460  
 461          // Resolve the connection promise
 462          if (this.connectionResolve) {
 463            this.connectionResolve(event.pubkey)
 464          }
 465          return
 466        }
 467  
 468        // Handle as normal response
 469        const response = parsed as NIP46Response
 470        const pending = this.pendingRequests.get(response.id)
 471  
 472        if (pending) {
 473          clearTimeout(pending.timeout)
 474          this.pendingRequests.delete(response.id)
 475  
 476          if (response.error) {
 477            pending.reject(new Error(response.error))
 478          } else if (response.result !== undefined) {
 479            pending.resolve(response.result)
 480          } else {
 481            pending.reject(new Error('Empty response'))
 482          }
 483        }
 484      } catch (err) {
 485        console.error('Failed to handle NIP-46 response:', err)
 486      }
 487    }
 488  
 489    /**
 490     * Send a NIP-46 request and wait for response.
 491     */
 492    private async sendRequest(method: NIP46Method, params: string[] = []): Promise<string> {
 493      if (!this.ws || !this.connected) {
 494        throw new Error('Not connected to bunker')
 495      }
 496  
 497      const request: NIP46Request = {
 498        id: generateRequestId(),
 499        method,
 500        params
 501      }
 502  
 503      // Encrypt with NIP-04 to the bunker's pubkey
 504      const encrypted = await nip04.encrypt(this.localPrivkey, this.bunkerPubkey, JSON.stringify(request))
 505  
 506      // Create NIP-46 request event
 507      const draftEvent: TDraftEvent = {
 508        kind: 24133,
 509        created_at: Math.floor(Date.now() / 1000),
 510        content: encrypted,
 511        tags: [['p', this.bunkerPubkey]]
 512      }
 513  
 514      const signedEvent = finalizeEvent(draftEvent, this.localPrivkey)
 515  
 516      // Send to relay
 517      this.ws.send(JSON.stringify(['EVENT', signedEvent]))
 518  
 519      // Wait for response
 520      return new Promise((resolve, reject) => {
 521        const timeout = setTimeout(() => {
 522          this.pendingRequests.delete(request.id)
 523          reject(new Error('Request timeout'))
 524        }, this.requestTimeout)
 525  
 526        this.pendingRequests.set(request.id, { resolve, reject, timeout })
 527      })
 528    }
 529  
 530    /**
 531     * Perform NIP-46 connect handshake.
 532     */
 533    private async connect(): Promise<void> {
 534      const params: string[] = [this.localPubkey]
 535      if (this.connectionSecret) {
 536        params.push(this.connectionSecret)
 537      }
 538  
 539      const result = await this.sendRequest(NIP46_METHOD.CONNECT, params)
 540      if (result !== 'ack') {
 541        throw new Error(`Connect failed: ${result}`)
 542      }
 543    }
 544  
 545    /**
 546     * Get the public key of the user (from the bunker).
 547     */
 548    async getPublicKey(): Promise<string> {
 549      if (this.remotePubkey) {
 550        return this.remotePubkey
 551      }
 552  
 553      const pubkey = await this.sendRequest(NIP46_METHOD.GET_PUBLIC_KEY)
 554      this.remotePubkey = pubkey
 555      return pubkey
 556    }
 557  
 558    /**
 559     * Sign an event via the bunker.
 560     */
 561    async signEvent(draftEvent: TDraftEvent): Promise<VerifiedEvent> {
 562      const eventJson = JSON.stringify({
 563        ...draftEvent,
 564        pubkey: await this.getPublicKey()
 565      })
 566  
 567      const signedEventJson = await this.sendRequest(NIP46_METHOD.SIGN_EVENT, [eventJson])
 568      const signedEvent = JSON.parse(signedEventJson) as VerifiedEvent
 569  
 570      return signedEvent
 571    }
 572  
 573    /**
 574     * Encrypt a message with NIP-04 via the bunker.
 575     */
 576    async nip04Encrypt(pubkey: string, plainText: string): Promise<string> {
 577      return this.sendRequest(NIP46_METHOD.NIP04_ENCRYPT, [pubkey, plainText])
 578    }
 579  
 580    /**
 581     * Decrypt a message with NIP-04 via the bunker.
 582     */
 583    async nip04Decrypt(pubkey: string, cipherText: string): Promise<string> {
 584      return this.sendRequest(NIP46_METHOD.NIP04_DECRYPT, [pubkey, cipherText])
 585    }
 586  
 587    /**
 588     * Encrypt a message with NIP-44 via the bunker.
 589     */
 590    async nip44Encrypt(pubkey: string, plainText: string): Promise<string> {
 591      return this.sendRequest(NIP46_METHOD.NIP44_ENCRYPT, [pubkey, plainText])
 592    }
 593  
 594    /**
 595     * Decrypt a message with NIP-44 via the bunker.
 596     */
 597    async nip44Decrypt(pubkey: string, cipherText: string): Promise<string> {
 598      return this.sendRequest(NIP46_METHOD.NIP44_DECRYPT, [pubkey, cipherText])
 599    }
 600  
 601    /**
 602     * Check if connected to the bunker.
 603     */
 604    isConnected(): boolean {
 605      return this.connected
 606    }
 607  
 608    /**
 609     * Disconnect from the bunker.
 610     */
 611    disconnect(): void {
 612      if (this.ws) {
 613        this.ws.close()
 614        this.ws = null
 615      }
 616      this.connected = false
 617      this.pendingRequests.forEach((pending) => {
 618        clearTimeout(pending.timeout)
 619        pending.reject(new Error('Disconnected'))
 620      })
 621      this.pendingRequests.clear()
 622    }
 623  
 624    /**
 625     * Get the bunker's public key.
 626     */
 627    getBunkerPubkey(): string {
 628      return this.bunkerPubkey
 629    }
 630  
 631    /**
 632     * Get the relay URLs.
 633     */
 634    getRelayUrls(): string[] {
 635      return this.relayUrls
 636    }
 637  
 638    /**
 639     * Get the bunker URL for sharing.
 640     */
 641    getBunkerUrl(): string {
 642      return buildBunkerUrl(this.bunkerPubkey, this.relayUrls)
 643    }
 644  }
 645