bunker-worker.js raw

   1  /**
   2   * BunkerWorker - Web Worker for persistent NIP-46 bunker service
   3   *
   4   * Runs in a separate thread to maintain WebSocket connection
   5   * regardless of UI component lifecycle.
   6   */
   7  
   8  import { nip04 } from 'nostr-tools';
   9  import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
  10  import { secp256k1 } from '@noble/curves/secp256k1';
  11  
  12  // State
  13  let ws = null;
  14  let connected = false;
  15  let userPubkey = null;
  16  let userPrivkey = null;
  17  let relayUrl = null;
  18  let subscriptionId = null;
  19  let heartbeatInterval = null;
  20  let allowedSecrets = new Set();
  21  let connectedClients = new Map();
  22  
  23  // NIP-46 methods
  24  const NIP46_METHOD = {
  25      CONNECT: 'connect',
  26      GET_PUBLIC_KEY: 'get_public_key',
  27      SIGN_EVENT: 'sign_event',
  28      NIP04_ENCRYPT: 'nip04_encrypt',
  29      NIP04_DECRYPT: 'nip04_decrypt',
  30      PING: 'ping'
  31  };
  32  
  33  function generateRandomHex(bytes = 16) {
  34      const arr = new Uint8Array(bytes);
  35      crypto.getRandomValues(arr);
  36      return bytesToHex(arr);
  37  }
  38  
  39  function postStatus(status, data = {}) {
  40      self.postMessage({ type: 'status', status, ...data });
  41  }
  42  
  43  function postError(error) {
  44      self.postMessage({ type: 'error', error });
  45  }
  46  
  47  function postClientsUpdate() {
  48      const clients = Array.from(connectedClients.entries()).map(([pubkey, info]) => ({
  49          pubkey,
  50          ...info
  51      }));
  52      self.postMessage({ type: 'clients', clients });
  53  }
  54  
  55  async function connect() {
  56      if (connected || !relayUrl || !userPubkey || !userPrivkey) {
  57          postError('Missing configuration or already connected');
  58          return;
  59      }
  60  
  61      return new Promise((resolve, reject) => {
  62          let wsUrl = relayUrl;
  63          if (wsUrl.startsWith('http://')) {
  64              wsUrl = 'ws://' + wsUrl.slice(7);
  65          } else if (wsUrl.startsWith('https://')) {
  66              wsUrl = 'wss://' + wsUrl.slice(8);
  67          } else if (!wsUrl.startsWith('ws://') && !wsUrl.startsWith('wss://')) {
  68              wsUrl = 'wss://' + wsUrl;
  69          }
  70  
  71          console.log('[BunkerWorker] Connecting to:', wsUrl);
  72  
  73          ws = new WebSocket(wsUrl);
  74  
  75          const timeout = setTimeout(() => {
  76              ws.close();
  77              postError('Connection timeout');
  78              reject(new Error('Connection timeout'));
  79          }, 10000);
  80  
  81          ws.onopen = () => {
  82              clearTimeout(timeout);
  83              connected = true;
  84              console.log('[BunkerWorker] Connected to relay');
  85  
  86              // Subscribe to NIP-46 events
  87              subscriptionId = generateRandomHex(8);
  88              const sub = JSON.stringify([
  89                  'REQ',
  90                  subscriptionId,
  91                  {
  92                      kinds: [24133],
  93                      '#p': [userPubkey],
  94                      since: Math.floor(Date.now() / 1000) - 60
  95                  }
  96              ]);
  97              ws.send(sub);
  98  
  99              startHeartbeat();
 100              postStatus('connected');
 101              resolve();
 102          };
 103  
 104          ws.onerror = (error) => {
 105              clearTimeout(timeout);
 106              console.error('[BunkerWorker] WebSocket error:', error);
 107              postError('WebSocket error');
 108              reject(new Error('WebSocket error'));
 109          };
 110  
 111          ws.onclose = () => {
 112              connected = false;
 113              ws = null;
 114              stopHeartbeat();
 115              console.log('[BunkerWorker] Disconnected from relay');
 116              postStatus('disconnected');
 117          };
 118  
 119          ws.onmessage = (event) => {
 120              handleMessage(event.data);
 121          };
 122      });
 123  }
 124  
 125  function disconnect() {
 126      stopHeartbeat();
 127      if (ws) {
 128          if (subscriptionId) {
 129              ws.send(JSON.stringify(['CLOSE', subscriptionId]));
 130          }
 131          ws.close();
 132          ws = null;
 133      }
 134      connected = false;
 135      connectedClients.clear();
 136      postStatus('disconnected');
 137      postClientsUpdate();
 138  }
 139  
 140  function startHeartbeat(intervalMs = 30000) {
 141      stopHeartbeat();
 142      heartbeatInterval = setInterval(() => {
 143          if (ws && ws.readyState === WebSocket.OPEN) {
 144              const sub = JSON.stringify([
 145                  'REQ',
 146                  subscriptionId,
 147                  {
 148                      kinds: [24133],
 149                      '#p': [userPubkey],
 150                      since: Math.floor(Date.now() / 1000) - 60
 151                  }
 152              ]);
 153              ws.send(sub);
 154          }
 155      }, intervalMs);
 156  }
 157  
 158  function stopHeartbeat() {
 159      if (heartbeatInterval) {
 160          clearInterval(heartbeatInterval);
 161          heartbeatInterval = null;
 162      }
 163  }
 164  
 165  async function handleMessage(data) {
 166      try {
 167          const msg = JSON.parse(data);
 168          if (!Array.isArray(msg)) return;
 169  
 170          const [type, ...rest] = msg;
 171  
 172          if (type === 'EVENT') {
 173              const [, event] = rest;
 174              if (event.kind === 24133) {
 175                  await handleNIP46Request(event);
 176              }
 177          } else if (type === 'OK') {
 178              console.log('[BunkerWorker] Event published:', rest[0]?.substring(0, 8));
 179          } else if (type === 'NOTICE') {
 180              console.warn('[BunkerWorker] Relay notice:', rest[0]);
 181          }
 182      } catch (err) {
 183          console.error('[BunkerWorker] Failed to parse message:', err);
 184      }
 185  }
 186  
 187  async function handleNIP46Request(event) {
 188      try {
 189          const privkeyHex = bytesToHex(userPrivkey);
 190          const decrypted = await nip04.decrypt(privkeyHex, event.pubkey, event.content);
 191          const request = JSON.parse(decrypted);
 192  
 193          console.log('[BunkerWorker] Received request:', request.method, 'from:', event.pubkey.substring(0, 8));
 194  
 195          // Log to main thread
 196          self.postMessage({
 197              type: 'request',
 198              method: request.method,
 199              from: event.pubkey,
 200              timestamp: Date.now()
 201          });
 202  
 203          let result = null;
 204          let error = null;
 205  
 206          try {
 207              switch (request.method) {
 208                  case NIP46_METHOD.CONNECT:
 209                      result = await handleConnect(request, event.pubkey);
 210                      break;
 211                  case NIP46_METHOD.GET_PUBLIC_KEY:
 212                      result = handleGetPublicKey(event.pubkey);
 213                      break;
 214                  case NIP46_METHOD.SIGN_EVENT:
 215                      result = await handleSignEvent(request, event.pubkey);
 216                      break;
 217                  case NIP46_METHOD.NIP04_ENCRYPT:
 218                      result = await handleNip04Encrypt(request, event.pubkey);
 219                      break;
 220                  case NIP46_METHOD.NIP04_DECRYPT:
 221                      result = await handleNip04Decrypt(request, event.pubkey);
 222                      break;
 223                  case NIP46_METHOD.PING:
 224                      result = 'pong';
 225                      break;
 226                  default:
 227                      error = `Unknown method: ${request.method}`;
 228              }
 229          } catch (err) {
 230              console.error('[BunkerWorker] Error handling request:', err);
 231              error = err.message;
 232          }
 233  
 234          await sendResponse(request.id, result, error, event.pubkey);
 235  
 236      } catch (err) {
 237          console.error('[BunkerWorker] Failed to handle NIP-46 request:', err);
 238      }
 239  }
 240  
 241  async function handleConnect(request, senderPubkey) {
 242      const [clientPubkey, secret] = request.params;
 243  
 244      if (allowedSecrets.size > 0) {
 245          if (!secret || !allowedSecrets.has(secret)) {
 246              throw new Error('Invalid or missing connection secret');
 247          }
 248      }
 249  
 250      connectedClients.set(senderPubkey, {
 251          clientPubkey: clientPubkey || senderPubkey,
 252          connectedAt: Date.now(),
 253          lastActivity: Date.now()
 254      });
 255  
 256      console.log('[BunkerWorker] Client connected:', senderPubkey.substring(0, 8));
 257      postClientsUpdate();
 258  
 259      return 'ack';
 260  }
 261  
 262  function handleGetPublicKey(senderPubkey) {
 263      if (connectedClients.has(senderPubkey)) {
 264          connectedClients.get(senderPubkey).lastActivity = Date.now();
 265      }
 266      return userPubkey;
 267  }
 268  
 269  async function handleSignEvent(request, senderPubkey) {
 270      if (!connectedClients.has(senderPubkey)) {
 271          throw new Error('Not connected');
 272      }
 273  
 274      connectedClients.get(senderPubkey).lastActivity = Date.now();
 275  
 276      const [eventJson] = request.params;
 277      const event = JSON.parse(eventJson);
 278  
 279      if (event.pubkey && event.pubkey !== userPubkey) {
 280          throw new Error('Event pubkey does not match signer pubkey');
 281      }
 282  
 283      event.pubkey = userPubkey;
 284  
 285      const serialized = JSON.stringify([
 286          0,
 287          event.pubkey,
 288          event.created_at,
 289          event.kind,
 290          event.tags,
 291          event.content
 292      ]);
 293      const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(serialized));
 294      event.id = bytesToHex(new Uint8Array(hash));
 295  
 296      const sig = secp256k1.sign(hexToBytes(event.id), userPrivkey);
 297      event.sig = sig.toCompactHex();
 298  
 299      console.log('[BunkerWorker] Signed event:', event.id.substring(0, 8), 'kind:', event.kind);
 300  
 301      return JSON.stringify(event);
 302  }
 303  
 304  async function handleNip04Encrypt(request, senderPubkey) {
 305      if (!connectedClients.has(senderPubkey)) {
 306          throw new Error('Not connected');
 307      }
 308  
 309      connectedClients.get(senderPubkey).lastActivity = Date.now();
 310  
 311      const [pubkey, plaintext] = request.params;
 312      const privkeyHex = bytesToHex(userPrivkey);
 313      return await nip04.encrypt(privkeyHex, pubkey, plaintext);
 314  }
 315  
 316  async function handleNip04Decrypt(request, senderPubkey) {
 317      if (!connectedClients.has(senderPubkey)) {
 318          throw new Error('Not connected');
 319      }
 320  
 321      connectedClients.get(senderPubkey).lastActivity = Date.now();
 322  
 323      const [pubkey, ciphertext] = request.params;
 324      const privkeyHex = bytesToHex(userPrivkey);
 325      return await nip04.decrypt(privkeyHex, pubkey, ciphertext);
 326  }
 327  
 328  async function sendResponse(requestId, result, error, recipientPubkey) {
 329      if (!ws || !connected) {
 330          console.error('[BunkerWorker] Cannot send response: not connected');
 331          return;
 332      }
 333  
 334      const response = {
 335          id: requestId,
 336          result: result !== null ? result : undefined,
 337          error: error !== null ? error : undefined
 338      };
 339  
 340      const privkeyHex = bytesToHex(userPrivkey);
 341      const encrypted = await nip04.encrypt(privkeyHex, recipientPubkey, JSON.stringify(response));
 342  
 343      const event = {
 344          kind: 24133,
 345          pubkey: userPubkey,
 346          created_at: Math.floor(Date.now() / 1000),
 347          content: encrypted,
 348          tags: [['p', recipientPubkey]]
 349      };
 350  
 351      const serialized = JSON.stringify([
 352          0,
 353          event.pubkey,
 354          event.created_at,
 355          event.kind,
 356          event.tags,
 357          event.content
 358      ]);
 359      const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(serialized));
 360      event.id = bytesToHex(new Uint8Array(hash));
 361  
 362      const sig = secp256k1.sign(hexToBytes(event.id), userPrivkey);
 363      event.sig = sig.toCompactHex();
 364  
 365      ws.send(JSON.stringify(['EVENT', event]));
 366      console.log('[BunkerWorker] Sent response for:', requestId);
 367  }
 368  
 369  // Message handler from main thread
 370  self.onmessage = async (event) => {
 371      const { type, ...data } = event.data;
 372  
 373      switch (type) {
 374          case 'configure':
 375              userPubkey = data.userPubkey;
 376              userPrivkey = data.userPrivkey ? hexToBytes(data.userPrivkey) : null;
 377              relayUrl = data.relayUrl;
 378              if (data.secrets) {
 379                  allowedSecrets = new Set(data.secrets);
 380              }
 381              console.log('[BunkerWorker] Configured for pubkey:', userPubkey?.substring(0, 8));
 382              break;
 383  
 384          case 'connect':
 385              try {
 386                  await connect();
 387              } catch (err) {
 388                  postError(err.message);
 389              }
 390              break;
 391  
 392          case 'disconnect':
 393              disconnect();
 394              break;
 395  
 396          case 'addSecret':
 397              allowedSecrets.add(data.secret);
 398              break;
 399  
 400          case 'removeSecret':
 401              allowedSecrets.delete(data.secret);
 402              break;
 403  
 404          case 'getStatus':
 405              postStatus(connected ? 'connected' : 'disconnected');
 406              postClientsUpdate();
 407              break;
 408  
 409          default:
 410              console.warn('[BunkerWorker] Unknown message type:', type);
 411      }
 412  };
 413  
 414  console.log('[BunkerWorker] Worker initialized');
 415