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