nrc-uri.ts raw

   1  import * as utils from '@noble/curves/abstract/utils'
   2  import { getPublicKey } from 'nostr-tools'
   3  import * as nip44 from 'nostr-tools/nip44'
   4  import { ParsedConnectionURI } from './nrc-types'
   5  
   6  /**
   7   * Generate a random 32-byte secret as hex string
   8   */
   9  export function generateSecret(): string {
  10    const bytes = new Uint8Array(32)
  11    crypto.getRandomValues(bytes)
  12    return utils.bytesToHex(bytes)
  13  }
  14  
  15  /**
  16   * Derive a keypair from a 32-byte secret
  17   * Returns the private key bytes and public key hex
  18   */
  19  export function deriveKeypairFromSecret(secretHex: string): {
  20    privkey: Uint8Array
  21    pubkey: string
  22  } {
  23    const privkey = utils.hexToBytes(secretHex)
  24    const pubkey = getPublicKey(privkey)
  25    return { privkey, pubkey }
  26  }
  27  
  28  /**
  29   * Derive conversation key for NIP-44 encryption
  30   */
  31  export function deriveConversationKey(
  32    ourPrivkey: Uint8Array,
  33    theirPubkey: string
  34  ): Uint8Array {
  35    return nip44.v2.utils.getConversationKey(ourPrivkey, theirPubkey)
  36  }
  37  
  38  /**
  39   * Generate a secret-based NRC connection URI
  40   *
  41   * @param relayPubkey - The public key of the listening client/relay
  42   * @param rendezvousUrl - The URL of the rendezvous relay
  43   * @param secret - Optional 32-byte hex secret (generated if not provided)
  44   * @param deviceName - Optional device name for identification
  45   * @returns The connection URI and the secret used
  46   */
  47  export function generateConnectionURI(
  48    relayPubkey: string,
  49    rendezvousUrl: string,
  50    secret?: string,
  51    deviceName?: string
  52  ): { uri: string; secret: string; clientPubkey: string } {
  53    const secretHex = secret || generateSecret()
  54    const { pubkey: clientPubkey } = deriveKeypairFromSecret(secretHex)
  55  
  56    // Build URI
  57    const params = new URLSearchParams()
  58    params.set('relay', rendezvousUrl)
  59    params.set('secret', secretHex)
  60    if (deviceName) {
  61      params.set('name', deviceName)
  62    }
  63  
  64    const uri = `nostr+relayconnect://${relayPubkey}?${params.toString()}`
  65  
  66    return { uri, secret: secretHex, clientPubkey }
  67  }
  68  
  69  /**
  70   * Parse an NRC connection URI
  71   *
  72   * @param uri - The nostr+relayconnect:// URI to parse
  73   * @returns Parsed connection parameters
  74   * @throws Error if URI is invalid
  75   */
  76  export function parseConnectionURI(uri: string): ParsedConnectionURI {
  77    // Parse as URL
  78    let url: URL
  79    try {
  80      url = new URL(uri)
  81    } catch {
  82      throw new Error('Invalid URI format')
  83    }
  84  
  85    // Validate scheme
  86    if (url.protocol !== 'nostr+relayconnect:') {
  87      throw new Error('Invalid URI scheme, expected nostr+relayconnect://')
  88    }
  89  
  90    // Extract relay pubkey from host (should be 64 hex chars)
  91    const relayPubkey = url.hostname
  92    if (!/^[0-9a-fA-F]{64}$/.test(relayPubkey)) {
  93      throw new Error('Invalid relay pubkey in URI')
  94    }
  95  
  96    // Extract rendezvous relay URL
  97    const rendezvousUrl = url.searchParams.get('relay')
  98    if (!rendezvousUrl) {
  99      throw new Error('Missing relay parameter in URI')
 100    }
 101  
 102    // Validate rendezvous URL
 103    try {
 104      new URL(rendezvousUrl)
 105    } catch {
 106      throw new Error('Invalid rendezvous relay URL')
 107    }
 108  
 109    // Extract device name (optional)
 110    const deviceName = url.searchParams.get('name') || undefined
 111  
 112    // Secret-based auth
 113    const secret = url.searchParams.get('secret')
 114    if (!secret) {
 115      throw new Error('Missing secret parameter in URI')
 116    }
 117  
 118    // Validate secret format (64 hex chars = 32 bytes)
 119    if (!/^[0-9a-fA-F]{64}$/.test(secret)) {
 120      throw new Error('Invalid secret format, expected 64 hex characters')
 121    }
 122  
 123    // Derive keypair from secret
 124    const { privkey, pubkey } = deriveKeypairFromSecret(secret)
 125  
 126    return {
 127      relayPubkey,
 128      rendezvousUrl,
 129      secret,
 130      clientPubkey: pubkey,
 131      clientPrivkey: privkey,
 132      deviceName
 133    }
 134  }
 135  
 136  /**
 137   * Validate a connection URI without fully parsing it
 138   * Returns true if the URI appears valid, false otherwise
 139   */
 140  export function isValidConnectionURI(uri: string): boolean {
 141    try {
 142      parseConnectionURI(uri)
 143      return true
 144    } catch {
 145      return false
 146    }
 147  }
 148