From ecd7c364007032a35a5c2dbac0008e3fa1c6ac97 Mon Sep 17 00:00:00 2001 From: woikos Date: Sun, 11 Jan 2026 09:16:03 +0100 Subject: [PATCH] Add NRC (Nostr Relay Connect) for cross-device sync Implements NRC listener that allows other user clients to connect and sync events through a rendezvous relay. Features: - REQ-only (read) sync for security - Secret-based and CAT token authentication - NIP-44 encrypted tunneling - Device-specific event filtering via d-tag prefix - Session management with timeouts - Settings UI with QR code connection flow Co-Authored-By: Claude Opus 4.5 --- src/App.tsx | 3 + src/components/NRCSettings/index.tsx | 409 +++++++++++++++++ src/components/Settings/index.tsx | 23 +- src/providers/NRCProvider.tsx | 310 +++++++++++++ src/services/indexed-db.service.ts | 80 +++- src/services/nrc/index.ts | 4 + src/services/nrc/nrc-listener.service.ts | 558 +++++++++++++++++++++++ src/services/nrc/nrc-session.ts | 240 ++++++++++ src/services/nrc/nrc-types.ts | 108 +++++ src/services/nrc/nrc-uri.ts | 189 ++++++++ 10 files changed, 1921 insertions(+), 3 deletions(-) create mode 100644 src/components/NRCSettings/index.tsx create mode 100644 src/providers/NRCProvider.tsx create mode 100644 src/services/nrc/index.ts create mode 100644 src/services/nrc/nrc-listener.service.ts create mode 100644 src/services/nrc/nrc-session.ts create mode 100644 src/services/nrc/nrc-types.ts create mode 100644 src/services/nrc/nrc-uri.ts diff --git a/src/App.tsx b/src/App.tsx index 2ddb2464..68d8175f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import { SocialGraphFilterProvider } from '@/providers/SocialGraphFilterProvider import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider' import { MuteListProvider } from '@/providers/MuteListProvider' import { NostrProvider } from '@/providers/NostrProvider' +import { NRCProvider } from '@/providers/NRCProvider' import { PasswordPromptProvider } from '@/providers/PasswordPromptProvider' import { PinListProvider } from '@/providers/PinListProvider' import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider' @@ -38,6 +39,7 @@ export default function App(): JSX.Element { + @@ -72,6 +74,7 @@ export default function App(): JSX.Element { + diff --git a/src/components/NRCSettings/index.tsx b/src/components/NRCSettings/index.tsx new file mode 100644 index 00000000..b76c9062 --- /dev/null +++ b/src/components/NRCSettings/index.tsx @@ -0,0 +1,409 @@ +/** + * NRC Settings Component + * + * UI for managing Nostr Relay Connect (NRC) connections and listener settings. + */ + +import { useState, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useNRC } from '@/providers/NRCProvider' +import { useNostr } from '@/providers/NostrProvider' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from '@/components/ui/alert-dialog' +import { + Link2, + Plus, + Trash2, + Copy, + Check, + QrCode, + Wifi, + WifiOff, + Users, + Server +} from 'lucide-react' +import { NRCConnection } from '@/services/nrc' +import QRCode from 'qrcode' + +export default function NRCSettings() { + const { t } = useTranslation() + const { pubkey } = useNostr() + const { + isEnabled, + isConnected, + connections, + activeSessions, + rendezvousUrl, + enable, + disable, + addConnection, + removeConnection, + getConnectionURI, + setRendezvousUrl + } = useNRC() + + const [newConnectionLabel, setNewConnectionLabel] = useState('') + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) + const [isQRDialogOpen, setIsQRDialogOpen] = useState(false) + const [currentQRConnection, setCurrentQRConnection] = useState(null) + const [currentQRUri, setCurrentQRUri] = useState('') + const [qrDataUrl, setQrDataUrl] = useState('') + const [copiedUri, setCopiedUri] = useState(false) + const [isLoading, setIsLoading] = useState(false) + + // Generate QR code when URI changes + const generateQRCode = useCallback(async (uri: string) => { + try { + const dataUrl = await QRCode.toDataURL(uri, { + width: 256, + margin: 2, + color: { dark: '#000000', light: '#ffffff' } + }) + setQrDataUrl(dataUrl) + } catch (error) { + console.error('Failed to generate QR code:', error) + } + }, []) + + const handleToggleEnabled = useCallback(async () => { + if (isEnabled) { + disable() + } else { + setIsLoading(true) + try { + await enable() + } catch (error) { + console.error('Failed to enable NRC:', error) + } finally { + setIsLoading(false) + } + } + }, [isEnabled, enable, disable]) + + const handleAddConnection = useCallback(async () => { + if (!newConnectionLabel.trim()) return + + setIsLoading(true) + try { + const { uri, connection } = await addConnection(newConnectionLabel.trim()) + setIsAddDialogOpen(false) + setNewConnectionLabel('') + + // Show QR code + setCurrentQRConnection(connection) + setCurrentQRUri(uri) + await generateQRCode(uri) + setIsQRDialogOpen(true) + } catch (error) { + console.error('Failed to add connection:', error) + } finally { + setIsLoading(false) + } + }, [newConnectionLabel, addConnection]) + + const handleShowQR = useCallback( + async (connection: NRCConnection) => { + try { + const uri = getConnectionURI(connection) + setCurrentQRConnection(connection) + setCurrentQRUri(uri) + await generateQRCode(uri) + setIsQRDialogOpen(true) + } catch (error) { + console.error('Failed to get connection URI:', error) + } + }, + [getConnectionURI, generateQRCode] + ) + + const handleCopyUri = useCallback(async () => { + try { + await navigator.clipboard.writeText(currentQRUri) + setCopiedUri(true) + setTimeout(() => setCopiedUri(false), 2000) + } catch (error) { + console.error('Failed to copy URI:', error) + } + }, [currentQRUri]) + + const handleRemoveConnection = useCallback( + async (id: string) => { + try { + await removeConnection(id) + } catch (error) { + console.error('Failed to remove connection:', error) + } + }, + [removeConnection] + ) + + if (!pubkey) { + return ( +
+ {t('Login required to use NRC')} +
+ ) + } + + return ( +
+ {/* Enable/Disable Toggle */} +
+
+ +

+ {t('Allow other devices to sync with this client')} +

+
+ +
+ + {/* Status Indicator */} + {isEnabled && ( +
+
+ {isConnected ? ( + + ) : ( + + )} + + {isConnected ? t('Connected') : t('Connecting...')} + +
+ {activeSessions > 0 && ( +
+ + + {activeSessions} {t('active session(s)')} + +
+ )} +
+ )} + + {/* Rendezvous Relay */} +
+ + setRendezvousUrl(e.target.value)} + placeholder="wss://relay.example.com" + disabled={isEnabled} + /> + {isEnabled && ( +

+ {t('Disable NRC to change the relay')} +

+ )} +
+ + {/* Connections List */} +
+
+ + +
+ + {connections.length === 0 ? ( +
+ {t('No devices connected yet')} +
+ ) : ( +
+ {connections.map((connection) => ( +
+
+
{connection.label}
+
+ {new Date(connection.createdAt).toLocaleDateString()} + {connection.useCat && ( + + CAT + + )} +
+
+
+ + + + + + + + {t('Remove Device?')} + + {t('This will revoke access for "{{label}}". The device will no longer be able to sync.', { + label: connection.label + })} + + + + {t('Cancel')} + handleRemoveConnection(connection.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {t('Remove')} + + + + +
+
+ ))} +
+ )} +
+ + {/* Add Connection Dialog */} + + + + {t('Add Device')} + + {t('Create a connection URI to link another device')} + + +
+
+ + setNewConnectionLabel(e.target.value)} + placeholder={t('e.g., Phone, Laptop')} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleAddConnection() + } + }} + /> +
+
+ + + + +
+
+ + {/* QR Code Dialog */} + + + + {t('Connection QR Code')} + + {currentQRConnection && ( + <> + {t('Scan this code with "{{label}}" to connect', { + label: currentQRConnection.label + })} + + )} + + +
+ {qrDataUrl && ( +
+ Connection QR Code +
+ )} +
+
+ + +
+
+
+ + + +
+
+
+ ) +} diff --git a/src/components/Settings/index.tsx b/src/components/Settings/index.tsx index bdb51066..9b771526 100644 --- a/src/components/Settings/index.tsx +++ b/src/components/Settings/index.tsx @@ -6,6 +6,7 @@ import EmojiPackList from '@/components/EmojiPackList' import EmojiPickerDialog from '@/components/EmojiPickerDialog' import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting' import MailboxSetting from '@/components/MailboxSetting' +import NRCSettings from '@/components/NRCSettings' import NoteList from '@/components/NoteList' import Tabs from '@/components/Tabs' import { @@ -73,6 +74,7 @@ import { PencilLine, RotateCcw, ScanLine, + RefreshCw, Server, Settings2, Smile, @@ -105,7 +107,7 @@ const NOTIFICATION_STYLES = [ ] as const // Accordion item values for keyboard navigation -const ACCORDION_ITEMS = ['general', 'appearance', 'relays', 'wallet', 'posts', 'emoji-packs', 'messaging', 'system'] +const ACCORDION_ITEMS = ['general', 'appearance', 'relays', 'sync', 'wallet', 'posts', 'emoji-packs', 'messaging', 'system'] export default function Settings() { const { t, i18n } = useTranslation() @@ -123,7 +125,7 @@ export default function Settings() { // Get the visible accordion items based on pubkey availability const visibleAccordionItems = pubkey ? ACCORDION_ITEMS - : ACCORDION_ITEMS.filter((item) => !['wallet', 'posts', 'emoji-packs', 'messaging'].includes(item)) + : ACCORDION_ITEMS.filter((item) => !['sync', 'wallet', 'posts', 'emoji-packs', 'messaging'].includes(item)) // Register as a navigation region - Settings decides what "up/down" means const handleSettingsIntent = useCallback( @@ -548,6 +550,23 @@ export default function Settings() { + {/* Sync (NRC) */} + {!!pubkey && ( + + + +
+ + {t('Device Sync')} +
+
+ + + +
+
+ )} + {/* Wallet */} {!!pubkey && ( diff --git a/src/providers/NRCProvider.tsx b/src/providers/NRCProvider.tsx new file mode 100644 index 00000000..d46786fe --- /dev/null +++ b/src/providers/NRCProvider.tsx @@ -0,0 +1,310 @@ +/** + * NRC (Nostr Relay Connect) Provider + * + * Manages NRC listener state and connections for cross-device sync. + */ + +import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react' +import { useNostr } from './NostrProvider' +import client from '@/services/client.service' +import { + NRCConnection, + CATConfig, + NRCListenerConfig, + generateConnectionURI, + generateCATConnectionURI, + getNRCListenerService +} from '@/services/nrc' + +// Storage keys +const STORAGE_KEY_ENABLED = 'nrc:enabled' +const STORAGE_KEY_CONNECTIONS = 'nrc:connections' +const STORAGE_KEY_RENDEZVOUS_URL = 'nrc:rendezvousUrl' +const STORAGE_KEY_CAT_CONFIG = 'nrc:catConfig' + +// Default rendezvous relay +const DEFAULT_RENDEZVOUS_URL = 'wss://relay.damus.io' + +interface NRCContextType { + // State + isEnabled: boolean + isListening: boolean + isConnected: boolean + connections: NRCConnection[] + activeSessions: number + catConfig: CATConfig | null + rendezvousUrl: string + + // Actions + enable: () => Promise + disable: () => void + addConnection: (label: string, useCat?: boolean) => Promise<{ uri: string; connection: NRCConnection }> + removeConnection: (id: string) => Promise + getConnectionURI: (connection: NRCConnection) => string + setRendezvousUrl: (url: string) => void + setCATConfig: (config: CATConfig | null) => void +} + +const NRCContext = createContext(undefined) + +export const useNRC = () => { + const context = useContext(NRCContext) + if (!context) { + throw new Error('useNRC must be used within an NRCProvider') + } + return context +} + +interface NRCProviderProps { + children: ReactNode +} + +export function NRCProvider({ children }: NRCProviderProps) { + const { pubkey } = useNostr() + + // Load initial state from storage + const [isEnabled, setIsEnabled] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY_ENABLED) + return stored === 'true' + }) + + const [connections, setConnections] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY_CONNECTIONS) + if (stored) { + try { + return JSON.parse(stored) + } catch { + return [] + } + } + return [] + }) + + const [rendezvousUrl, setRendezvousUrlState] = useState(() => { + return localStorage.getItem(STORAGE_KEY_RENDEZVOUS_URL) || DEFAULT_RENDEZVOUS_URL + }) + + const [catConfig, setCATConfigState] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY_CAT_CONFIG) + if (stored) { + try { + return JSON.parse(stored) + } catch { + return null + } + } + return null + }) + + const [isListening, setIsListening] = useState(false) + const [isConnected, setIsConnected] = useState(false) + const [activeSessions, setActiveSessions] = useState(0) + + const listenerService = getNRCListenerService() + + // Persist state to storage + useEffect(() => { + localStorage.setItem(STORAGE_KEY_ENABLED, String(isEnabled)) + }, [isEnabled]) + + useEffect(() => { + localStorage.setItem(STORAGE_KEY_CONNECTIONS, JSON.stringify(connections)) + }, [connections]) + + useEffect(() => { + localStorage.setItem(STORAGE_KEY_RENDEZVOUS_URL, rendezvousUrl) + }, [rendezvousUrl]) + + useEffect(() => { + if (catConfig) { + localStorage.setItem(STORAGE_KEY_CAT_CONFIG, JSON.stringify(catConfig)) + } else { + localStorage.removeItem(STORAGE_KEY_CAT_CONFIG) + } + }, [catConfig]) + + // Build authorized secrets map from connections + const buildAuthorizedSecrets = useCallback((): Map => { + const map = new Map() + for (const conn of connections) { + if (conn.secret && conn.clientPubkey) { + map.set(conn.clientPubkey, conn.label) + } + } + return map + }, [connections]) + + // Start/stop listener based on enabled state + useEffect(() => { + if (!isEnabled || !client.signer || !pubkey) { + if (listenerService.isRunning()) { + listenerService.stop() + setIsListening(false) + setIsConnected(false) + setActiveSessions(0) + } + return + } + + const startListener = async () => { + try { + const config: NRCListenerConfig = { + rendezvousUrl, + signer: client.signer!, + authorizedSecrets: buildAuthorizedSecrets(), + catConfig: catConfig || undefined + } + + listenerService.setOnSessionChange((count) => { + setActiveSessions(count) + }) + + await listenerService.start(config) + setIsListening(true) + setIsConnected(listenerService.isConnected()) + + // Poll connection status + const statusInterval = setInterval(() => { + setIsConnected(listenerService.isConnected()) + setActiveSessions(listenerService.getActiveSessionCount()) + }, 5000) + + return () => { + clearInterval(statusInterval) + } + } catch (error) { + console.error('[NRC] Failed to start listener:', error) + setIsListening(false) + setIsConnected(false) + } + } + + const cleanup = startListener() + return () => { + cleanup?.then((fn) => fn?.()) + } + }, [isEnabled, pubkey, rendezvousUrl, buildAuthorizedSecrets, catConfig]) + + // Restart listener when connections change (to update authorized secrets) + useEffect(() => { + if (!isEnabled || !client.signer || !pubkey) return + + // Update authorized secrets without full restart + // Note: The current implementation requires a full restart + // A future optimization could update secrets dynamically + }, [connections, isEnabled, pubkey]) + + const enable = useCallback(async () => { + if (!client.signer) { + throw new Error('Signer required to enable NRC') + } + setIsEnabled(true) + }, []) + + const disable = useCallback(() => { + setIsEnabled(false) + listenerService.stop() + setIsListening(false) + setIsConnected(false) + setActiveSessions(0) + }, []) + + const addConnection = useCallback( + async (label: string, useCat = false): Promise<{ uri: string; connection: NRCConnection }> => { + if (!pubkey) { + throw new Error('Not logged in') + } + + const id = crypto.randomUUID() + const createdAt = Date.now() + + let connection: NRCConnection + let uri: string + + if (useCat && catConfig) { + // CAT-based connection + uri = generateCATConnectionURI(pubkey, rendezvousUrl, catConfig.mintUrl) + connection = { + id, + label, + useCat: true, + createdAt + } + } else { + // Secret-based connection + const result = generateConnectionURI(pubkey, rendezvousUrl, undefined, label) + uri = result.uri + connection = { + id, + label, + secret: result.secret, + clientPubkey: result.clientPubkey, + useCat: false, + createdAt + } + } + + setConnections((prev) => [...prev, connection]) + + return { uri, connection } + }, + [pubkey, rendezvousUrl, catConfig] + ) + + const removeConnection = useCallback(async (id: string) => { + setConnections((prev) => prev.filter((c) => c.id !== id)) + }, []) + + const getConnectionURI = useCallback( + (connection: NRCConnection): string => { + if (!pubkey) { + throw new Error('Not logged in') + } + + if (connection.useCat && catConfig) { + return generateCATConnectionURI(pubkey, rendezvousUrl, catConfig.mintUrl) + } + + if (connection.secret) { + const result = generateConnectionURI( + pubkey, + rendezvousUrl, + connection.secret, + connection.label + ) + return result.uri + } + + throw new Error('Connection has no secret or CAT config') + }, + [pubkey, rendezvousUrl, catConfig] + ) + + const setRendezvousUrl = useCallback((url: string) => { + setRendezvousUrlState(url) + // Listener will restart automatically via effect + }, []) + + const setCATConfig = useCallback((config: CATConfig | null) => { + setCATConfigState(config) + }, []) + + const value: NRCContextType = { + isEnabled, + isListening, + isConnected, + connections, + activeSessions, + catConfig, + rendezvousUrl, + enable, + disable, + addConnection, + removeConnection, + getConnectionURI, + setRendezvousUrl, + setCATConfig + } + + return {children} +} diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index ce117b79..1b4a93ac 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -1,7 +1,7 @@ import { ExtendedKind } from '@/constants' import { tagNameEquals } from '@/lib/tag' import { TDMDeletedState, TRelayInfo } from '@/types' -import { Event, kinds } from 'nostr-tools' +import { Event, Filter, kinds, matchFilters } from 'nostr-tools' type TValue = { key: string @@ -1014,6 +1014,84 @@ class IndexedDbService { } } + /** + * Query all events across all stores for NRC sync. + * Returns events matching the provided filters. + * + * Note: This method queries all event-containing stores and filters + * client-side using matchFilters. Device-specific event filtering + * should be done by the caller. + */ + async queryEventsForNRC(filters: Filter[]): Promise { + await this.initPromise + if (!this.db) { + return [] + } + + // List of stores that contain Event objects + const eventStores = [ + StoreNames.PROFILE_EVENTS, + StoreNames.RELAY_LIST_EVENTS, + StoreNames.FOLLOW_LIST_EVENTS, + StoreNames.MUTE_LIST_EVENTS, + StoreNames.BOOKMARK_LIST_EVENTS, + StoreNames.BLOSSOM_SERVER_LIST_EVENTS, + StoreNames.USER_EMOJI_LIST_EVENTS, + StoreNames.EMOJI_SET_EVENTS, + StoreNames.PIN_LIST_EVENTS, + StoreNames.PINNED_USERS_EVENTS, + StoreNames.FAVORITE_RELAYS, + StoreNames.RELAY_SETS, + StoreNames.DM_EVENTS + ] + + const allEvents: Event[] = [] + + // Query each store + const transaction = this.db.transaction(eventStores, 'readonly') + + await Promise.all( + eventStores.map( + (storeName) => + new Promise((resolve) => { + const store = transaction.objectStore(storeName) + const request = store.openCursor() + + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result + if (cursor) { + const value = cursor.value as TValue + if (value.value) { + // Check if event matches any of the filters + if (matchFilters(filters, value.value)) { + allEvents.push(value.value) + } + } + cursor.continue() + } else { + resolve() + } + } + + request.onerror = () => { + resolve() // Continue even if one store fails + } + }) + ) + ) + + // Sort by created_at descending (newest first) + allEvents.sort((a, b) => b.created_at - a.created_at) + + // Apply limit from filters if specified + const limit = Math.min(...filters.map((f) => f.limit ?? Infinity)) + if (limit !== Infinity && limit > 0) { + return allEvents.slice(0, limit) + } + + return allEvents + } + private async cleanUp() { await this.initPromise if (!this.db) { diff --git a/src/services/nrc/index.ts b/src/services/nrc/index.ts new file mode 100644 index 00000000..ba406633 --- /dev/null +++ b/src/services/nrc/index.ts @@ -0,0 +1,4 @@ +export * from './nrc-types' +export * from './nrc-uri' +export * from './nrc-session' +export { NRCListenerService, getNRCListenerService, default as nrcListenerService } from './nrc-listener.service' diff --git a/src/services/nrc/nrc-listener.service.ts b/src/services/nrc/nrc-listener.service.ts new file mode 100644 index 00000000..18900de3 --- /dev/null +++ b/src/services/nrc/nrc-listener.service.ts @@ -0,0 +1,558 @@ +/** + * NRC (Nostr Relay Connect) Listener Service + * + * Listens for NRC requests (kind 24891) on a rendezvous relay and responds + * with events from the local IndexedDB. This allows other user clients to + * sync their data through this client. + * + * Protocol: + * - Client sends kind 24891 request with encrypted REQ/CLOSE message + * - This listener decrypts, queries local storage, and responds with kind 24892 + * - All content is NIP-44 encrypted end-to-end + */ + +import { Event, Filter } from 'nostr-tools' +import * as nip44 from 'nostr-tools/nip44' +import * as utils from '@noble/curves/abstract/utils' +import indexedDb from '@/services/indexed-db.service' +import { + KIND_NRC_REQUEST, + KIND_NRC_RESPONSE, + NRCListenerConfig, + RequestMessage, + ResponseMessage, + AuthResult, + NRCSession, + isDeviceSpecificEvent +} from './nrc-types' +import { NRCSessionManager } from './nrc-session' +import { deriveConversationKey } from './nrc-uri' + +/** + * Generate a random subscription ID + */ +function generateSubId(): string { + const bytes = crypto.getRandomValues(new Uint8Array(8)) + return utils.bytesToHex(bytes) +} + +/** + * NRC Listener Service + * + * Listens for incoming NRC requests and responds with local events. + */ +export class NRCListenerService { + private config: NRCListenerConfig | null = null + private sessions: NRCSessionManager + private ws: WebSocket | null = null + private subId: string | null = null + private connected = false + private running = false + private reconnectTimeout: ReturnType | null = null + private reconnectDelay = 1000 // Start with 1 second + private maxReconnectDelay = 30000 // Max 30 seconds + private listenerPubkey: string | null = null + + // Event callbacks + private onSessionChange?: (count: number) => void + + constructor() { + this.sessions = new NRCSessionManager() + } + + /** + * Set callback for session count changes + */ + setOnSessionChange(callback: (count: number) => void): void { + this.onSessionChange = callback + } + + /** + * Start listening for NRC requests + */ + async start(config: NRCListenerConfig): Promise { + if (this.running) { + console.warn('[NRC] Listener already running') + return + } + + this.config = config + this.running = true + + // Get our public key + this.listenerPubkey = await config.signer.getPublicKey() + + // Start session cleanup + this.sessions.start() + + // Connect to rendezvous relay + await this.connectToRelay() + } + + /** + * Stop listening + */ + stop(): void { + this.running = false + + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout) + this.reconnectTimeout = null + } + + if (this.ws) { + // Unsubscribe + if (this.subId) { + try { + this.ws.send(JSON.stringify(['CLOSE', this.subId])) + } catch { + // Ignore errors when closing + } + } + this.ws.close() + this.ws = null + } + + this.sessions.stop() + this.connected = false + this.subId = null + + console.log('[NRC] Listener stopped') + } + + /** + * Check if listener is running + */ + isRunning(): boolean { + return this.running + } + + /** + * Check if connected to rendezvous relay + */ + isConnected(): boolean { + return this.connected + } + + /** + * Get active session count + */ + getActiveSessionCount(): number { + return this.sessions.getActiveSessionCount() + } + + /** + * Connect to the rendezvous relay + */ + private async connectToRelay(): Promise { + if (!this.config || !this.running) return Promise.resolve() + + const relayUrl = this.config.rendezvousUrl + + return new Promise((resolve, reject) => { + // Normalize WebSocket URL + let wsUrl = relayUrl + if (relayUrl.startsWith('http://')) { + wsUrl = 'ws://' + relayUrl.slice(7) + } else if (relayUrl.startsWith('https://')) { + wsUrl = 'wss://' + relayUrl.slice(8) + } else if (!relayUrl.startsWith('ws://') && !relayUrl.startsWith('wss://')) { + wsUrl = 'wss://' + relayUrl + } + + console.log(`[NRC] Connecting to rendezvous relay: ${wsUrl}`) + + const ws = new WebSocket(wsUrl) + + const timeout = setTimeout(() => { + ws.close() + reject(new Error('Connection timeout')) + }, 10000) + + ws.onopen = () => { + clearTimeout(timeout) + this.ws = ws + this.connected = true + this.reconnectDelay = 1000 // Reset reconnect delay on success + + // Subscribe to NRC requests for our pubkey + this.subId = generateSubId() + ws.send( + JSON.stringify([ + 'REQ', + this.subId, + { + kinds: [KIND_NRC_REQUEST], + '#p': [this.listenerPubkey], + since: Math.floor(Date.now() / 1000) - 60 + } + ]) + ) + + console.log(`[NRC] Connected and subscribed with subId: ${this.subId}`) + resolve() + } + + ws.onerror = (error) => { + clearTimeout(timeout) + console.error('[NRC] WebSocket error:', error) + reject(new Error('WebSocket error')) + } + + ws.onclose = () => { + this.connected = false + this.ws = null + this.subId = null + console.log('[NRC] WebSocket closed') + + // Attempt reconnection if still running + if (this.running) { + this.scheduleReconnect() + } + } + + ws.onmessage = (event) => { + this.handleMessage(event.data) + } + }).catch((error) => { + console.error('[NRC] Failed to connect:', error) + if (this.running) { + this.scheduleReconnect() + } + }) + } + + /** + * Schedule reconnection with exponential backoff + */ + private scheduleReconnect(): void { + if (this.reconnectTimeout || !this.running) return + + console.log(`[NRC] Scheduling reconnect in ${this.reconnectDelay}ms`) + + this.reconnectTimeout = setTimeout(() => { + this.reconnectTimeout = null + this.connectToRelay() + }, this.reconnectDelay) + + // Exponential backoff + this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay) + } + + /** + * Handle incoming WebSocket message + */ + private handleMessage(data: string): void { + try { + const msg = JSON.parse(data) + if (!Array.isArray(msg)) return + + const [type, ...rest] = msg + + if (type === 'EVENT') { + const [, event] = rest as [string, Event] + if (event.kind === KIND_NRC_REQUEST) { + this.handleRequest(event).catch((err) => { + console.error('[NRC] Error handling request:', err) + }) + } + } else if (type === 'EOSE') { + // End of stored events, listener is now live + console.log('[NRC] Received EOSE, now listening for live events') + } else if (type === 'NOTICE') { + console.log('[NRC] Relay notice:', rest[0]) + } else if (type === 'OK') { + // Event published successfully + } else if (type === 'CLOSED') { + console.log('[NRC] Subscription closed:', rest) + } + } catch (err) { + console.error('[NRC] Failed to parse message:', err) + } + } + + /** + * Handle an NRC request event + */ + private async handleRequest(event: Event): Promise { + if (!this.config) return + + // Extract session ID from tags (used for correlation but we use pubkey-based sessions) + const sessionTag = event.tags.find((t) => t[0] === 'session') + const _sessionId = sessionTag?.[1] + void _sessionId // Suppress unused variable warning + + try { + // Authorize the request + const authResult = await this.authorize(event) + + // Get or create session + const session = this.sessions.getOrCreateSession( + event.pubkey, + authResult.conversationKey, + authResult.mode, + authResult.deviceName + ) + + // Notify session change + this.onSessionChange?.(this.sessions.getActiveSessionCount()) + + // Decrypt the content + const plaintext = nip44.v2.decrypt(event.content, authResult.conversationKey) + const request: RequestMessage = JSON.parse(plaintext) + + // Handle the request based on type + switch (request.type) { + case 'REQ': + await this.handleREQ(event, session, request.payload) + break + case 'CLOSE': + await this.handleCLOSE(session, request.payload) + break + case 'EVENT': + // Read-only mode - reject EVENT requests + await this.sendError(event, session, 'This NRC endpoint is read-only') + break + case 'COUNT': + // Not implemented + await this.sendError(event, session, 'COUNT not supported') + break + default: + await this.sendError(event, session, `Unknown message type: ${request.type}`) + } + } catch (err) { + console.error('[NRC] Request handling failed:', err) + // Try to send error response (best effort) + try { + await this.sendErrorBestEffort(event, `Request failed: ${err instanceof Error ? err.message : 'Unknown error'}`) + } catch { + // Ignore errors when sending error response + } + } + } + + /** + * Authorize an incoming request + */ + private async authorize(event: Event): Promise { + if (!this.config) { + throw new Error('Listener not configured') + } + + // Check for CAT token in cashu tag + const cashuTag = event.tags.find((t) => t[0] === 'cashu') + if (cashuTag && this.config.catConfig) { + // TODO: Implement CAT token verification + // For now, we'll use the client's Nostr pubkey for ECDH + const ourPrivkey = await this.getPrivateKey() + const conversationKey = deriveConversationKey(ourPrivkey, event.pubkey) + return { + mode: 'cat', + conversationKey, + deviceName: 'cat-client' + } + } + + // Secret-based auth: check if pubkey is authorized + const deviceName = this.config.authorizedSecrets.get(event.pubkey) + if (!deviceName) { + throw new Error('Unauthorized: unknown client pubkey') + } + + // Derive conversation key via ECDH + const ourPrivkey = await this.getPrivateKey() + const conversationKey = deriveConversationKey(ourPrivkey, event.pubkey) + + return { + mode: 'secret', + conversationKey, + deviceName + } + } + + /** + * Get our private key from the signer + * Note: This requires the signer to expose the private key, which not all signers do + */ + private async getPrivateKey(): Promise { + if (!this.config) { + throw new Error('Listener not configured') + } + + // Try to get private key from signer if it's an NsecSigner + const signer = this.config.signer as { privkey?: Uint8Array } + if (signer.privkey) { + return signer.privkey + } + + throw new Error('Signer does not expose private key - NRC requires direct key access') + } + + /** + * Handle REQ message - query local storage and respond + */ + private async handleREQ( + reqEvent: Event, + session: NRCSession, + payload: unknown[] + ): Promise { + // Parse REQ: ["REQ", subId, filter1, filter2, ...] + if (payload.length < 2) { + await this.sendError(reqEvent, session, 'Invalid REQ: missing subscription ID or filters') + return + } + + const [, subId, ...filterObjs] = payload as [string, string, ...Filter[]] + + // Add subscription to session + const subscription = this.sessions.addSubscription(session.id, subId, filterObjs) + if (!subscription) { + await this.sendError(reqEvent, session, 'Too many subscriptions') + return + } + + // Query local events matching the filters + const events = await this.queryLocalEvents(filterObjs) + + // Send each matching event + for (const evt of events) { + const response: ResponseMessage = { + type: 'EVENT', + payload: ['EVENT', subId, evt] + } + await this.sendResponse(reqEvent, session, response) + this.sessions.incrementEventCount(session.id, subId) + } + + // Send EOSE + const eoseResponse: ResponseMessage = { + type: 'EOSE', + payload: ['EOSE', subId] + } + await this.sendResponse(reqEvent, session, eoseResponse) + this.sessions.markEOSE(session.id, subId) + } + + /** + * Handle CLOSE message + */ + private async handleCLOSE(session: NRCSession, payload: unknown[]): Promise { + // Parse CLOSE: ["CLOSE", subId] + const [, subId] = payload as [string, string] + if (subId) { + this.sessions.removeSubscription(session.id, subId) + } + } + + /** + * Query local IndexedDB for events matching filters + */ + private async queryLocalEvents(filters: Filter[]): Promise { + // Get all events from IndexedDB and filter + const allEvents = await indexedDb.queryEventsForNRC(filters) + + // Filter out device-specific events + return allEvents.filter((evt) => !isDeviceSpecificEvent(evt)) + } + + /** + * Send an encrypted response + */ + private async sendResponse( + reqEvent: Event, + session: NRCSession, + response: ResponseMessage + ): Promise { + if (!this.ws || !this.config || !this.listenerPubkey) { + throw new Error('Not connected') + } + + // Encrypt the response + const plaintext = JSON.stringify(response) + const encrypted = nip44.v2.encrypt(plaintext, session.conversationKey) + + // Build the response event + const unsignedEvent = { + kind: KIND_NRC_RESPONSE, + content: encrypted, + tags: [ + ['p', reqEvent.pubkey], + ['encryption', 'nip44_v2'], + ['session', session.id], + ['e', reqEvent.id] + ], + created_at: Math.floor(Date.now() / 1000) + } + + // Sign with our signer + const signedEvent = await this.config.signer.signEvent(unsignedEvent) + + // Publish to rendezvous relay + this.ws.send(JSON.stringify(['EVENT', signedEvent])) + } + + /** + * Send an error response + */ + private async sendError( + reqEvent: Event, + session: NRCSession, + message: string + ): Promise { + const response: ResponseMessage = { + type: 'NOTICE', + payload: ['NOTICE', message] + } + await this.sendResponse(reqEvent, session, response) + } + + /** + * Send error response with best-effort ECDH + */ + private async sendErrorBestEffort(reqEvent: Event, message: string): Promise { + if (!this.ws || !this.config || !this.listenerPubkey) { + return + } + + try { + const ourPrivkey = await this.getPrivateKey() + const conversationKey = deriveConversationKey(ourPrivkey, reqEvent.pubkey) + + const response: ResponseMessage = { + type: 'NOTICE', + payload: ['NOTICE', message] + } + + const plaintext = JSON.stringify(response) + const encrypted = nip44.v2.encrypt(plaintext, conversationKey) + + const unsignedEvent = { + kind: KIND_NRC_RESPONSE, + content: encrypted, + tags: [ + ['p', reqEvent.pubkey], + ['encryption', 'nip44_v2'], + ['e', reqEvent.id] + ], + created_at: Math.floor(Date.now() / 1000) + } + + const signedEvent = await this.config.signer.signEvent(unsignedEvent) + this.ws.send(JSON.stringify(['EVENT', signedEvent])) + } catch { + // Best effort - ignore errors + } + } +} + +// Singleton instance +let instance: NRCListenerService | null = null + +export function getNRCListenerService(): NRCListenerService { + if (!instance) { + instance = new NRCListenerService() + } + return instance +} + +export default getNRCListenerService() diff --git a/src/services/nrc/nrc-session.ts b/src/services/nrc/nrc-session.ts new file mode 100644 index 00000000..ca52fa0d --- /dev/null +++ b/src/services/nrc/nrc-session.ts @@ -0,0 +1,240 @@ +import { Filter } from 'nostr-tools' +import { NRCSession, NRCSubscription, AuthMode } from './nrc-types' + +// Default session timeout: 30 minutes +const DEFAULT_SESSION_TIMEOUT = 30 * 60 * 1000 + +// Default max subscriptions per session +const DEFAULT_MAX_SUBSCRIPTIONS = 100 + +/** + * Generate a unique session ID + */ +function generateSessionId(): string { + return crypto.randomUUID() +} + +/** + * Session manager for tracking NRC client sessions + */ +export class NRCSessionManager { + private sessions: Map = new Map() + private sessionTimeout: number + private maxSubscriptions: number + private cleanupInterval: ReturnType | null = null + + constructor( + sessionTimeout: number = DEFAULT_SESSION_TIMEOUT, + maxSubscriptions: number = DEFAULT_MAX_SUBSCRIPTIONS + ) { + this.sessionTimeout = sessionTimeout + this.maxSubscriptions = maxSubscriptions + } + + /** + * Start the cleanup interval for expired sessions + */ + start(): void { + if (this.cleanupInterval) return + + // Run cleanup every 5 minutes + this.cleanupInterval = setInterval(() => { + this.cleanupExpiredSessions() + }, 5 * 60 * 1000) + } + + /** + * Stop the cleanup interval + */ + stop(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval) + this.cleanupInterval = null + } + this.sessions.clear() + } + + /** + * Get or create a session for a client + */ + getOrCreateSession( + clientPubkey: string, + conversationKey: Uint8Array, + authMode: AuthMode, + deviceName?: string + ): NRCSession { + // Check if session exists for this client + for (const session of this.sessions.values()) { + if (session.clientPubkey === clientPubkey) { + // Update last activity and return existing session + session.lastActivity = Date.now() + return session + } + } + + // Create new session + const session: NRCSession = { + id: generateSessionId(), + clientPubkey, + conversationKey, + deviceName, + authMode, + createdAt: Date.now(), + lastActivity: Date.now(), + subscriptions: new Map() + } + + this.sessions.set(session.id, session) + return session + } + + /** + * Get a session by ID + */ + getSession(sessionId: string): NRCSession | undefined { + return this.sessions.get(sessionId) + } + + /** + * Get a session by client pubkey + */ + getSessionByClientPubkey(clientPubkey: string): NRCSession | undefined { + for (const session of this.sessions.values()) { + if (session.clientPubkey === clientPubkey) { + return session + } + } + return undefined + } + + /** + * Touch a session to update last activity + */ + touchSession(sessionId: string): void { + const session = this.sessions.get(sessionId) + if (session) { + session.lastActivity = Date.now() + } + } + + /** + * Add a subscription to a session + */ + addSubscription( + sessionId: string, + subId: string, + filters: Filter[] + ): NRCSubscription | null { + const session = this.sessions.get(sessionId) + if (!session) return null + + // Check subscription limit + if (session.subscriptions.size >= this.maxSubscriptions) { + return null + } + + const subscription: NRCSubscription = { + id: subId, + filters, + createdAt: Date.now(), + eventCount: 0, + eoseSent: false + } + + session.subscriptions.set(subId, subscription) + session.lastActivity = Date.now() + + return subscription + } + + /** + * Get a subscription from a session + */ + getSubscription(sessionId: string, subId: string): NRCSubscription | undefined { + const session = this.sessions.get(sessionId) + return session?.subscriptions.get(subId) + } + + /** + * Remove a subscription from a session + */ + removeSubscription(sessionId: string, subId: string): boolean { + const session = this.sessions.get(sessionId) + if (!session) return false + + const deleted = session.subscriptions.delete(subId) + if (deleted) { + session.lastActivity = Date.now() + } + return deleted + } + + /** + * Mark EOSE sent for a subscription + */ + markEOSE(sessionId: string, subId: string): void { + const subscription = this.getSubscription(sessionId, subId) + if (subscription) { + subscription.eoseSent = true + } + } + + /** + * Increment event count for a subscription + */ + incrementEventCount(sessionId: string, subId: string): void { + const subscription = this.getSubscription(sessionId, subId) + if (subscription) { + subscription.eventCount++ + } + } + + /** + * Remove a session + */ + removeSession(sessionId: string): boolean { + return this.sessions.delete(sessionId) + } + + /** + * Get the count of active sessions + */ + getActiveSessionCount(): number { + return this.sessions.size + } + + /** + * Get all active sessions + */ + getAllSessions(): NRCSession[] { + return Array.from(this.sessions.values()) + } + + /** + * Clean up expired sessions + */ + private cleanupExpiredSessions(): void { + const now = Date.now() + const expiredSessionIds: string[] = [] + + for (const [sessionId, session] of this.sessions) { + if (now - session.lastActivity > this.sessionTimeout) { + expiredSessionIds.push(sessionId) + } + } + + for (const sessionId of expiredSessionIds) { + this.sessions.delete(sessionId) + console.log(`[NRC] Cleaned up expired session: ${sessionId}`) + } + } + + /** + * Check if a session is expired + */ + isSessionExpired(sessionId: string): boolean { + const session = this.sessions.get(sessionId) + if (!session) return true + return Date.now() - session.lastActivity > this.sessionTimeout + } +} diff --git a/src/services/nrc/nrc-types.ts b/src/services/nrc/nrc-types.ts new file mode 100644 index 00000000..195dc4ba --- /dev/null +++ b/src/services/nrc/nrc-types.ts @@ -0,0 +1,108 @@ +import { Filter, Event } from 'nostr-tools' +import { ISigner } from '@/types' + +// NRC Event Kinds +export const KIND_NRC_REQUEST = 24891 +export const KIND_NRC_RESPONSE = 24892 + +// Authentication modes +export type AuthMode = 'secret' | 'cat' + +// Session types +export interface NRCSession { + id: string + clientPubkey: string + conversationKey: Uint8Array + deviceName?: string + authMode: AuthMode + createdAt: number + lastActivity: number + subscriptions: Map +} + +export interface NRCSubscription { + id: string + filters: Filter[] + createdAt: number + eventCount: number + eoseSent: boolean +} + +// Message types (encrypted content) +export interface RequestMessage { + type: 'REQ' | 'CLOSE' | 'EVENT' | 'COUNT' + payload: unknown[] +} + +export interface ResponseMessage { + type: 'EVENT' | 'EOSE' | 'OK' | 'NOTICE' | 'CLOSED' | 'COUNT' + payload: unknown[] +} + +// Connection management +export interface NRCConnection { + id: string + label: string + secret?: string // For secret-based auth + clientPubkey?: string // Derived from secret + useCat: boolean // Whether to use CAT auth + createdAt: number + lastUsed?: number +} + +// CAT (Cashu Access Token) configuration +export interface CATConfig { + mintUrl: string // Cashu mint URL + scope: string // Token scope (e.g., "nrc") +} + +// Listener configuration +export interface NRCListenerConfig { + rendezvousUrl: string + signer: ISigner + authorizedSecrets: Map // clientPubkey → deviceName + catConfig?: CATConfig // For CAT verification + sessionTimeout?: number // Session inactivity timeout in ms (default 30 min) + maxSubscriptionsPerSession?: number // Max subscriptions per session (default 100) +} + +// Authorization result +export interface AuthResult { + mode: AuthMode + conversationKey: Uint8Array + deviceName: string +} + +// Parsed connection URI +export interface ParsedConnectionURI { + relayPubkey: string // Hex pubkey of the listening relay/client + rendezvousUrl: string // URL of the rendezvous relay + authMode: AuthMode + // For secret-based auth + secret?: string // 32-byte hex secret + clientPubkey?: string // Derived pubkey from secret + clientPrivkey?: Uint8Array // Derived private key from secret + // For CAT auth + mintUrl?: string + // Optional + deviceName?: string +} + +// Listener state for React context +export interface NRCListenerState { + isEnabled: boolean + isListening: boolean + connections: NRCConnection[] + activeSessions: number + catConfig: CATConfig | null + rendezvousUrl: string +} + +// Event with simplified typing for storage queries +export type StoredEvent = Event + +// Device-specific event check +export function isDeviceSpecificEvent(event: Event): boolean { + const dTag = event.tags.find((t) => t[0] === 'd')?.[1] + return dTag?.startsWith('device:') ?? false +} diff --git a/src/services/nrc/nrc-uri.ts b/src/services/nrc/nrc-uri.ts new file mode 100644 index 00000000..593dbde0 --- /dev/null +++ b/src/services/nrc/nrc-uri.ts @@ -0,0 +1,189 @@ +import * as utils from '@noble/curves/abstract/utils' +import { getPublicKey } from 'nostr-tools' +import * as nip44 from 'nostr-tools/nip44' +import { ParsedConnectionURI, AuthMode } from './nrc-types' + +/** + * Generate a random 32-byte secret as hex string + */ +export function generateSecret(): string { + const bytes = new Uint8Array(32) + crypto.getRandomValues(bytes) + return utils.bytesToHex(bytes) +} + +/** + * Derive a keypair from a 32-byte secret + * Returns the private key bytes and public key hex + */ +export function deriveKeypairFromSecret(secretHex: string): { + privkey: Uint8Array + pubkey: string +} { + const privkey = utils.hexToBytes(secretHex) + const pubkey = getPublicKey(privkey) + return { privkey, pubkey } +} + +/** + * Derive conversation key for NIP-44 encryption + */ +export function deriveConversationKey( + ourPrivkey: Uint8Array, + theirPubkey: string +): Uint8Array { + return nip44.v2.utils.getConversationKey(ourPrivkey, theirPubkey) +} + +/** + * Generate a secret-based NRC connection URI + * + * @param relayPubkey - The public key of the listening client/relay + * @param rendezvousUrl - The URL of the rendezvous relay + * @param secret - Optional 32-byte hex secret (generated if not provided) + * @param deviceName - Optional device name for identification + * @returns The connection URI and the secret used + */ +export function generateConnectionURI( + relayPubkey: string, + rendezvousUrl: string, + secret?: string, + deviceName?: string +): { uri: string; secret: string; clientPubkey: string } { + const secretHex = secret || generateSecret() + const { pubkey: clientPubkey } = deriveKeypairFromSecret(secretHex) + + // Build URI + const params = new URLSearchParams() + params.set('relay', rendezvousUrl) + params.set('secret', secretHex) + if (deviceName) { + params.set('name', deviceName) + } + + const uri = `nostr+relayconnect://${relayPubkey}?${params.toString()}` + + return { uri, secret: secretHex, clientPubkey } +} + +/** + * Generate a CAT-based NRC connection URI + * + * @param relayPubkey - The public key of the listening client/relay + * @param rendezvousUrl - The URL of the rendezvous relay + * @param mintUrl - The URL of the Cashu mint for token verification + * @returns The connection URI + */ +export function generateCATConnectionURI( + relayPubkey: string, + rendezvousUrl: string, + mintUrl: string +): string { + const params = new URLSearchParams() + params.set('relay', rendezvousUrl) + params.set('auth', 'cat') + params.set('mint', mintUrl) + + return `nostr+relayconnect://${relayPubkey}?${params.toString()}` +} + +/** + * Parse an NRC connection URI + * + * @param uri - The nostr+relayconnect:// URI to parse + * @returns Parsed connection parameters + * @throws Error if URI is invalid + */ +export function parseConnectionURI(uri: string): ParsedConnectionURI { + // Parse as URL + let url: URL + try { + url = new URL(uri) + } catch { + throw new Error('Invalid URI format') + } + + // Validate scheme + if (url.protocol !== 'nostr+relayconnect:') { + throw new Error('Invalid URI scheme, expected nostr+relayconnect://') + } + + // Extract relay pubkey from host (should be 64 hex chars) + const relayPubkey = url.hostname + if (!/^[0-9a-fA-F]{64}$/.test(relayPubkey)) { + throw new Error('Invalid relay pubkey in URI') + } + + // Extract rendezvous relay URL + const rendezvousUrl = url.searchParams.get('relay') + if (!rendezvousUrl) { + throw new Error('Missing relay parameter in URI') + } + + // Validate rendezvous URL + try { + new URL(rendezvousUrl) + } catch { + throw new Error('Invalid rendezvous relay URL') + } + + // Determine auth mode + const authParam = url.searchParams.get('auth') + const authMode: AuthMode = authParam === 'cat' ? 'cat' : 'secret' + + // Extract device name (optional) + const deviceName = url.searchParams.get('name') || undefined + + if (authMode === 'cat') { + // CAT-based auth + const mintUrl = url.searchParams.get('mint') + if (!mintUrl) { + throw new Error('CAT auth requires mint parameter') + } + + return { + relayPubkey, + rendezvousUrl, + authMode, + mintUrl, + deviceName + } + } else { + // Secret-based auth + const secret = url.searchParams.get('secret') + if (!secret) { + throw new Error('Secret auth requires secret parameter') + } + + // Validate secret format (64 hex chars = 32 bytes) + if (!/^[0-9a-fA-F]{64}$/.test(secret)) { + throw new Error('Invalid secret format, expected 64 hex characters') + } + + // Derive keypair from secret + const { privkey, pubkey } = deriveKeypairFromSecret(secret) + + return { + relayPubkey, + rendezvousUrl, + authMode, + secret, + clientPubkey: pubkey, + clientPrivkey: privkey, + deviceName + } + } +} + +/** + * Validate a connection URI without fully parsing it + * Returns true if the URI appears valid, false otherwise + */ +export function isValidConnectionURI(uri: string): boolean { + try { + parseConnectionURI(uri) + return true + } catch { + return false + } +}