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