smesh-signer-extension.ts raw

   1  /* eslint-disable @typescript-eslint/no-explicit-any */
   2  import { Event as NostrEvent, EventTemplate } from 'nostr-tools';
   3  import { ExtensionMethod } from '@common';
   4  
   5  // Extend Window interface for NIP-07 and WebLN
   6  declare global {
   7    interface Window {
   8      nostr?: any;
   9      webln?: any;
  10    }
  11  }
  12  
  13  type Relays = Record<string, { read: boolean; write: boolean }>;
  14  
  15  // Fallback UUID generator for contexts where crypto.randomUUID is unavailable
  16  function generateUUID(): string {
  17    if (typeof crypto !== 'undefined' && crypto.randomUUID) {
  18      return crypto.randomUUID();
  19    }
  20    // Fallback using crypto.getRandomValues
  21    const bytes = new Uint8Array(16);
  22    crypto.getRandomValues(bytes);
  23    bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4
  24    bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant 10
  25    const hex = [...bytes].map(b => b.toString(16).padStart(2, '0')).join('');
  26    return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
  27  }
  28  
  29  class Messenger {
  30    #requests = new Map<
  31      string,
  32      {
  33        resolve: (value: unknown) => void;
  34        reject: (reason: any) => void;
  35      }
  36    >();
  37  
  38    constructor() {
  39      window.addEventListener('message', this.#handleCallResponse.bind(this));
  40    }
  41  
  42    async request(method: ExtensionMethod, params: any): Promise<any> {
  43      const id = generateUUID();
  44  
  45      return new Promise((resolve, reject) => {
  46        this.#requests.set(id, { resolve, reject });
  47        window.postMessage(
  48          {
  49            id,
  50            ext: 'smesh-signer',
  51            method,
  52            params,
  53          },
  54          '*'
  55        );
  56      });
  57    }
  58  
  59    #handleCallResponse(message: MessageEvent) {
  60      // We also will receive our own messages, that we sent.
  61      // We have to ignore them (they will not have a response field).
  62      if (
  63        !message.data ||
  64        message.data.response === null ||
  65        message.data.response === undefined ||
  66        message.data.ext !== 'smesh-signer' ||
  67        !this.#requests.has(message.data.id)
  68      ) {
  69        return;
  70      }
  71  
  72      if (message.data.response.error) {
  73        this.#requests.get(message.data.id)?.reject(message.data.response.error);
  74      } else {
  75        this.#requests.get(message.data.id)?.resolve(message.data.response);
  76      }
  77  
  78      this.#requests.delete(message.data.id);
  79    }
  80  }
  81  
  82  const nostr = {
  83    messenger: new Messenger(),
  84  
  85    async getPublicKey(): Promise<string> {
  86      debug('getPublicKey received');
  87      const pubkey = await this.messenger.request('getPublicKey', {});
  88      debug(`getPublicKey response:`);
  89      debug(pubkey);
  90      return pubkey;
  91    },
  92  
  93    async signEvent(event: EventTemplate): Promise<NostrEvent> {
  94      debug('signEvent received');
  95      const signedEvent = await this.messenger.request('signEvent', event);
  96      debug('signEvent response:');
  97      debug(signedEvent);
  98      return signedEvent;
  99    },
 100  
 101    async getRelays(): Promise<Relays> {
 102      debug('getRelays received');
 103      const relays = (await this.messenger.request('getRelays', {})) as Relays;
 104      debug('getRelays response:');
 105      debug(relays);
 106      return relays;
 107    },
 108  
 109    nip04: {
 110      that: this,
 111  
 112      async encrypt(peerPubkey: string, plaintext: string): Promise<string> {
 113        debug('nip04.encrypt received');
 114        const ciphertext = (await nostr.messenger.request('nip04.encrypt', {
 115          peerPubkey,
 116          plaintext,
 117        })) as string;
 118        debug('nip04.encrypt response:');
 119        debug(ciphertext);
 120        return ciphertext;
 121      },
 122  
 123      async decrypt(peerPubkey: string, ciphertext: string): Promise<string> {
 124        debug('nip04.decrypt received');
 125        const plaintext = (await nostr.messenger.request('nip04.decrypt', {
 126          peerPubkey,
 127          ciphertext,
 128        })) as string;
 129        debug('nip04.decrypt response:');
 130        debug(plaintext);
 131        return plaintext;
 132      },
 133    },
 134  
 135    nip44: {
 136      async encrypt(peerPubkey: string, plaintext: string): Promise<string> {
 137        debug('nip44.encrypt received');
 138        const ciphertext = (await nostr.messenger.request('nip44.encrypt', {
 139          peerPubkey,
 140          plaintext,
 141        })) as string;
 142        debug('nip44.encrypt response:');
 143        debug(ciphertext);
 144        return ciphertext;
 145      },
 146  
 147      async decrypt(peerPubkey: string, ciphertext: string): Promise<string> {
 148        debug('nip44.decrypt received');
 149        const plaintext = (await nostr.messenger.request('nip44.decrypt', {
 150          peerPubkey,
 151          ciphertext,
 152        })) as string;
 153        debug('nip44.decrypt response:');
 154        debug(plaintext);
 155        return plaintext;
 156      },
 157    },
 158  
 159    mls: {
 160      async init(relayURLs: string[], lastEventTS?: number): Promise<string> {
 161        debug('mls.init received');
 162        // Persist relay URLs on window so mls-bridge.mjs can pick them up even if
 163        // it loaded after this CustomEvent fired (DOM events are not queued).
 164        (window as any)._nostrMlsRelays = relayURLs;
 165        window.dispatchEvent(new CustomEvent('nostr-mls', { detail: { cmd: 'relays', relays: relayURLs } }));
 166        const r = await nostr.messenger.request('mls.init' as any, { relayURLs, lastEventTS: lastEventTS || 0 });
 167        debug('mls.init result: ' + r);
 168        return r;
 169      },
 170      async sendDM(recipient: string, content: string): Promise<string> {
 171        debug('mls.sendDM received: ' + recipient.slice(0, 8) + '...');
 172        const r = await nostr.messenger.request('mls.sendDM' as any, { recipient, content });
 173        debug('mls.sendDM result: ' + r);
 174        return r;
 175      },
 176      async subscribe(): Promise<string> {
 177        debug('mls.subscribe received');
 178        return await nostr.messenger.request('mls.subscribe' as any, {});
 179      },
 180      async publishKP(): Promise<string> {
 181        debug('mls.publishKP received');
 182        return await nostr.messenger.request('mls.publishKP' as any, {});
 183      },
 184      async listGroups(): Promise<string[]> {
 185        return await nostr.messenger.request('mls.listGroups' as any, {});
 186      },
 187      async deliverEvent(subId: number, eventJSON: string): Promise<string> {
 188        return await nostr.messenger.request('mls.deliverEvent' as any, { subId, eventJSON });
 189      },
 190      async backupGroups(): Promise<string> {
 191        return await nostr.messenger.request('mls.backupGroups' as any, {});
 192      },
 193      async restoreGroups(): Promise<string> {
 194        return await nostr.messenger.request('mls.restoreGroups' as any, {});
 195      },
 196      async ratchetGroup(peerHex: string): Promise<string> {
 197        return await nostr.messenger.request('mls.ratchetGroup' as any, { peerHex });
 198      },
 199    },
 200  };
 201  
 202  window.nostr = nostr as any;
 203  
 204  // WebLN types (inline to avoid build issues with @common types in injected script)
 205  interface RequestInvoiceArgs {
 206    amount?: string | number;
 207    defaultAmount?: string | number;
 208    minimumAmount?: string | number;
 209    maximumAmount?: string | number;
 210    defaultMemo?: string;
 211  }
 212  
 213  interface KeysendArgs {
 214    destination: string;
 215    amount: string | number;
 216    customRecords?: Record<string, string>;
 217  }
 218  
 219  // Create a shared messenger instance for WebLN
 220  const weblnMessenger = nostr.messenger;
 221  
 222  const webln = {
 223    enabled: false,
 224  
 225    async enable(): Promise<void> {
 226      debug('webln.enable received');
 227      await weblnMessenger.request('webln.enable', {});
 228      this.enabled = true;
 229      debug('webln.enable completed');
 230      // Dispatch webln:enabled event as per WebLN spec
 231      window.dispatchEvent(new Event('webln:enabled'));
 232    },
 233  
 234    async getInfo(): Promise<{ node: { alias?: string; pubkey?: string; color?: string } }> {
 235      debug('webln.getInfo received');
 236      const info = await weblnMessenger.request('webln.getInfo', {});
 237      debug('webln.getInfo response:');
 238      debug(info);
 239      return info;
 240    },
 241  
 242    async sendPayment(paymentRequest: string): Promise<{ preimage: string }> {
 243      debug('webln.sendPayment received');
 244      const result = await weblnMessenger.request('webln.sendPayment', { paymentRequest });
 245      debug('webln.sendPayment response:');
 246      debug(result);
 247      return result;
 248    },
 249  
 250    async keysend(args: KeysendArgs): Promise<{ preimage: string }> {
 251      debug('webln.keysend received');
 252      const result = await weblnMessenger.request('webln.keysend', args);
 253      debug('webln.keysend response:');
 254      debug(result);
 255      return result;
 256    },
 257  
 258    async makeInvoice(
 259      args: string | number | RequestInvoiceArgs
 260    ): Promise<{ paymentRequest: string }> {
 261      debug('webln.makeInvoice received');
 262      // Normalize args to RequestInvoiceArgs
 263      let normalizedArgs: RequestInvoiceArgs;
 264      if (typeof args === 'string' || typeof args === 'number') {
 265        normalizedArgs = { amount: args };
 266      } else {
 267        normalizedArgs = args;
 268      }
 269      const result = await weblnMessenger.request('webln.makeInvoice', normalizedArgs);
 270      debug('webln.makeInvoice response:');
 271      debug(result);
 272      return result;
 273    },
 274  
 275    signMessage(): Promise<{ message: string; signature: string }> {
 276      throw new Error('signMessage is not supported - NWC does not provide node signing capabilities');
 277    },
 278  
 279    verifyMessage(): Promise<void> {
 280      throw new Error('verifyMessage is not supported - NWC does not provide message verification');
 281    },
 282  };
 283  
 284  window.webln = webln as any;
 285  
 286  // Dispatch webln:ready event to signal that webln is available
 287  // This is dispatched on document as per the WebLN standard
 288  document.dispatchEvent(new Event('webln:ready'));
 289  
 290  // Listen for MLS push messages from the extension background.
 291  // These arrive via content script relay and are dispatched as custom events
 292  // so the page app can handle them (publish events, display DMs, etc.).
 293  window.addEventListener('message', (event: MessageEvent) => {
 294    if (event.data?.ext !== 'smesh-signer' || event.data?.type !== 'mls-push') return;
 295    window.dispatchEvent(new CustomEvent('nostr-mls', { detail: event.data.data }));
 296  });
 297  
 298  const debug = function (value: any) {
 299    console.log('[signer]', typeof value === 'string' ? value : JSON.stringify(value));
 300  };
 301