bunker-service.js raw

   1  /**
   2   * BunkerService - NIP-46 Remote Signer
   3   *
   4   * Implements the signer side of NIP-46 protocol.
   5   * Listens for signing requests from remote clients and responds using
   6   * the user's private key stored in ORLY.
   7   *
   8   * Protocol:
   9   * - Kind 24133 events for request/response
  10   * - NIP-04 encryption for payloads
  11   * - Methods: connect, get_public_key, sign_event, nip04_encrypt, nip04_decrypt, ping
  12   */
  13  
  14  import { nip04 } from 'nostr-tools';
  15  import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
  16  import { secp256k1 } from '@noble/curves/secp256k1';
  17  
  18  // NIP-46 methods
  19  const NIP46_METHOD = {
  20      CONNECT: 'connect',
  21      GET_PUBLIC_KEY: 'get_public_key',
  22      SIGN_EVENT: 'sign_event',
  23      NIP04_ENCRYPT: 'nip04_encrypt',
  24      NIP04_DECRYPT: 'nip04_decrypt',
  25      PING: 'ping'
  26  };
  27  
  28  /**
  29   * Generate a random hex string.
  30   */
  31  function generateRandomHex(bytes = 16) {
  32      const arr = new Uint8Array(bytes);
  33      crypto.getRandomValues(arr);
  34      return bytesToHex(arr);
  35  }
  36  
  37  /**
  38   * BunkerService class - implements NIP-46 signer protocol.
  39   */
  40  export class BunkerService {
  41      /**
  42       * @param {string} relayUrl - WebSocket URL of the relay
  43       * @param {string} userPubkey - User's public key (hex)
  44       * @param {Uint8Array} userPrivkey - User's private key (32 bytes)
  45       */
  46      constructor(relayUrl, userPubkey, userPrivkey) {
  47          this.relayUrl = relayUrl;
  48          this.userPubkey = userPubkey;
  49          this.userPrivkey = userPrivkey;
  50          this.ws = null;
  51          this.connected = false;
  52          this.allowedSecrets = new Set();
  53          this.connectedClients = new Map(); // pubkey -> { connectedAt, lastActivity }
  54          this.requestLog = [];
  55          this.heartbeatInterval = null;
  56          this.subscriptionId = null;
  57  
  58          // Callbacks
  59          this.onClientConnected = null;
  60          this.onClientDisconnected = null;
  61          this.onRequest = null;
  62          this.onStatusChange = null;
  63      }
  64  
  65      /**
  66       * Add an allowed connection secret.
  67       */
  68      addAllowedSecret(secret) {
  69          this.allowedSecrets.add(secret);
  70      }
  71  
  72      /**
  73       * Remove an allowed secret.
  74       */
  75      removeAllowedSecret(secret) {
  76          this.allowedSecrets.delete(secret);
  77      }
  78  
  79      /**
  80       * Connect to the relay and start listening for NIP-46 requests.
  81       */
  82      async connect() {
  83          return new Promise((resolve, reject) => {
  84              // Build WebSocket URL
  85              let wsUrl = this.relayUrl;
  86              if (wsUrl.startsWith('http://')) {
  87                  wsUrl = 'ws://' + wsUrl.slice(7);
  88              } else if (wsUrl.startsWith('https://')) {
  89                  wsUrl = 'wss://' + wsUrl.slice(8);
  90              } else if (!wsUrl.startsWith('ws://') && !wsUrl.startsWith('wss://')) {
  91                  wsUrl = 'wss://' + wsUrl;
  92              }
  93  
  94              console.log('[BunkerService] Connecting to:', wsUrl);
  95  
  96              const ws = new WebSocket(wsUrl);
  97  
  98              const timeout = setTimeout(() => {
  99                  ws.close();
 100                  reject(new Error('Connection timeout'));
 101              }, 10000);
 102  
 103              ws.onopen = () => {
 104                  clearTimeout(timeout);
 105                  this.ws = ws;
 106                  this.connected = true;
 107                  console.log('[BunkerService] Connected to relay');
 108  
 109                  // Subscribe to NIP-46 events for our pubkey
 110                  this.subscriptionId = generateRandomHex(8);
 111                  const sub = JSON.stringify([
 112                      'REQ',
 113                      this.subscriptionId,
 114                      {
 115                          kinds: [24133],
 116                          '#p': [this.userPubkey],
 117                          since: Math.floor(Date.now() / 1000) - 60
 118                      }
 119                  ]);
 120                  ws.send(sub);
 121                  console.log('[BunkerService] Subscribed to NIP-46 events');
 122  
 123                  // Start heartbeat
 124                  this.startHeartbeat();
 125  
 126                  if (this.onStatusChange) {
 127                      this.onStatusChange('connected');
 128                  }
 129  
 130                  resolve();
 131              };
 132  
 133              ws.onerror = (error) => {
 134                  clearTimeout(timeout);
 135                  console.error('[BunkerService] WebSocket error:', error);
 136                  reject(new Error('WebSocket error'));
 137              };
 138  
 139              ws.onclose = () => {
 140                  this.connected = false;
 141                  this.ws = null;
 142                  this.stopHeartbeat();
 143                  console.log('[BunkerService] Disconnected from relay');
 144  
 145                  if (this.onStatusChange) {
 146                      this.onStatusChange('disconnected');
 147                  }
 148              };
 149  
 150              ws.onmessage = (event) => {
 151                  this.handleMessage(event.data);
 152              };
 153          });
 154      }
 155  
 156      /**
 157       * Start WebSocket heartbeat to keep connection alive.
 158       */
 159      startHeartbeat(intervalMs = 30000) {
 160          this.stopHeartbeat();
 161          this.heartbeatInterval = setInterval(() => {
 162              if (this.ws && this.ws.readyState === WebSocket.OPEN) {
 163                  // Send a ping via Nostr protocol (re-subscribe)
 164                  const sub = JSON.stringify([
 165                      'REQ',
 166                      this.subscriptionId,
 167                      {
 168                          kinds: [24133],
 169                          '#p': [this.userPubkey],
 170                          since: Math.floor(Date.now() / 1000) - 60
 171                      }
 172                  ]);
 173                  this.ws.send(sub);
 174              }
 175          }, intervalMs);
 176      }
 177  
 178      /**
 179       * Stop WebSocket heartbeat.
 180       */
 181      stopHeartbeat() {
 182          if (this.heartbeatInterval) {
 183              clearInterval(this.heartbeatInterval);
 184              this.heartbeatInterval = null;
 185          }
 186      }
 187  
 188      /**
 189       * Disconnect from the relay.
 190       */
 191      disconnect() {
 192          this.stopHeartbeat();
 193          if (this.ws) {
 194              // Close subscription
 195              if (this.subscriptionId) {
 196                  this.ws.send(JSON.stringify(['CLOSE', this.subscriptionId]));
 197              }
 198              this.ws.close();
 199              this.ws = null;
 200          }
 201          this.connected = false;
 202          this.connectedClients.clear();
 203      }
 204  
 205      /**
 206       * Handle incoming WebSocket messages.
 207       */
 208      async handleMessage(data) {
 209          try {
 210              const msg = JSON.parse(data);
 211              if (!Array.isArray(msg)) return;
 212  
 213              const [type, ...rest] = msg;
 214  
 215              if (type === 'EVENT') {
 216                  const [, event] = rest;
 217                  if (event.kind === 24133) {
 218                      await this.handleNIP46Request(event);
 219                  }
 220              } else if (type === 'OK') {
 221                  // Event published confirmation
 222                  console.log('[BunkerService] Event published:', rest[0]?.substring(0, 8));
 223              } else if (type === 'NOTICE') {
 224                  console.warn('[BunkerService] Relay notice:', rest[0]);
 225              }
 226          } catch (err) {
 227              console.error('[BunkerService] Failed to parse message:', err);
 228          }
 229      }
 230  
 231      /**
 232       * Handle NIP-46 request event.
 233       */
 234      async handleNIP46Request(event) {
 235          try {
 236              // Decrypt the content with NIP-04
 237              const privkeyHex = bytesToHex(this.userPrivkey);
 238              const decrypted = await nip04.decrypt(privkeyHex, event.pubkey, event.content);
 239              const request = JSON.parse(decrypted);
 240  
 241              console.log('[BunkerService] Received request:', request.method, 'from:', event.pubkey.substring(0, 8));
 242  
 243              // Log the request
 244              this.requestLog.push({
 245                  id: request.id,
 246                  method: request.method,
 247                  from: event.pubkey,
 248                  timestamp: Date.now()
 249              });
 250              if (this.requestLog.length > 100) {
 251                  this.requestLog.shift();
 252              }
 253  
 254              if (this.onRequest) {
 255                  this.onRequest(request, event.pubkey);
 256              }
 257  
 258              // Handle the request
 259              let result = null;
 260              let error = null;
 261  
 262              try {
 263                  switch (request.method) {
 264                      case NIP46_METHOD.CONNECT:
 265                          result = await this.handleConnect(request, event.pubkey);
 266                          break;
 267                      case NIP46_METHOD.GET_PUBLIC_KEY:
 268                          result = await this.handleGetPublicKey(request, event.pubkey);
 269                          break;
 270                      case NIP46_METHOD.SIGN_EVENT:
 271                          result = await this.handleSignEvent(request, event.pubkey);
 272                          break;
 273                      case NIP46_METHOD.NIP04_ENCRYPT:
 274                          result = await this.handleNip04Encrypt(request, event.pubkey);
 275                          break;
 276                      case NIP46_METHOD.NIP04_DECRYPT:
 277                          result = await this.handleNip04Decrypt(request, event.pubkey);
 278                          break;
 279                      case NIP46_METHOD.PING:
 280                          result = 'pong';
 281                          break;
 282                      default:
 283                          error = `Unknown method: ${request.method}`;
 284                  }
 285              } catch (err) {
 286                  console.error('[BunkerService] Error handling request:', err);
 287                  error = err.message;
 288              }
 289  
 290              // Send response
 291              await this.sendResponse(request.id, result, error, event.pubkey);
 292  
 293          } catch (err) {
 294              console.error('[BunkerService] Failed to handle NIP-46 request:', err);
 295          }
 296      }
 297  
 298      /**
 299       * Handle connect request.
 300       */
 301      async handleConnect(request, senderPubkey) {
 302          const [clientPubkey, secret] = request.params;
 303  
 304          // Validate secret if required
 305          if (this.allowedSecrets.size > 0) {
 306              if (!secret || !this.allowedSecrets.has(secret)) {
 307                  throw new Error('Invalid or missing connection secret');
 308              }
 309          }
 310  
 311          // Register connected client
 312          this.connectedClients.set(senderPubkey, {
 313              clientPubkey: clientPubkey || senderPubkey,
 314              connectedAt: Date.now(),
 315              lastActivity: Date.now()
 316          });
 317  
 318          console.log('[BunkerService] Client connected:', senderPubkey.substring(0, 8));
 319  
 320          if (this.onClientConnected) {
 321              this.onClientConnected(senderPubkey);
 322          }
 323  
 324          return 'ack';
 325      }
 326  
 327      /**
 328       * Handle get_public_key request.
 329       */
 330      async handleGetPublicKey(request, senderPubkey) {
 331          // Update last activity
 332          if (this.connectedClients.has(senderPubkey)) {
 333              this.connectedClients.get(senderPubkey).lastActivity = Date.now();
 334          }
 335  
 336          return this.userPubkey;
 337      }
 338  
 339      /**
 340       * Handle sign_event request.
 341       */
 342      async handleSignEvent(request, senderPubkey) {
 343          // Check if client is connected
 344          if (!this.connectedClients.has(senderPubkey)) {
 345              throw new Error('Not connected');
 346          }
 347  
 348          // Update last activity
 349          this.connectedClients.get(senderPubkey).lastActivity = Date.now();
 350  
 351          const [eventJson] = request.params;
 352          const event = JSON.parse(eventJson);
 353  
 354          // Ensure pubkey matches
 355          if (event.pubkey && event.pubkey !== this.userPubkey) {
 356              throw new Error('Event pubkey does not match signer pubkey');
 357          }
 358  
 359          // Set pubkey if not set
 360          event.pubkey = this.userPubkey;
 361  
 362          // Calculate event ID
 363          const serialized = JSON.stringify([
 364              0,
 365              event.pubkey,
 366              event.created_at,
 367              event.kind,
 368              event.tags,
 369              event.content
 370          ]);
 371          const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(serialized));
 372          event.id = bytesToHex(new Uint8Array(hash));
 373  
 374          // Sign the event
 375          const sig = secp256k1.sign(hexToBytes(event.id), this.userPrivkey);
 376          event.sig = sig.toCompactHex();
 377  
 378          console.log('[BunkerService] Signed event:', event.id.substring(0, 8), 'kind:', event.kind);
 379  
 380          return JSON.stringify(event);
 381      }
 382  
 383      /**
 384       * Handle nip04_encrypt request.
 385       */
 386      async handleNip04Encrypt(request, senderPubkey) {
 387          // Check if client is connected
 388          if (!this.connectedClients.has(senderPubkey)) {
 389              throw new Error('Not connected');
 390          }
 391  
 392          // Update last activity
 393          this.connectedClients.get(senderPubkey).lastActivity = Date.now();
 394  
 395          const [pubkey, plaintext] = request.params;
 396          const privkeyHex = bytesToHex(this.userPrivkey);
 397          const ciphertext = await nip04.encrypt(privkeyHex, pubkey, plaintext);
 398          return ciphertext;
 399      }
 400  
 401      /**
 402       * Handle nip04_decrypt request.
 403       */
 404      async handleNip04Decrypt(request, senderPubkey) {
 405          // Check if client is connected
 406          if (!this.connectedClients.has(senderPubkey)) {
 407              throw new Error('Not connected');
 408          }
 409  
 410          // Update last activity
 411          this.connectedClients.get(senderPubkey).lastActivity = Date.now();
 412  
 413          const [pubkey, ciphertext] = request.params;
 414          const privkeyHex = bytesToHex(this.userPrivkey);
 415          const plaintext = await nip04.decrypt(privkeyHex, pubkey, ciphertext);
 416          return plaintext;
 417      }
 418  
 419      /**
 420       * Send NIP-46 response to client.
 421       */
 422      async sendResponse(requestId, result, error, recipientPubkey) {
 423          if (!this.ws || !this.connected) {
 424              console.error('[BunkerService] Cannot send response: not connected');
 425              return;
 426          }
 427  
 428          const response = {
 429              id: requestId,
 430              result: result !== null ? result : undefined,
 431              error: error !== null ? error : undefined
 432          };
 433  
 434          // Encrypt response with NIP-04
 435          const privkeyHex = bytesToHex(this.userPrivkey);
 436          const encrypted = await nip04.encrypt(privkeyHex, recipientPubkey, JSON.stringify(response));
 437  
 438          // Create response event
 439          const event = {
 440              kind: 24133,
 441              pubkey: this.userPubkey,
 442              created_at: Math.floor(Date.now() / 1000),
 443              content: encrypted,
 444              tags: [['p', recipientPubkey]]
 445          };
 446  
 447          // Calculate event ID
 448          const serialized = JSON.stringify([
 449              0,
 450              event.pubkey,
 451              event.created_at,
 452              event.kind,
 453              event.tags,
 454              event.content
 455          ]);
 456          const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(serialized));
 457          event.id = bytesToHex(new Uint8Array(hash));
 458  
 459          // Sign the event
 460          const sig = secp256k1.sign(hexToBytes(event.id), this.userPrivkey);
 461          event.sig = sig.toCompactHex();
 462  
 463          // Send to relay
 464          this.ws.send(JSON.stringify(['EVENT', event]));
 465          console.log('[BunkerService] Sent response for:', requestId);
 466      }
 467  
 468      /**
 469       * Check if the service is connected.
 470       */
 471      isConnected() {
 472          return this.connected;
 473      }
 474  
 475      /**
 476       * Get list of connected clients.
 477       */
 478      getConnectedClients() {
 479          return Array.from(this.connectedClients.entries()).map(([pubkey, info]) => ({
 480              pubkey,
 481              ...info
 482          }));
 483      }
 484  
 485      /**
 486       * Get request log.
 487       */
 488      getRequestLog() {
 489          return [...this.requestLog];
 490      }
 491  }
 492