websocket-auth.ts raw
1 /**
2 * NIP-42 Relay Authentication
3 *
4 * Handles WebSocket connections to relays that require authentication.
5 * When a relay sends an AUTH challenge, this module signs the challenge
6 * and authenticates before proceeding with event publishing.
7 */
8
9 import { finalizeEvent, getPublicKey } from 'nostr-tools';
10
11 export interface AuthenticatedRelayConnection {
12 ws: WebSocket;
13 url: string;
14 authenticated: boolean;
15 pubkey: string;
16 }
17
18 export interface PublishResult {
19 relay: string;
20 success: boolean;
21 message: string;
22 }
23
24 /**
25 * Create a NIP-42 authentication event (kind 22242)
26 */
27 function createAuthEvent(
28 relayUrl: string,
29 challenge: string,
30 privateKeyHex: string
31 ): ReturnType<typeof finalizeEvent> {
32 const unsignedEvent = {
33 kind: 22242,
34 created_at: Math.floor(Date.now() / 1000),
35 tags: [
36 ['relay', relayUrl],
37 ['challenge', challenge],
38 ],
39 content: '',
40 };
41
42 // Convert hex private key to Uint8Array
43 const privkeyBytes = hexToBytes(privateKeyHex);
44 return finalizeEvent(unsignedEvent, privkeyBytes);
45 }
46
47 /**
48 * Convert hex string to Uint8Array
49 */
50 function hexToBytes(hex: string): Uint8Array {
51 const bytes = new Uint8Array(hex.length / 2);
52 for (let i = 0; i < bytes.length; i++) {
53 bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
54 }
55 return bytes;
56 }
57
58 /**
59 * Connect to a relay with NIP-42 authentication support
60 *
61 * @param relayUrl - The relay WebSocket URL (e.g., wss://relay.example.com)
62 * @param privateKeyHex - The private key in hex format for signing
63 * @param timeoutMs - Connection and authentication timeout in milliseconds
64 * @returns Promise resolving to authenticated connection or null if failed
65 */
66 export async function connectWithAuth(
67 relayUrl: string,
68 privateKeyHex: string,
69 timeoutMs = 10000
70 ): Promise<AuthenticatedRelayConnection | null> {
71 return new Promise((resolve) => {
72 const timeout = setTimeout(() => {
73 ws.close();
74 resolve(null);
75 }, timeoutMs);
76
77 const ws = new WebSocket(relayUrl);
78 const pubkey = getPublicKey(hexToBytes(privateKeyHex));
79
80 ws.onopen = () => {
81 // Connection open, wait for AUTH challenge or proceed directly
82 };
83
84 ws.onmessage = (event) => {
85 try {
86 const message = JSON.parse(event.data);
87 const messageType = message[0];
88
89 if (messageType === 'AUTH') {
90 // Relay sent an auth challenge
91 const challenge = message[1];
92 const authEvent = createAuthEvent(relayUrl, challenge, privateKeyHex);
93
94 // Send AUTH response
95 ws.send(JSON.stringify(['AUTH', authEvent]));
96 } else if (messageType === 'OK') {
97 // Check if this is the AUTH response
98 const success = message[2];
99 const msg = message[3] || '';
100
101 if (success) {
102 clearTimeout(timeout);
103 resolve({
104 ws,
105 url: relayUrl,
106 authenticated: true,
107 pubkey,
108 });
109 } else {
110 console.error(`Auth failed for ${relayUrl}: ${msg}`);
111 clearTimeout(timeout);
112 ws.close();
113 resolve(null);
114 }
115 } else if (messageType === 'NOTICE') {
116 // Some relays don't require auth - connection is ready
117 clearTimeout(timeout);
118 resolve({
119 ws,
120 url: relayUrl,
121 authenticated: false,
122 pubkey,
123 });
124 }
125 } catch {
126 // Ignore parse errors
127 }
128 };
129
130 ws.onerror = () => {
131 clearTimeout(timeout);
132 resolve(null);
133 };
134
135 ws.onclose = () => {
136 clearTimeout(timeout);
137 };
138
139 // For relays that don't send AUTH challenge, resolve after short delay
140 setTimeout(() => {
141 if (ws.readyState === WebSocket.OPEN) {
142 clearTimeout(timeout);
143 resolve({
144 ws,
145 url: relayUrl,
146 authenticated: false, // No auth was required
147 pubkey,
148 });
149 }
150 }, 2000); // Wait 2 seconds for potential AUTH challenge
151 });
152 }
153
154 /**
155 * Publish an event to a relay with NIP-42 authentication support
156 *
157 * This function handles the complete flow:
158 * 1. Connect to relay
159 * 2. Handle AUTH challenge if sent
160 * 3. Publish the event
161 * 4. Wait for OK response
162 * 5. Close connection
163 *
164 * @param relayUrl - The relay WebSocket URL
165 * @param signedEvent - The already-signed Nostr event to publish
166 * @param privateKeyHex - Private key for AUTH (if required)
167 * @param timeoutMs - Timeout for the entire operation
168 * @returns Promise resolving to publish result
169 */
170 export async function publishEventWithAuth(
171 relayUrl: string,
172 signedEvent: ReturnType<typeof finalizeEvent>,
173 privateKeyHex: string,
174 timeoutMs = 15000
175 ): Promise<PublishResult> {
176 return new Promise((resolve) => {
177 const timeout = setTimeout(() => {
178 if (ws && ws.readyState === WebSocket.OPEN) {
179 ws.close();
180 }
181 resolve({
182 relay: relayUrl,
183 success: false,
184 message: 'Timeout',
185 });
186 }, timeoutMs);
187
188 let ws: WebSocket;
189 let authenticated = false;
190 let eventSent = false;
191
192 try {
193 ws = new WebSocket(relayUrl);
194 } catch (e) {
195 clearTimeout(timeout);
196 resolve({
197 relay: relayUrl,
198 success: false,
199 message: `Connection failed: ${e}`,
200 });
201 return;
202 }
203
204 const sendEvent = () => {
205 if (!eventSent && ws.readyState === WebSocket.OPEN) {
206 eventSent = true;
207 ws.send(JSON.stringify(['EVENT', signedEvent]));
208 }
209 };
210
211 ws.onopen = () => {
212 // Wait a moment for potential AUTH challenge before sending event
213 setTimeout(() => {
214 if (!authenticated) {
215 // No auth challenge received, try sending event directly
216 sendEvent();
217 }
218 }, 500);
219 };
220
221 ws.onmessage = (event) => {
222 try {
223 const message = JSON.parse(event.data);
224 const messageType = message[0];
225
226 if (messageType === 'AUTH') {
227 // Relay requires authentication
228 const challenge = message[1];
229 const authEvent = createAuthEvent(relayUrl, challenge, privateKeyHex);
230 ws.send(JSON.stringify(['AUTH', authEvent]));
231 authenticated = true;
232 } else if (messageType === 'OK') {
233 const eventId = message[1];
234 const success = message[2];
235 const msg = message[3] || '';
236
237 // Check if this is our event or AUTH response
238 if (eventId === signedEvent.id) {
239 // This is the response to our published event
240 clearTimeout(timeout);
241 ws.close();
242
243 if (success) {
244 resolve({
245 relay: relayUrl,
246 success: true,
247 message: 'Published successfully',
248 });
249 } else {
250 // Check if we need to retry after auth
251 if (msg.includes('auth-required') && !authenticated) {
252 // Relay requires auth but didn't send challenge
253 // This shouldn't normally happen
254 resolve({
255 relay: relayUrl,
256 success: false,
257 message: 'Auth required but no challenge received',
258 });
259 } else {
260 resolve({
261 relay: relayUrl,
262 success: false,
263 message: msg || 'Publish rejected',
264 });
265 }
266 }
267 } else if (authenticated && !eventSent) {
268 // This is the OK response to our AUTH
269 if (success) {
270 // Auth succeeded, now send the event
271 sendEvent();
272 } else {
273 clearTimeout(timeout);
274 ws.close();
275 resolve({
276 relay: relayUrl,
277 success: false,
278 message: `Authentication failed: ${msg}`,
279 });
280 }
281 }
282 } else if (messageType === 'NOTICE') {
283 // Log notices but don't fail
284 console.log(`Relay ${relayUrl} notice: ${message[1]}`);
285 }
286 } catch {
287 // Ignore parse errors
288 }
289 };
290
291 ws.onerror = () => {
292 clearTimeout(timeout);
293 resolve({
294 relay: relayUrl,
295 success: false,
296 message: 'Connection error',
297 });
298 };
299
300 ws.onclose = () => {
301 // If we haven't resolved yet, treat as failure
302 clearTimeout(timeout);
303 };
304 });
305 }
306
307 /**
308 * Publish an event to multiple relays with NIP-42 support
309 *
310 * @param relayUrls - Array of relay WebSocket URLs
311 * @param signedEvent - The already-signed Nostr event to publish
312 * @param privateKeyHex - Private key for AUTH (if required)
313 * @returns Promise resolving to array of publish results
314 */
315 export async function publishToRelaysWithAuth(
316 relayUrls: string[],
317 signedEvent: ReturnType<typeof finalizeEvent>,
318 privateKeyHex: string
319 ): Promise<PublishResult[]> {
320 const results = await Promise.all(
321 relayUrls.map((url) => publishEventWithAuth(url, signedEvent, privateKeyHex))
322 );
323 return results;
324 }
325