dm.js raw

   1  // dm.js — DM processing, sending, subscriptions
   2  
   3  import { mux } from './chan.js'
   4  import {
   5    nip04Encrypt, nip04Decrypt,
   6    nip44ConversationKey, nip44Encrypt, nip44Decrypt,
   7    signEvent, giftWrap, randomizeTimestamp, bytesToHex, schnorr,
   8  } from './crypto.js'
   9  import { saveDM, dmDedupId, saveEvent } from './db.js'
  10  import { sendToRelay, pool, onReconnect } from './pool.js'
  11  
  12  // ─── module state (set by sw.js) ────────────────────────────────────
  13  
  14  let _secretKeyHex = null
  15  let _secretKey = null
  16  let _myPubkey = null
  17  
  18  export function setDMState({ secretKeyHex, secretKey, myPubkey }) {
  19    if (secretKeyHex !== undefined) _secretKeyHex = secretKeyHex
  20    if (secretKey !== undefined) _secretKey = secretKey
  21    if (myPubkey !== undefined) _myPubkey = myPubkey
  22  }
  23  
  24  // ─── extension mode crypto bridge via mux ───────────────────────────
  25  
  26  const cryptoMux = mux()
  27  let cryptoRequestId = 0
  28  
  29  export function handleCryptoResult(requestId, result, error) {
  30    cryptoMux.send(requestId, { result, error })
  31  }
  32  
  33  async function broadcastToClients(msg) {
  34    const clients = await self.clients.matchAll()
  35    for (const client of clients) client.postMessage(msg)
  36  }
  37  
  38  async function requestCrypto(type, pubkey, text) {
  39    const id = ++cryptoRequestId
  40    broadcastToClients([type, id, pubkey, text])
  41    const resp = await cryptoMux.recv(id)
  42    if (resp.error) throw new Error(resp.error)
  43    return resp.result
  44  }
  45  
  46  // ─── encrypt/decrypt wrappers ───────────────────────────────────────
  47  
  48  async function decryptNip04(pubkey, ciphertext) {
  49    if (_secretKeyHex) return nip04Decrypt(_secretKeyHex, pubkey, ciphertext)
  50    return requestCrypto('DECRYPT_NIP04', pubkey, ciphertext)
  51  }
  52  
  53  async function encryptNip04(pubkey, plaintext) {
  54    if (_secretKeyHex) return nip04Encrypt(_secretKeyHex, pubkey, plaintext)
  55    return requestCrypto('ENCRYPT_NIP04', pubkey, plaintext)
  56  }
  57  
  58  async function decryptNip44(pubkey, ciphertext) {
  59    if (_secretKeyHex) {
  60      const ck = nip44ConversationKey(_secretKeyHex, pubkey)
  61      return nip44Decrypt(ciphertext, ck)
  62    }
  63    return requestCrypto('DECRYPT_NIP44', pubkey, ciphertext)
  64  }
  65  
  66  async function encryptNip44(pubkey, plaintext) {
  67    if (_secretKeyHex) {
  68      const ck = nip44ConversationKey(_secretKeyHex, pubkey)
  69      return nip44Encrypt(plaintext, ck)
  70    }
  71    return requestCrypto('ENCRYPT_NIP44', pubkey, plaintext)
  72  }
  73  
  74  // ─── incoming DM processing ─────────────────────────────────────────
  75  
  76  export async function processIncomingDM(event) {
  77    if (!_myPubkey) return
  78    if (event.kind === 4) return processNip04DM(event)
  79    if (event.kind === 1059) return processNip17DM(event)
  80  }
  81  
  82  async function processNip04DM(event) {
  83    const pTag = event.tags?.find((t) => t[0] === 'p')
  84    if (!pTag) return
  85    const recipient = pTag[1]
  86    const isMine = event.pubkey === _myPubkey
  87    const isForMe = recipient === _myPubkey
  88    if (!isMine && !isForMe) return
  89  
  90    const peer = isMine ? recipient : event.pubkey
  91  
  92    try {
  93      const plaintext = await decryptNip04(peer, event.content)
  94      const dm = {
  95        id: dmDedupId(peer, plaintext, event.created_at),
  96        peer,
  97        from: event.pubkey,
  98        content: plaintext,
  99        created_at: event.created_at,
 100        protocol: 'nip04',
 101        eventId: event.id,
 102      }
 103      const result = await saveDM(dm)
 104      if (result !== 'duplicate') {
 105        broadcastToClients(['DM_RECEIVED', dm])
 106      }
 107    } catch (err) {
 108      console.warn('nip04 decrypt fail:', err.message)
 109    }
 110  }
 111  
 112  async function processNip17DM(event) {
 113    try {
 114      const sealJson = await decryptNip44(event.pubkey, event.content)
 115      const seal = JSON.parse(sealJson)
 116      if (seal.kind !== 13) return
 117  
 118      const innerJson = await decryptNip44(seal.pubkey, seal.content)
 119      const inner = JSON.parse(innerJson)
 120      if (inner.kind !== 14) return
 121  
 122      const senderPub = seal.pubkey
 123      const pTag = inner.tags?.find((t) => t[0] === 'p')
 124      const recipient = pTag?.[1]
 125      const isMine = senderPub === _myPubkey
 126      const peer = isMine ? (recipient || '') : senderPub
 127  
 128      if (!peer) return
 129  
 130      const dm = {
 131        id: dmDedupId(peer, inner.content, inner.created_at || event.created_at),
 132        peer,
 133        from: senderPub,
 134        content: inner.content,
 135        created_at: inner.created_at || event.created_at,
 136        protocol: 'nip17',
 137        eventId: event.id,
 138      }
 139      const result = await saveDM(dm)
 140      if (result !== 'duplicate') {
 141        broadcastToClients(['DM_RECEIVED', dm])
 142      }
 143    } catch (err) {
 144      // FIX #8: log instead of swallowing
 145      console.warn('nip17 unwrap fail:', err.message)
 146    }
 147  }
 148  
 149  // ─── DM sending ─────────────────────────────────────────────────────
 150  
 151  export async function sendDM(clientId, recipientPubkey, content, relayUrls) {
 152    if (!_myPubkey) return
 153    const errors = []
 154  
 155    if (_secretKeyHex) {
 156      try { await sendNip04DM(recipientPubkey, content, relayUrls) }
 157      catch (err) { errors.push('nip04: ' + err.message) }
 158      try { await sendNip17DM(recipientPubkey, content, relayUrls) }
 159      catch (err) { errors.push('nip17: ' + err.message) }
 160    } else {
 161      const client = await self.clients.get(clientId)
 162      if (client) {
 163        client.postMessage(['DM_SEND_VIA_EXT', recipientPubkey, content, relayUrls])
 164        return
 165      }
 166    }
 167  
 168    const now = Math.floor(Date.now() / 1000)
 169    const dm = {
 170      id: dmDedupId(recipientPubkey, content, now),
 171      peer: recipientPubkey,
 172      from: _myPubkey,
 173      content,
 174      created_at: now,
 175      protocol: _secretKeyHex ? 'nip17' : 'nip04',
 176      eventId: '',
 177    }
 178    await saveDM(dm)
 179  
 180    const client = await self.clients.get(clientId)
 181    if (client) {
 182      if (errors.length) {
 183        client.postMessage(['DM_SENT', recipientPubkey, false, errors.join('; ')])
 184      } else {
 185        client.postMessage(['DM_SENT', recipientPubkey, true, ''])
 186      }
 187    }
 188    broadcastToClients(['DM_RECEIVED', dm])
 189  }
 190  
 191  async function sendNip04DM(recipientPubkey, content, relayUrls) {
 192    const ciphertext = await encryptNip04(recipientPubkey, content)
 193    const ev = signEvent({
 194      kind: 4,
 195      content: ciphertext,
 196      tags: [['p', recipientPubkey]],
 197      created_at: Math.floor(Date.now() / 1000),
 198      pubkey: _myPubkey,
 199    }, _secretKey)
 200    await saveEvent(ev)
 201    for (const url of relayUrls) sendToRelay(url, ['EVENT', ev])
 202  }
 203  
 204  async function sendNip17DM(recipientPubkey, content, relayUrls) {
 205    const now = Math.floor(Date.now() / 1000)
 206  
 207    const inner = signEvent({
 208      kind: 14,
 209      content,
 210      tags: [['p', recipientPubkey]],
 211      created_at: now,
 212      pubkey: _myPubkey,
 213    }, _secretKey)
 214    const innerJson = JSON.stringify(inner)
 215  
 216    // recipient copy
 217    const recipientSealContent = await encryptNip44(recipientPubkey, innerJson)
 218    const recipientSeal = signEvent({
 219      kind: 13,
 220      content: recipientSealContent,
 221      tags: [],
 222      created_at: randomizeTimestamp(now),
 223      pubkey: _myPubkey,
 224    }, _secretKey)
 225    const recipientWrap = await giftWrap(recipientSeal, recipientPubkey, now)
 226    for (const url of relayUrls) sendToRelay(url, ['EVENT', recipientWrap])
 227  
 228    // sender (self) copy
 229    const senderSealContent = await encryptNip44(_myPubkey, innerJson)
 230    const senderSeal = signEvent({
 231      kind: 13,
 232      content: senderSealContent,
 233      tags: [],
 234      created_at: randomizeTimestamp(now),
 235      pubkey: _myPubkey,
 236    }, _secretKey)
 237    const senderWrap = await giftWrap(senderSeal, _myPubkey, now)
 238    for (const url of relayUrls) sendToRelay(url, ['EVENT', senderWrap])
 239  }
 240  
 241  // ─── DM subscriptions ───────────────────────────────────────────────
 242  
 243  const dmSubIds = new Set()
 244  let _dmRelayUrls = []
 245  
 246  export async function handleDMSub(clientId, relayUrls) {
 247    if (!_myPubkey || !relayUrls?.length) return
 248    _dmRelayUrls = relayUrls
 249  
 250    // close existing
 251    for (const rSubId of dmSubIds) {
 252      for (const [url, conn] of pool) {
 253        if (conn.ws?.readyState === WebSocket.OPEN) {
 254          conn.ws.send(JSON.stringify(['CLOSE', rSubId]))
 255        }
 256      }
 257    }
 258    dmSubIds.clear()
 259  
 260    openDMSubs(relayUrls)
 261  }
 262  
 263  function openDMSubs(relayUrls) {
 264    for (const url of relayUrls) {
 265      const suffix = url.replace(/\W/g, '').slice(-8)
 266      const id1 = 'dm4in_' + suffix
 267      const id2 = 'dm4out_' + suffix
 268      const id3 = 'dm17_' + suffix
 269      dmSubIds.add(id1)
 270      dmSubIds.add(id2)
 271      dmSubIds.add(id3)
 272  
 273      sendToRelay(url, ['REQ', id1, { kinds: [4], '#p': [_myPubkey], limit: 100 }])
 274      sendToRelay(url, ['REQ', id2, { kinds: [4], authors: [_myPubkey], limit: 100 }])
 275      sendToRelay(url, ['REQ', id3, { kinds: [1059], '#p': [_myPubkey], limit: 100 }])
 276    }
 277  }
 278  
 279  // FIX #2: re-send DM subs when a relay reconnects
 280  onReconnect((url) => {
 281    if (!_myPubkey || !_dmRelayUrls.includes(url)) return
 282    // small delay to let the connection stabilize
 283    setTimeout(() => openDMSubs([url]), 500)
 284  })
 285  
 286  // ─── extension mode DM result ───────────────────────────────────────
 287  
 288  export async function handleExtDMResult(clientId, peer, content, success, errMsg) {
 289    if (success && _myPubkey) {
 290      const now = Math.floor(Date.now() / 1000)
 291      const dm = {
 292        id: dmDedupId(peer, content, now),
 293        peer,
 294        from: _myPubkey,
 295        content,
 296        created_at: now,
 297        protocol: 'nip04',
 298        eventId: '',
 299      }
 300      await saveDM(dm)
 301      broadcastToClients(['DM_RECEIVED', dm])
 302    }
 303    const client = await self.clients.get(clientId)
 304    if (client) client.postMessage(['DM_SENT', peer, success, errMsg || ''])
 305  }
 306