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