bridge.go raw

   1  package nrc
   2  
   3  import (
   4  	"context"
   5  	"crypto/rand"
   6  	"encoding/base64"
   7  	"encoding/json"
   8  	"fmt"
   9  	"sync"
  10  	"time"
  11  
  12  	"next.orly.dev/pkg/nostr/crypto/encryption"
  13  	"next.orly.dev/pkg/nostr/encoders/event"
  14  	"next.orly.dev/pkg/nostr/encoders/filter"
  15  	"next.orly.dev/pkg/nostr/encoders/hex"
  16  	"next.orly.dev/pkg/nostr/encoders/kind"
  17  	"next.orly.dev/pkg/nostr/encoders/tag"
  18  	"next.orly.dev/pkg/nostr/encoders/timestamp"
  19  	"next.orly.dev/pkg/nostr/interfaces/signer"
  20  	"next.orly.dev/pkg/nostr/ws"
  21  	"next.orly.dev/pkg/lol/chk"
  22  	"next.orly.dev/pkg/lol/log"
  23  )
  24  
  25  const (
  26  	// KindNRCRequest is the event kind for NRC requests.
  27  	KindNRCRequest = 24891
  28  	// KindNRCResponse is the event kind for NRC responses.
  29  	KindNRCResponse = 24892
  30  	// MaxChunkSize is the maximum size for a single chunk (40KB to stay under 65KB limit after NIP-44 + base64).
  31  	MaxChunkSize = 40000
  32  )
  33  
  34  // NRCAuthorizer defines the interface for NRC authorization lookups.
  35  // This allows the bridge to look up authorized clients dynamically from the database.
  36  type NRCAuthorizer interface {
  37  	// GetNRCClientByPubkey looks up an authorized client by their derived pubkey.
  38  	// Returns the client ID, label, and whether the client was found.
  39  	// If not found, returns empty strings and false.
  40  	GetNRCClientByPubkey(derivedPubkey []byte) (id string, label string, found bool, err error)
  41  	// UpdateNRCClientLastUsed updates the last used timestamp for tracking.
  42  	UpdateNRCClientLastUsed(id string) error
  43  }
  44  
  45  // BridgeConfig holds configuration for the NRC bridge.
  46  type BridgeConfig struct {
  47  	// RendezvousURL is the WebSocket URL of the public relay.
  48  	RendezvousURL string
  49  	// LocalRelayURL is the WebSocket URL of the local private relay.
  50  	LocalRelayURL string
  51  	// Signer is the relay's signer for signing response events.
  52  	Signer signer.I
  53  	// AuthorizedSecrets maps derived pubkeys to device names (secret-based auth).
  54  	// Used when Authorizer is nil.
  55  	AuthorizedSecrets map[string]string
  56  	// Authorizer provides dynamic NRC authorization lookups from database.
  57  	// If set, this takes precedence over AuthorizedSecrets.
  58  	Authorizer NRCAuthorizer
  59  	// SessionTimeout is the inactivity timeout for sessions.
  60  	SessionTimeout time.Duration
  61  }
  62  
  63  // Bridge connects a private relay to a public rendezvous relay.
  64  type Bridge struct {
  65  	config   *BridgeConfig
  66  	sessions *SessionManager
  67  
  68  	// rendezvousConn is the connection to the rendezvous relay.
  69  	rendezvousConn *ws.Client
  70  
  71  	// mu protects connection state.
  72  	mu sync.RWMutex
  73  
  74  	// ctx is the bridge context.
  75  	ctx context.Context
  76  	// cancel cancels the bridge context.
  77  	cancel context.CancelFunc
  78  }
  79  
  80  // NewBridge creates a new NRC bridge.
  81  func NewBridge(config *BridgeConfig) *Bridge {
  82  	ctx, cancel := context.WithCancel(context.Background())
  83  	timeout := config.SessionTimeout
  84  	if timeout == 0 {
  85  		timeout = DefaultSessionTimeout
  86  	}
  87  	return &Bridge{
  88  		config:   config,
  89  		sessions: NewSessionManager(timeout),
  90  		ctx:      ctx,
  91  		cancel:   cancel,
  92  	}
  93  }
  94  
  95  // Start starts the bridge and begins listening for NRC requests.
  96  func (b *Bridge) Start() error {
  97  	log.I.F("starting NRC bridge, rendezvous: %s, local: %s",
  98  		b.config.RendezvousURL, b.config.LocalRelayURL)
  99  
 100  	// Start session cleanup goroutine
 101  	go b.cleanupLoop()
 102  
 103  	// Start the main bridge loop with auto-reconnection
 104  	go b.runLoop()
 105  
 106  	return nil
 107  }
 108  
 109  // Stop stops the bridge.
 110  func (b *Bridge) Stop() {
 111  	log.I.F("stopping NRC bridge")
 112  	b.cancel()
 113  	b.sessions.Close()
 114  
 115  	b.mu.Lock()
 116  	defer b.mu.Unlock()
 117  	if b.rendezvousConn != nil {
 118  		b.rendezvousConn.Close()
 119  	}
 120  }
 121  
 122  // UpdateAuthorizedSecrets updates the map of authorized secrets.
 123  // This allows dynamic management of authorized connections through the UI.
 124  func (b *Bridge) UpdateAuthorizedSecrets(secrets map[string]string) {
 125  	b.mu.Lock()
 126  	defer b.mu.Unlock()
 127  	b.config.AuthorizedSecrets = secrets
 128  }
 129  
 130  // cleanupLoop periodically cleans up expired sessions.
 131  func (b *Bridge) cleanupLoop() {
 132  	ticker := time.NewTicker(5 * time.Minute)
 133  	defer ticker.Stop()
 134  
 135  	for {
 136  		select {
 137  		case <-b.ctx.Done():
 138  			return
 139  		case <-ticker.C:
 140  			removed := b.sessions.CleanupExpired()
 141  			if removed > 0 {
 142  				log.D.F("cleaned up %d expired NRC sessions", removed)
 143  			}
 144  		}
 145  	}
 146  }
 147  
 148  // runLoop runs the main bridge loop with auto-reconnection.
 149  func (b *Bridge) runLoop() {
 150  	delay := time.Second
 151  
 152  	for {
 153  		select {
 154  		case <-b.ctx.Done():
 155  			return
 156  		default:
 157  		}
 158  
 159  		err := b.runOnce()
 160  		if err != nil {
 161  			if b.ctx.Err() != nil {
 162  				return // Context cancelled, exit cleanly
 163  			}
 164  			log.W.F("NRC bridge error: %v, reconnecting in %v", err, delay)
 165  			select {
 166  			case <-time.After(delay):
 167  				if delay < 30*time.Second {
 168  					delay *= 2
 169  				}
 170  			case <-b.ctx.Done():
 171  				return
 172  			}
 173  			continue
 174  		}
 175  		delay = time.Second
 176  	}
 177  }
 178  
 179  // runOnce runs a single iteration of the bridge.
 180  func (b *Bridge) runOnce() error {
 181  	// Connect to rendezvous relay
 182  	rendezvousConn, err := ws.RelayConnect(b.ctx, b.config.RendezvousURL)
 183  	if chk.E(err) {
 184  		return fmt.Errorf("%w: %v", ErrRendezvousConnectionFailed, err)
 185  	}
 186  	defer rendezvousConn.Close()
 187  
 188  	b.mu.Lock()
 189  	b.rendezvousConn = rendezvousConn
 190  	b.mu.Unlock()
 191  
 192  	// Subscribe to NRC request events
 193  	relayPubkeyHex := hex.Enc(b.config.Signer.Pub())
 194  	sub, err := rendezvousConn.Subscribe(
 195  		b.ctx,
 196  		filter.NewS(&filter.F{
 197  			Kinds: kind.NewS(kind.New(KindNRCRequest)),
 198  			Tags: tag.NewS(
 199  				tag.NewFromAny("p", relayPubkeyHex),
 200  			),
 201  			Since: &timestamp.T{V: time.Now().Unix()},
 202  		}),
 203  	)
 204  	if chk.E(err) {
 205  		return fmt.Errorf("subscription failed: %w", err)
 206  	}
 207  	defer sub.Unsub()
 208  
 209  	log.I.F("NRC bridge listening for requests on %s", b.config.RendezvousURL)
 210  
 211  	// Process incoming request events
 212  	for {
 213  		select {
 214  		case <-b.ctx.Done():
 215  			return nil
 216  		case ev := <-sub.Events:
 217  			if ev == nil {
 218  				return fmt.Errorf("subscription closed")
 219  			}
 220  			go b.handleRequest(ev)
 221  		}
 222  	}
 223  }
 224  
 225  // handleRequest handles a single NRC request event.
 226  func (b *Bridge) handleRequest(ev *event.E) {
 227  	ctx, cancel := context.WithTimeout(b.ctx, 30*time.Second)
 228  	defer cancel()
 229  
 230  	// Extract session ID from tags
 231  	sessionID := ""
 232  	sessionTag := ev.Tags.GetFirst([]byte("session"))
 233  	if sessionTag != nil && sessionTag.Len() >= 2 {
 234  		sessionID = string(sessionTag.Value())
 235  	}
 236  	if sessionID == "" {
 237  		log.W.F("NRC request missing session tag from %s", hex.Enc(ev.Pubkey[:]))
 238  		return
 239  	}
 240  
 241  	// Verify authorization
 242  	conversationKey, authMode, deviceName, err := b.authorize(ctx, ev)
 243  	if err != nil {
 244  		log.W.F("NRC authorization failed for %s: %v", hex.Enc(ev.Pubkey[:]), err)
 245  		b.sendError(ctx, ev, sessionID, "unauthorized: "+err.Error())
 246  		return
 247  	}
 248  
 249  	// Get or create session
 250  	session := b.sessions.GetOrCreate(sessionID, ev.Pubkey[:], conversationKey, authMode, deviceName)
 251  	session.Touch()
 252  
 253  	// Decrypt request content
 254  	decrypted, err := encryption.Decrypt(conversationKey, string(ev.Content))
 255  	if err != nil {
 256  		log.W.F("NRC decryption failed: %v", err)
 257  		b.sendError(ctx, ev, sessionID, "decryption failed")
 258  		return
 259  	}
 260  
 261  	// Parse request message
 262  	reqMsg, err := ParseRequestContent([]byte(decrypted))
 263  	if err != nil {
 264  		log.W.F("NRC invalid request format: %v", err)
 265  		b.sendError(ctx, ev, sessionID, "invalid request format")
 266  		return
 267  	}
 268  
 269  	log.D.F("NRC request: type=%s session=%s from=%s",
 270  		reqMsg.Type, sessionID, hex.Enc(ev.Pubkey[:]))
 271  
 272  	// Forward to local relay and handle response
 273  	if err := b.forwardToLocalRelay(ctx, session, ev, reqMsg); err != nil {
 274  		log.W.F("NRC forward failed: %v", err)
 275  		b.sendError(ctx, ev, sessionID, "relay error: "+err.Error())
 276  	}
 277  }
 278  
 279  // authorize checks if the request is authorized and returns the conversation key.
 280  func (b *Bridge) authorize(ctx context.Context, ev *event.E) (conversationKey []byte, authMode AuthMode, deviceName string, err error) {
 281  	clientPubkey := ev.Pubkey[:]
 282  	clientPubkeyHex := string(hex.Enc(clientPubkey))
 283  
 284  	// Try database-backed authorization first (if Authorizer is set)
 285  	if b.config.Authorizer != nil {
 286  		clientID, clientLabel, found, authErr := b.config.Authorizer.GetNRCClientByPubkey(clientPubkey)
 287  		if authErr == nil && found {
 288  			// Client is authorized via database
 289  			conversationKey, err = encryption.GenerateConversationKey(
 290  				b.config.Signer.Sec(),
 291  				clientPubkey,
 292  			)
 293  			if chk.E(err) {
 294  				return
 295  			}
 296  			authMode = AuthModeSecret
 297  			deviceName = clientLabel
 298  
 299  			// Update last used timestamp in background
 300  			go func() {
 301  				if updateErr := b.config.Authorizer.UpdateNRCClientLastUsed(clientID); updateErr != nil {
 302  					log.W.F("failed to update NRC client last used: %v", updateErr)
 303  				}
 304  			}()
 305  			return
 306  		}
 307  	}
 308  
 309  	// Fallback to static map (for backwards compatibility)
 310  	if name, ok := b.config.AuthorizedSecrets[clientPubkeyHex]; ok {
 311  		// Secret auth uses ECDH between relay key and client's derived key
 312  		conversationKey, err = encryption.GenerateConversationKey(
 313  			b.config.Signer.Sec(),
 314  			clientPubkey,
 315  		)
 316  		if chk.E(err) {
 317  			return
 318  		}
 319  		authMode = AuthModeSecret
 320  		deviceName = name
 321  		return
 322  	}
 323  
 324  	err = ErrUnauthorized
 325  	return
 326  }
 327  
 328  // forwardToLocalRelay forwards a request to the local relay and handles responses.
 329  func (b *Bridge) forwardToLocalRelay(ctx context.Context, session *Session, reqEvent *event.E, reqMsg *RequestMessage) error {
 330  	// Connect to local relay
 331  	localConn, err := ws.RelayConnect(ctx, b.config.LocalRelayURL)
 332  	if chk.E(err) {
 333  		return fmt.Errorf("%w: %v", ErrRelayConnectionFailed, err)
 334  	}
 335  	defer localConn.Close()
 336  
 337  	// Handle different message types
 338  	switch reqMsg.Type {
 339  	case "REQ":
 340  		return b.handleREQ(ctx, session, reqEvent, reqMsg, localConn)
 341  	case "EVENT":
 342  		return b.handleEVENT(ctx, session, reqEvent, reqMsg, localConn)
 343  	case "CLOSE":
 344  		return b.handleCLOSE(ctx, session, reqEvent, reqMsg)
 345  	case "COUNT":
 346  		return b.handleCOUNT(ctx, session, reqEvent, reqMsg, localConn)
 347  	case "IDS":
 348  		return b.handleIDS(ctx, session, reqEvent, reqMsg, localConn)
 349  	default:
 350  		return fmt.Errorf("unsupported message type: %s", reqMsg.Type)
 351  	}
 352  }
 353  
 354  // handleREQ handles a REQ message and forwards responses.
 355  func (b *Bridge) handleREQ(ctx context.Context, session *Session, reqEvent *event.E, reqMsg *RequestMessage, conn *ws.Client) error {
 356  	// Extract subscription ID and filters from payload
 357  	// Payload: ["REQ", "<sub_id>", filter1, filter2, ...]
 358  	if len(reqMsg.Payload) < 3 {
 359  		return fmt.Errorf("invalid REQ payload")
 360  	}
 361  	subID, ok := reqMsg.Payload[1].(string)
 362  	if !ok {
 363  		return fmt.Errorf("invalid subscription ID")
 364  	}
 365  
 366  	// Parse filters from payload
 367  	var filters []*filter.F
 368  	for i := 2; i < len(reqMsg.Payload); i++ {
 369  		filterMap, ok := reqMsg.Payload[i].(map[string]any)
 370  		if !ok {
 371  			continue
 372  		}
 373  		filterBytes, err := json.Marshal(filterMap)
 374  		if err != nil {
 375  			continue
 376  		}
 377  		var f filter.F
 378  		if err := json.Unmarshal(filterBytes, &f); err != nil {
 379  			continue
 380  		}
 381  		filters = append(filters, &f)
 382  	}
 383  
 384  	if len(filters) == 0 {
 385  		return fmt.Errorf("no valid filters in REQ")
 386  	}
 387  
 388  	// Add subscription to session
 389  	if err := session.AddSubscription(subID); err != nil {
 390  		return err
 391  	}
 392  
 393  	// Create filter set
 394  	filterSet := filter.NewS(filters...)
 395  
 396  	// Subscribe to local relay
 397  	sub, err := conn.Subscribe(ctx, filterSet)
 398  	if chk.E(err) {
 399  		session.RemoveSubscription(subID)
 400  		return fmt.Errorf("local subscribe failed: %w", err)
 401  	}
 402  	defer sub.Unsub()
 403  
 404  	// Forward events until EOSE or timeout
 405  	for {
 406  		select {
 407  		case <-ctx.Done():
 408  			return ctx.Err()
 409  		case ev := <-sub.Events:
 410  			if ev == nil {
 411  				// Subscription closed, send EOSE
 412  				resp := &ResponseMessage{
 413  					Type:    "EOSE",
 414  					Payload: []any{"EOSE", subID},
 415  				}
 416  				return b.sendResponse(ctx, reqEvent, session, resp)
 417  			}
 418  
 419  			// Convert event to JSON-compatible map
 420  			eventBytes, err := json.Marshal(ev)
 421  			if err != nil {
 422  				continue
 423  			}
 424  			var eventMap map[string]any
 425  			if err := json.Unmarshal(eventBytes, &eventMap); err != nil {
 426  				continue
 427  			}
 428  
 429  			// Send EVENT response
 430  			resp := &ResponseMessage{
 431  				Type:    "EVENT",
 432  				Payload: []any{"EVENT", subID, eventMap},
 433  			}
 434  			if err := b.sendResponse(ctx, reqEvent, session, resp); err != nil {
 435  				log.W.F("failed to send event response: %v", err)
 436  			}
 437  			session.IncrementEventCount(subID)
 438  		case <-sub.EndOfStoredEvents:
 439  			// Send EOSE
 440  			session.MarkEOSE(subID)
 441  			resp := &ResponseMessage{
 442  				Type:    "EOSE",
 443  				Payload: []any{"EOSE", subID},
 444  			}
 445  			return b.sendResponse(ctx, reqEvent, session, resp)
 446  		}
 447  	}
 448  }
 449  
 450  // handleEVENT handles an EVENT message and forwards the OK response.
 451  func (b *Bridge) handleEVENT(ctx context.Context, session *Session, reqEvent *event.E, reqMsg *RequestMessage, conn *ws.Client) error {
 452  	// Extract event from payload: ["EVENT", {...event...}]
 453  	if len(reqMsg.Payload) < 2 {
 454  		return fmt.Errorf("invalid EVENT payload")
 455  	}
 456  
 457  	eventMap, ok := reqMsg.Payload[1].(map[string]any)
 458  	if !ok {
 459  		return fmt.Errorf("invalid event data")
 460  	}
 461  
 462  	// Parse event
 463  	eventBytes, err := json.Marshal(eventMap)
 464  	if err != nil {
 465  		return fmt.Errorf("failed to marshal event: %w", err)
 466  	}
 467  
 468  	var ev event.E
 469  	if err := json.Unmarshal(eventBytes, &ev); err != nil {
 470  		return fmt.Errorf("failed to unmarshal event: %w", err)
 471  	}
 472  
 473  	// Publish to local relay
 474  	err = conn.Publish(ctx, &ev)
 475  	success := err == nil
 476  	message := ""
 477  	if err != nil {
 478  		message = err.Error()
 479  	}
 480  
 481  	// Send OK response
 482  	resp := &ResponseMessage{
 483  		Type:    "OK",
 484  		Payload: []any{"OK", string(hex.Enc(ev.ID[:])), success, message},
 485  	}
 486  	return b.sendResponse(ctx, reqEvent, session, resp)
 487  }
 488  
 489  // handleCLOSE handles a CLOSE message.
 490  func (b *Bridge) handleCLOSE(ctx context.Context, session *Session, reqEvent *event.E, reqMsg *RequestMessage) error {
 491  	// Extract subscription ID: ["CLOSE", "<sub_id>"]
 492  	if len(reqMsg.Payload) >= 2 {
 493  		if subID, ok := reqMsg.Payload[1].(string); ok {
 494  			session.RemoveSubscription(subID)
 495  		}
 496  	}
 497  	// CLOSE doesn't have a response
 498  	return nil
 499  }
 500  
 501  // handleCOUNT handles a COUNT message.
 502  func (b *Bridge) handleCOUNT(ctx context.Context, session *Session, reqEvent *event.E, reqMsg *RequestMessage, conn *ws.Client) error {
 503  	// COUNT is not supported via ws.Client directly, return error
 504  	resp := &ResponseMessage{
 505  		Type:    "NOTICE",
 506  		Payload: []any{"NOTICE", "COUNT not supported through NRC tunnel"},
 507  	}
 508  	return b.sendResponse(ctx, reqEvent, session, resp)
 509  }
 510  
 511  // handleIDS handles an IDS message - returns event manifests for diffing.
 512  func (b *Bridge) handleIDS(ctx context.Context, session *Session, reqEvent *event.E, reqMsg *RequestMessage, conn *ws.Client) error {
 513  	// Extract subscription ID and filters from payload
 514  	// Payload: ["IDS", "<sub_id>", filter1, filter2, ...]
 515  	if len(reqMsg.Payload) < 3 {
 516  		return fmt.Errorf("invalid IDS payload")
 517  	}
 518  	subID, ok := reqMsg.Payload[1].(string)
 519  	if !ok {
 520  		return fmt.Errorf("invalid subscription ID")
 521  	}
 522  
 523  	// Parse filters from payload
 524  	var filters []*filter.F
 525  	for i := 2; i < len(reqMsg.Payload); i++ {
 526  		filterMap, ok := reqMsg.Payload[i].(map[string]any)
 527  		if !ok {
 528  			continue
 529  		}
 530  		filterBytes, err := json.Marshal(filterMap)
 531  		if err != nil {
 532  			continue
 533  		}
 534  		var f filter.F
 535  		if err := json.Unmarshal(filterBytes, &f); err != nil {
 536  			continue
 537  		}
 538  		filters = append(filters, &f)
 539  	}
 540  
 541  	if len(filters) == 0 {
 542  		return fmt.Errorf("no valid filters in IDS")
 543  	}
 544  
 545  	// Add subscription to session
 546  	if err := session.AddSubscription(subID); err != nil {
 547  		return err
 548  	}
 549  	defer session.RemoveSubscription(subID)
 550  
 551  	// Create filter set
 552  	filterSet := filter.NewS(filters...)
 553  
 554  	// Subscribe to local relay
 555  	sub, err := conn.Subscribe(ctx, filterSet)
 556  	if chk.E(err) {
 557  		return fmt.Errorf("local subscribe failed: %w", err)
 558  	}
 559  	defer sub.Unsub()
 560  
 561  	// Collect events and build manifest
 562  	var manifest []EventManifestEntry
 563  	for {
 564  		select {
 565  		case <-ctx.Done():
 566  			return ctx.Err()
 567  		case ev := <-sub.Events:
 568  			if ev == nil {
 569  				// Subscription closed, send IDS response
 570  				return b.sendIDSResponse(ctx, reqEvent, session, subID, manifest)
 571  			}
 572  
 573  			// Build manifest entry
 574  			entry := EventManifestEntry{
 575  				Kind:      int(ev.Kind),
 576  				ID:        string(hex.Enc(ev.ID[:])),
 577  				CreatedAt: ev.CreatedAt,
 578  			}
 579  
 580  			// Check for d tag (parameterized replaceable events)
 581  			dTag := ev.Tags.GetFirst([]byte("d"))
 582  			if dTag != nil && dTag.Len() >= 2 {
 583  				entry.D = string(dTag.Value())
 584  			}
 585  
 586  			manifest = append(manifest, entry)
 587  		case <-sub.EndOfStoredEvents:
 588  			// Send IDS response with manifest
 589  			return b.sendIDSResponse(ctx, reqEvent, session, subID, manifest)
 590  		}
 591  	}
 592  }
 593  
 594  // sendIDSResponse sends an IDS response with the event manifest, chunking if necessary.
 595  func (b *Bridge) sendIDSResponse(ctx context.Context, reqEvent *event.E, session *Session, subID string, manifest []EventManifestEntry) error {
 596  	resp := &ResponseMessage{
 597  		Type:    "IDS",
 598  		Payload: []any{"IDS", subID, manifest},
 599  	}
 600  	return b.sendResponseChunked(ctx, reqEvent, session, resp)
 601  }
 602  
 603  // sendResponseChunked sends a response, chunking if necessary for large payloads.
 604  func (b *Bridge) sendResponseChunked(ctx context.Context, reqEvent *event.E, session *Session, resp *ResponseMessage) error {
 605  	// Marshal response content
 606  	content, err := MarshalResponseContent(resp)
 607  	if err != nil {
 608  		return fmt.Errorf("marshal failed: %w", err)
 609  	}
 610  
 611  	// If small enough, send directly
 612  	if len(content) <= MaxChunkSize {
 613  		return b.sendResponse(ctx, reqEvent, session, resp)
 614  	}
 615  
 616  	// Need to chunk - encode to base64 for safe transmission
 617  	encoded := base64.StdEncoding.EncodeToString(content)
 618  	var chunks []string
 619  
 620  	// Split into chunks
 621  	for i := 0; i < len(encoded); i += MaxChunkSize {
 622  		end := i + MaxChunkSize
 623  		if end > len(encoded) {
 624  			end = len(encoded)
 625  		}
 626  		chunks = append(chunks, encoded[i:end])
 627  	}
 628  
 629  	// Generate message ID
 630  	messageID := generateMessageID()
 631  	log.D.F("NRC: chunking large message (%d bytes) into %d chunks", len(content), len(chunks))
 632  
 633  	// Send each chunk
 634  	for i, chunkData := range chunks {
 635  		chunkMsg := ChunkMessage{
 636  			Type:      "CHUNK",
 637  			MessageID: messageID,
 638  			Index:     i,
 639  			Total:     len(chunks),
 640  			Data:      chunkData,
 641  		}
 642  
 643  		chunkResp := &ResponseMessage{
 644  			Type:    "CHUNK",
 645  			Payload: []any{chunkMsg},
 646  		}
 647  
 648  		if err := b.sendResponse(ctx, reqEvent, session, chunkResp); err != nil {
 649  			return fmt.Errorf("failed to send chunk %d/%d: %w", i+1, len(chunks), err)
 650  		}
 651  	}
 652  
 653  	return nil
 654  }
 655  
 656  // generateMessageID generates a random message ID for chunking.
 657  func generateMessageID() string {
 658  	b := make([]byte, 16)
 659  	rand.Read(b)
 660  	return string(hex.Enc(b))
 661  }
 662  
 663  // sendResponse encrypts and sends a response to the client.
 664  func (b *Bridge) sendResponse(ctx context.Context, reqEvent *event.E, session *Session, resp *ResponseMessage) error {
 665  	// Marshal response content
 666  	content, err := MarshalResponseContent(resp)
 667  	if err != nil {
 668  		return fmt.Errorf("marshal failed: %w", err)
 669  	}
 670  
 671  	// Encrypt content
 672  	encrypted, err := encryption.Encrypt(session.ConversationKey, content, nil)
 673  	if err != nil {
 674  		return fmt.Errorf("%w: %v", ErrEncryptionFailed, err)
 675  	}
 676  
 677  	// Build response event
 678  	respEvent := &event.E{
 679  		Content:   []byte(encrypted),
 680  		CreatedAt: time.Now().Unix(),
 681  		Kind:      KindNRCResponse,
 682  		Tags: tag.NewS(
 683  			tag.NewFromAny("p", hex.Enc(reqEvent.Pubkey[:])),
 684  			tag.NewFromAny("encryption", "nip44_v2"),
 685  			tag.NewFromAny("session", session.ID),
 686  			tag.NewFromAny("e", hex.Enc(reqEvent.ID[:])),
 687  		),
 688  	}
 689  
 690  	// Sign with relay key
 691  	if err := respEvent.Sign(b.config.Signer); chk.E(err) {
 692  		return fmt.Errorf("signing failed: %w", err)
 693  	}
 694  
 695  	// Publish to rendezvous relay
 696  	b.mu.RLock()
 697  	conn := b.rendezvousConn
 698  	b.mu.RUnlock()
 699  
 700  	if conn == nil {
 701  		return fmt.Errorf("not connected to rendezvous relay")
 702  	}
 703  
 704  	if err := conn.Publish(ctx, respEvent); chk.E(err) {
 705  		return fmt.Errorf("publish failed: %w", err)
 706  	}
 707  
 708  	return nil
 709  }
 710  
 711  // sendError sends an error response to the client.
 712  func (b *Bridge) sendError(ctx context.Context, reqEvent *event.E, sessionID string, errMsg string) {
 713  	// For errors, we need to get or create a conversation key
 714  	// This is best-effort since we may not be able to authenticate
 715  	conversationKey, err := encryption.GenerateConversationKey(
 716  		b.config.Signer.Sec(),
 717  		reqEvent.Pubkey[:],
 718  	)
 719  	if err != nil {
 720  		log.W.F("failed to generate conversation key for error response: %v", err)
 721  		return
 722  	}
 723  
 724  	resp := &ResponseMessage{
 725  		Type:    "NOTICE",
 726  		Payload: []any{"NOTICE", "nrc: " + errMsg},
 727  	}
 728  
 729  	content, err := MarshalResponseContent(resp)
 730  	if err != nil {
 731  		return
 732  	}
 733  
 734  	encrypted, err := encryption.Encrypt(conversationKey, content, nil)
 735  	if err != nil {
 736  		return
 737  	}
 738  
 739  	respEvent := &event.E{
 740  		Content:   []byte(encrypted),
 741  		CreatedAt: time.Now().Unix(),
 742  		Kind:      KindNRCResponse,
 743  		Tags: tag.NewS(
 744  			tag.NewFromAny("p", hex.Enc(reqEvent.Pubkey[:])),
 745  			tag.NewFromAny("encryption", "nip44_v2"),
 746  			tag.NewFromAny("session", sessionID),
 747  			tag.NewFromAny("e", hex.Enc(reqEvent.ID[:])),
 748  		),
 749  	}
 750  
 751  	if err := respEvent.Sign(b.config.Signer); err != nil {
 752  		return
 753  	}
 754  
 755  	b.mu.RLock()
 756  	conn := b.rendezvousConn
 757  	b.mu.RUnlock()
 758  
 759  	if conn != nil {
 760  		conn.Publish(ctx, respEvent)
 761  	}
 762  }
 763  
 764  // AddAuthorizedSecret adds an authorized secret (derived pubkey).
 765  func (b *Bridge) AddAuthorizedSecret(pubkeyHex, deviceName string) {
 766  	b.config.AuthorizedSecrets[pubkeyHex] = deviceName
 767  }
 768  
 769  // RemoveAuthorizedSecret removes an authorized secret.
 770  func (b *Bridge) RemoveAuthorizedSecret(pubkeyHex string) {
 771  	delete(b.config.AuthorizedSecrets, pubkeyHex)
 772  }
 773  
 774  // ListAuthorizedSecrets returns a copy of the authorized secrets map.
 775  func (b *Bridge) ListAuthorizedSecrets() map[string]string {
 776  	result := make(map[string]string)
 777  	for k, v := range b.config.AuthorizedSecrets {
 778  		result[k] = v
 779  	}
 780  	return result
 781  }
 782  
 783  // SessionCount returns the number of active sessions.
 784  func (b *Bridge) SessionCount() int {
 785  	return b.sessions.Count()
 786  }
 787