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