nrc-listener.service.ts raw

   1  /**
   2   * NRC (Nostr Relay Connect) Listener Service
   3   *
   4   * Listens for NRC requests (kind 24891) on a rendezvous relay and responds
   5   * with events from the local IndexedDB. This allows other user clients to
   6   * sync their data through this client.
   7   *
   8   * Protocol:
   9   * - Client sends kind 24891 request with encrypted REQ/CLOSE message
  10   * - This listener decrypts, queries local storage, and responds with kind 24892
  11   * - All content is NIP-44 encrypted end-to-end
  12   */
  13  
  14  import { Event, Filter } from 'nostr-tools'
  15  import * as utils from '@noble/hashes/utils'
  16  import indexedDb from '@/services/indexed-db.service'
  17  import {
  18    KIND_NRC_REQUEST,
  19    KIND_NRC_RESPONSE,
  20    NRCListenerConfig,
  21    RequestMessage,
  22    ResponseMessage,
  23    AuthResult,
  24    NRCSession,
  25    isDeviceSpecificEvent,
  26    EventManifestEntry
  27  } from './nrc-types'
  28  import { NRCSessionManager } from './nrc-session'
  29  
  30  /**
  31   * Generate a random subscription ID
  32   */
  33  function generateSubId(): string {
  34    const bytes = crypto.getRandomValues(new Uint8Array(8))
  35    return utils.bytesToHex(bytes)
  36  }
  37  
  38  /**
  39   * NRC Listener Service
  40   *
  41   * Listens for incoming NRC requests and responds with local events.
  42   */
  43  export class NRCListenerService {
  44    private config: NRCListenerConfig | null = null
  45    private sessions: NRCSessionManager
  46    private ws: WebSocket | null = null
  47    private subId: string | null = null
  48    private connected = false
  49    private running = false
  50    private reconnectTimeout: ReturnType<typeof setTimeout> | null = null
  51    private reconnectDelay = 1000 // Start with 1 second
  52    private maxReconnectDelay = 30000 // Max 30 seconds
  53    private listenerPubkey: string | null = null
  54  
  55    // Event callbacks
  56    private onSessionChange?: (count: number) => void
  57  
  58    constructor() {
  59      this.sessions = new NRCSessionManager()
  60    }
  61  
  62    /**
  63     * Set callback for session count changes
  64     */
  65    setOnSessionChange(callback: (count: number) => void): void {
  66      this.onSessionChange = callback
  67    }
  68  
  69    /**
  70     * Start listening for NRC requests
  71     */
  72    async start(config: NRCListenerConfig): Promise<void> {
  73      if (this.running) {
  74        console.warn('[NRC] Listener already running')
  75        return
  76      }
  77  
  78      this.config = config
  79      this.running = true
  80  
  81      // Get our public key
  82      this.listenerPubkey = await config.signer.getPublicKey()
  83  
  84      // Start session cleanup
  85      this.sessions.start()
  86  
  87      // Connect to rendezvous relay
  88      await this.connectToRelay()
  89    }
  90  
  91    /**
  92     * Stop listening
  93     */
  94    stop(): void {
  95      this.running = false
  96  
  97      if (this.reconnectTimeout) {
  98        clearTimeout(this.reconnectTimeout)
  99        this.reconnectTimeout = null
 100      }
 101  
 102      if (this.ws) {
 103        // Unsubscribe
 104        if (this.subId) {
 105          try {
 106            this.ws.send(JSON.stringify(['CLOSE', this.subId]))
 107          } catch {
 108            // Ignore errors when closing
 109          }
 110        }
 111        this.ws.close()
 112        this.ws = null
 113      }
 114  
 115      this.sessions.stop()
 116      this.connected = false
 117      this.subId = null
 118  
 119      console.log('[NRC] Listener stopped')
 120    }
 121  
 122    /**
 123     * Check if listener is running
 124     */
 125    isRunning(): boolean {
 126      return this.running
 127    }
 128  
 129    /**
 130     * Check if connected to rendezvous relay
 131     */
 132    isConnected(): boolean {
 133      return this.connected
 134    }
 135  
 136    /**
 137     * Get active session count
 138     */
 139    getActiveSessionCount(): number {
 140      return this.sessions.getActiveSessionCount()
 141    }
 142  
 143    /**
 144     * Connect to the rendezvous relay
 145     */
 146    private async connectToRelay(): Promise<void> {
 147      if (!this.config || !this.running) return Promise.resolve()
 148  
 149      const relayUrl = this.config.rendezvousUrl
 150  
 151      return new Promise<void>((resolve, reject) => {
 152        // Normalize WebSocket URL
 153        let wsUrl = relayUrl
 154        if (relayUrl.startsWith('http://')) {
 155          wsUrl = 'ws://' + relayUrl.slice(7)
 156        } else if (relayUrl.startsWith('https://')) {
 157          wsUrl = 'wss://' + relayUrl.slice(8)
 158        } else if (!relayUrl.startsWith('ws://') && !relayUrl.startsWith('wss://')) {
 159          wsUrl = 'wss://' + relayUrl
 160        }
 161  
 162        console.log(`[NRC] Connecting to rendezvous relay: ${wsUrl}`)
 163  
 164        const ws = new WebSocket(wsUrl)
 165  
 166        const timeout = setTimeout(() => {
 167          ws.close()
 168          reject(new Error('Connection timeout'))
 169        }, 10000)
 170  
 171        ws.onopen = () => {
 172          clearTimeout(timeout)
 173          this.ws = ws
 174          this.connected = true
 175          this.reconnectDelay = 1000 // Reset reconnect delay on success
 176  
 177          // Subscribe to NRC requests for our pubkey
 178          this.subId = generateSubId()
 179          ws.send(
 180            JSON.stringify([
 181              'REQ',
 182              this.subId,
 183              {
 184                kinds: [KIND_NRC_REQUEST],
 185                '#p': [this.listenerPubkey],
 186                since: Math.floor(Date.now() / 1000) - 60
 187              }
 188            ])
 189          )
 190  
 191          console.log(`[NRC] Connected and subscribed with subId: ${this.subId}, listening for pubkey: ${this.listenerPubkey}`)
 192          resolve()
 193        }
 194  
 195        ws.onerror = (error) => {
 196          clearTimeout(timeout)
 197          console.error('[NRC] WebSocket error:', error)
 198          reject(new Error('WebSocket error'))
 199        }
 200  
 201        ws.onclose = () => {
 202          this.connected = false
 203          this.ws = null
 204          this.subId = null
 205          console.log('[NRC] WebSocket closed')
 206  
 207          // Attempt reconnection if still running
 208          if (this.running) {
 209            this.scheduleReconnect()
 210          }
 211        }
 212  
 213        ws.onmessage = (event) => {
 214          this.handleMessage(event.data)
 215        }
 216      }).catch((error) => {
 217        console.error('[NRC] Failed to connect:', error)
 218        if (this.running) {
 219          this.scheduleReconnect()
 220        }
 221      })
 222    }
 223  
 224    /**
 225     * Schedule reconnection with exponential backoff
 226     */
 227    private scheduleReconnect(): void {
 228      if (this.reconnectTimeout || !this.running) return
 229  
 230      console.log(`[NRC] Scheduling reconnect in ${this.reconnectDelay}ms`)
 231  
 232      this.reconnectTimeout = setTimeout(() => {
 233        this.reconnectTimeout = null
 234        this.connectToRelay()
 235      }, this.reconnectDelay)
 236  
 237      // Exponential backoff
 238      this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay)
 239    }
 240  
 241    /**
 242     * Handle incoming WebSocket message
 243     */
 244    private handleMessage(data: string): void {
 245      try {
 246        const msg = JSON.parse(data)
 247        if (!Array.isArray(msg)) return
 248  
 249        const [type, ...rest] = msg
 250  
 251        if (type === 'EVENT') {
 252          const [, event] = rest as [string, Event]
 253          if (event.kind === KIND_NRC_REQUEST) {
 254            console.log('[NRC] Received NRC request from pubkey:', event.pubkey)
 255            this.handleRequest(event).catch((err) => {
 256              console.error('[NRC] Error handling request:', err)
 257            })
 258          }
 259        } else if (type === 'EOSE') {
 260          // End of stored events, listener is now live
 261          console.log('[NRC] Received EOSE, now listening for live events')
 262        } else if (type === 'NOTICE') {
 263          console.log('[NRC] Relay notice:', rest[0])
 264        } else if (type === 'OK') {
 265          // Event published successfully
 266        } else if (type === 'CLOSED') {
 267          console.log('[NRC] Subscription closed:', rest)
 268        }
 269      } catch (err) {
 270        console.error('[NRC] Failed to parse message:', err)
 271      }
 272    }
 273  
 274    /**
 275     * Handle an NRC request event
 276     */
 277    private async handleRequest(event: Event): Promise<void> {
 278      if (!this.config) return
 279  
 280      // Extract session ID from tags (used for correlation but we use pubkey-based sessions)
 281      const sessionTag = event.tags.find((t) => t[0] === 'session')
 282      const _sessionId = sessionTag?.[1]
 283      void _sessionId // Suppress unused variable warning
 284  
 285      try {
 286        // Authorize the request
 287        const authResult = await this.authorize(event)
 288  
 289        // Get or create session
 290        const session = this.sessions.getOrCreateSession(
 291          event.pubkey,
 292          undefined, // We use signer's nip44 methods instead of conversationKey
 293          authResult.deviceName
 294        )
 295  
 296        // Notify session change
 297        this.onSessionChange?.(this.sessions.getActiveSessionCount())
 298  
 299        // Decrypt the content using signer
 300        const plaintext = await this.decrypt(event.pubkey, event.content)
 301        const request: RequestMessage = JSON.parse(plaintext)
 302        console.log('[NRC] Received request:', request.type)
 303  
 304        // Handle the request based on type
 305        switch (request.type) {
 306          case 'REQ':
 307            await this.handleREQ(event, session, request.payload)
 308            break
 309          case 'CLOSE':
 310            await this.handleCLOSE(session, request.payload)
 311            break
 312          case 'EVENT':
 313            await this.handleEVENT(event, session, request.payload)
 314            break
 315          case 'IDS':
 316            // Return just event IDs matching filters (for diffing)
 317            await this.handleIDS(event, session, request.payload)
 318            break
 319          case 'COUNT':
 320            // Not implemented
 321            await this.sendError(event, session, 'COUNT not supported')
 322            break
 323          default:
 324            await this.sendError(event, session, `Unknown message type: ${request.type}`)
 325        }
 326      } catch (err) {
 327        console.error('[NRC] Request handling failed:', err)
 328        // Try to send error response (best effort)
 329        try {
 330          await this.sendErrorBestEffort(event, `Request failed: ${err instanceof Error ? err.message : 'Unknown error'}`)
 331        } catch {
 332          // Ignore errors when sending error response
 333        }
 334      }
 335    }
 336  
 337    /**
 338     * Authorize an incoming request
 339     */
 340    private async authorize(event: Event): Promise<AuthResult> {
 341      if (!this.config) {
 342        throw new Error('Listener not configured')
 343      }
 344  
 345      // Secret-based auth: check if pubkey is authorized
 346      const deviceName = this.config.authorizedSecrets.get(event.pubkey)
 347      if (!deviceName) {
 348        console.log('[NRC] Unauthorized pubkey:', event.pubkey)
 349        console.log('[NRC] Authorized pubkeys:', Array.from(this.config.authorizedSecrets.keys()))
 350        console.log('[NRC] Authorized pubkeys (full):', JSON.stringify(Array.from(this.config.authorizedSecrets.entries())))
 351        throw new Error('Unauthorized: unknown client pubkey')
 352      }
 353  
 354      return {
 355        deviceName
 356      }
 357    }
 358  
 359    /**
 360     * Decrypt content using the signer's NIP-44 implementation
 361     */
 362    private async decrypt(clientPubkey: string, ciphertext: string): Promise<string> {
 363      if (!this.config) {
 364        throw new Error('Listener not configured')
 365      }
 366  
 367      if (!this.config.signer.nip44Decrypt) {
 368        throw new Error('Signer does not support NIP-44 decryption')
 369      }
 370  
 371      return this.config.signer.nip44Decrypt(clientPubkey, ciphertext)
 372    }
 373  
 374    /**
 375     * Encrypt content using the signer's NIP-44 implementation
 376     */
 377    private async encrypt(clientPubkey: string, plaintext: string): Promise<string> {
 378      if (!this.config) {
 379        throw new Error('Listener not configured')
 380      }
 381  
 382      if (!this.config.signer.nip44Encrypt) {
 383        throw new Error('Signer does not support NIP-44 encryption')
 384      }
 385  
 386      return this.config.signer.nip44Encrypt(clientPubkey, plaintext)
 387    }
 388  
 389    // Max chunk size (accounting for encryption overhead and event wrapper)
 390    // NIP-44 adds ~100 bytes overhead, plus base64 encoding increases size by ~33%
 391    private static readonly MAX_CHUNK_SIZE = 40000 // ~40KB chunks to stay safely under 65KB limit
 392  
 393    /**
 394     * Handle REQ message - query local storage and respond
 395     */
 396    private async handleREQ(
 397      reqEvent: Event,
 398      session: NRCSession,
 399      payload: unknown[]
 400    ): Promise<void> {
 401      // Parse REQ: ["REQ", subId, filter1, filter2, ...]
 402      if (payload.length < 2) {
 403        await this.sendError(reqEvent, session, 'Invalid REQ: missing subscription ID or filters')
 404        return
 405      }
 406  
 407      const [, subId, ...filterObjs] = payload as [string, string, ...Filter[]]
 408  
 409      // Add subscription to session
 410      const subscription = this.sessions.addSubscription(session.id, subId, filterObjs)
 411      if (!subscription) {
 412        await this.sendError(reqEvent, session, 'Too many subscriptions')
 413        return
 414      }
 415  
 416      // Query local events matching the filters
 417      const events = await this.queryLocalEvents(filterObjs)
 418      console.log(`[NRC] Found ${events.length} events matching filters`)
 419  
 420      // Send each matching event
 421      for (const evt of events) {
 422        const response: ResponseMessage = {
 423          type: 'EVENT',
 424          payload: ['EVENT', subId, evt]
 425        }
 426  
 427        try {
 428          await this.sendResponseChunked(reqEvent, session, response)
 429          this.sessions.incrementEventCount(session.id, subId)
 430        } catch (err) {
 431          console.error(`[NRC] Failed to send event ${evt.id?.slice(0, 8)}:`, err)
 432        }
 433      }
 434  
 435      // Send EOSE
 436      const eoseResponse: ResponseMessage = {
 437        type: 'EOSE',
 438        payload: ['EOSE', subId]
 439      }
 440      await this.sendResponse(reqEvent, session, eoseResponse)
 441      this.sessions.markEOSE(session.id, subId)
 442      console.log(`[NRC] Sent EOSE for subscription ${subId}`)
 443    }
 444  
 445    /**
 446     * Handle CLOSE message
 447     */
 448    private async handleCLOSE(session: NRCSession, payload: unknown[]): Promise<void> {
 449      // Parse CLOSE: ["CLOSE", subId]
 450      const [, subId] = payload as [string, string]
 451      if (subId) {
 452        this.sessions.removeSubscription(session.id, subId)
 453      }
 454    }
 455  
 456    /**
 457     * Handle EVENT message - store an event from the remote device
 458     */
 459    private async handleEVENT(
 460      reqEvent: Event,
 461      session: NRCSession,
 462      payload: unknown[]
 463    ): Promise<void> {
 464      // Parse EVENT: ["EVENT", eventObject]
 465      const [, eventToStore] = payload as [string, Event]
 466  
 467      if (!eventToStore || !eventToStore.id || !eventToStore.sig) {
 468        await this.sendError(reqEvent, session, 'Invalid EVENT: missing event data')
 469        return
 470      }
 471  
 472      try {
 473        // Store the event in IndexedDB
 474        await indexedDb.putReplaceableEvent(eventToStore)
 475        console.log(`[NRC] Stored event ${eventToStore.id.slice(0, 8)} kind ${eventToStore.kind} from ${session.deviceName}`)
 476  
 477        // Send OK response
 478        const response: ResponseMessage = {
 479          type: 'OK',
 480          payload: ['OK', eventToStore.id, true, '']
 481        }
 482        await this.sendResponse(reqEvent, session, response)
 483      } catch (err) {
 484        console.error('[NRC] Failed to store event:', err)
 485        const response: ResponseMessage = {
 486          type: 'OK',
 487          payload: ['OK', eventToStore.id, false, `Failed to store: ${err instanceof Error ? err.message : 'Unknown error'}`]
 488        }
 489        await this.sendResponse(reqEvent, session, response)
 490      }
 491    }
 492  
 493    /**
 494     * Handle IDS message - return event IDs matching filters (for diffing)
 495     * Similar to REQ but returns only IDs, not full events
 496     */
 497    private async handleIDS(
 498      reqEvent: Event,
 499      session: NRCSession,
 500      payload: unknown[]
 501    ): Promise<void> {
 502      // Parse IDS: ["IDS", subId, filter1, filter2, ...]
 503      if (payload.length < 2) {
 504        await this.sendError(reqEvent, session, 'Invalid IDS: missing subscription ID or filters')
 505        return
 506      }
 507  
 508      const [, subId, ...filterObjs] = payload as [string, string, ...Filter[]]
 509  
 510      // Query local events matching the filters
 511      const events = await this.queryLocalEvents(filterObjs)
 512      console.log(`[NRC] Found ${events.length} events for IDS request`)
 513  
 514      // Build manifest of event IDs with metadata for diffing
 515      const manifest: EventManifestEntry[] = events.map((evt) => ({
 516        kind: evt.kind,
 517        id: evt.id,
 518        created_at: evt.created_at,
 519        d: evt.tags.find((t) => t[0] === 'd')?.[1]
 520      }))
 521  
 522      // Send IDS response with the manifest
 523      const response: ResponseMessage = {
 524        type: 'IDS',
 525        payload: ['IDS', subId, manifest]
 526      }
 527      await this.sendResponseChunked(reqEvent, session, response)
 528      console.log(`[NRC] Sent IDS response with ${manifest.length} entries`)
 529    }
 530  
 531    /**
 532     * Query local IndexedDB for events matching filters
 533     */
 534    private async queryLocalEvents(filters: Filter[]): Promise<Event[]> {
 535      // Get all events from IndexedDB and filter
 536      const allEvents = await indexedDb.queryEventsForNRC(filters)
 537  
 538      // Filter out device-specific events
 539      return allEvents.filter((evt) => !isDeviceSpecificEvent(evt))
 540    }
 541  
 542    /**
 543     * Send an encrypted response
 544     */
 545    private async sendResponse(
 546      reqEvent: Event,
 547      session: NRCSession,
 548      response: ResponseMessage
 549    ): Promise<void> {
 550      if (!this.ws || !this.config || !this.listenerPubkey) {
 551        throw new Error('Not connected')
 552      }
 553  
 554      // Encrypt the response using signer
 555      const plaintext = JSON.stringify(response)
 556      const encrypted = await this.encrypt(session.clientPubkey, plaintext)
 557  
 558      // Build the response event
 559      const unsignedEvent = {
 560        kind: KIND_NRC_RESPONSE,
 561        content: encrypted,
 562        tags: [
 563          ['p', reqEvent.pubkey],
 564          ['encryption', 'nip44_v2'],
 565          ['session', session.id],
 566          ['e', reqEvent.id]
 567        ],
 568        created_at: Math.floor(Date.now() / 1000)
 569      }
 570  
 571      // Sign with our signer
 572      const signedEvent = await this.config.signer.signEvent(unsignedEvent)
 573  
 574      // Publish to rendezvous relay
 575      this.ws.send(JSON.stringify(['EVENT', signedEvent]))
 576    }
 577  
 578    /**
 579     * Send a response, chunking if necessary for large payloads
 580     */
 581    private async sendResponseChunked(
 582      reqEvent: Event,
 583      session: NRCSession,
 584      response: ResponseMessage
 585    ): Promise<void> {
 586      const plaintext = JSON.stringify(response)
 587  
 588      // If small enough, send directly
 589      if (plaintext.length <= NRCListenerService.MAX_CHUNK_SIZE) {
 590        await this.sendResponse(reqEvent, session, response)
 591        return
 592      }
 593  
 594      // Need to chunk - convert to base64 for safe transmission
 595      const encoded = btoa(unescape(encodeURIComponent(plaintext)))
 596      const chunks: string[] = []
 597  
 598      // Split into chunks
 599      for (let i = 0; i < encoded.length; i += NRCListenerService.MAX_CHUNK_SIZE) {
 600        chunks.push(encoded.slice(i, i + NRCListenerService.MAX_CHUNK_SIZE))
 601      }
 602  
 603      const messageId = crypto.randomUUID()
 604      console.log(`[NRC] Chunking large message (${plaintext.length} bytes) into ${chunks.length} chunks`)
 605  
 606      // Send each chunk
 607      for (let i = 0; i < chunks.length; i++) {
 608        const chunkResponse: ResponseMessage = {
 609          type: 'CHUNK',
 610          payload: [{
 611            type: 'CHUNK',
 612            messageId,
 613            index: i,
 614            total: chunks.length,
 615            data: chunks[i]
 616          }]
 617        }
 618        await this.sendResponse(reqEvent, session, chunkResponse)
 619      }
 620    }
 621  
 622    /**
 623     * Send an error response
 624     */
 625    private async sendError(
 626      reqEvent: Event,
 627      session: NRCSession,
 628      message: string
 629    ): Promise<void> {
 630      const response: ResponseMessage = {
 631        type: 'NOTICE',
 632        payload: ['NOTICE', message]
 633      }
 634      await this.sendResponse(reqEvent, session, response)
 635    }
 636  
 637    /**
 638     * Send error response with best-effort encryption
 639     */
 640    private async sendErrorBestEffort(reqEvent: Event, message: string): Promise<void> {
 641      if (!this.ws || !this.config || !this.listenerPubkey) {
 642        return
 643      }
 644  
 645      try {
 646        const response: ResponseMessage = {
 647          type: 'NOTICE',
 648          payload: ['NOTICE', message]
 649        }
 650  
 651        const plaintext = JSON.stringify(response)
 652        const encrypted = await this.encrypt(reqEvent.pubkey, plaintext)
 653  
 654        const unsignedEvent = {
 655          kind: KIND_NRC_RESPONSE,
 656          content: encrypted,
 657          tags: [
 658            ['p', reqEvent.pubkey],
 659            ['encryption', 'nip44_v2'],
 660            ['e', reqEvent.id]
 661          ],
 662          created_at: Math.floor(Date.now() / 1000)
 663        }
 664  
 665        const signedEvent = await this.config.signer.signEvent(unsignedEvent)
 666        this.ws.send(JSON.stringify(['EVENT', signedEvent]))
 667      } catch {
 668        // Best effort - ignore errors
 669      }
 670    }
 671  }
 672  
 673  // Singleton instance
 674  let instance: NRCListenerService | null = null
 675  
 676  export function getNRCListenerService(): NRCListenerService {
 677    if (!instance) {
 678      instance = new NRCListenerService()
 679    }
 680    return instance
 681  }
 682  
 683  export default getNRCListenerService()
 684