main.mx raw
1 package main
2
3 // MLS Worker - marmot (MLS-over-Nostr) messaging.
4 //
5 // Owns marmot state: KeyPairPackage, group states, peer key package cache.
6 // Communicates through supervisor (wasm-host.mjs) via typed JSON messages.
7 //
8 // supervisor -> worker:
9 // ["M_SET_PUBKEY", pk]
10 // ["M_INIT", urlsJSON] -- generate/load KPP, load groups, subscribe
11 // ["M_SEND_DM", peer, content, urlsJSON] -- send MLS DM
12 // ["M_INCOMING", evJSON] -- raw kind 443/445/1059 from fetch worker
13 // ["M_CRYPTO_RESULT", reqID, result, err] -- signer response
14 // ["M_STORE_RESULT", tag, reqID, data] -- store response
15 // ["M_FETCH_KP_RESULT", peer, evJSON] -- key package fetch result
16 //
17 // worker -> supervisor:
18 // ["M_CRYPTO_REQ", reqID, method, peer, data] -- to signer
19 // ["M_STORE_REQ", tag, reqID, ...] -- to store worker
20 // ["M_PUBLISH", evJSON, urlsJSON] -- publish via relay-proxy
21 // ["M_FETCH_KP", peer, urlsJSON] -- request key package from fetch worker
22 // ["M_SUBSCRIBE", urlsJSON, groupIDsJSON] -- trigger fetch worker subscription
23 // ["M_UPDATE_GROUPS", groupIDsJSON] -- update fetch worker kind 445 filter
24 // ["MLS_DM_RECEIVED", dmJSON] -- to app worker
25 // ["MLS_DM_SENT", peer, ok, err] -- to app worker
26 // ["MLS_UNREAD_COUNT", count] -- to app worker
27 // ["MLS_DM_LIST", listJSON] -- to app worker
28 // ["MLS_DM_HISTORY", peer, msgsJSON] -- to app worker
29
30 import (
31 "runtime"
32 "git.smesh.lol/musiquay/web/common/helpers"
33 "git.smesh.lol/musiquay/web/common/jsbridge/mlsw"
34 "git.smesh.lol/musiquay/web/common/marmot"
35 "git.smesh.lol/musiquay/web/common/mls"
36 "git.smesh.lol/musiquay/web/common/mw"
37 "git.smesh.lol/nostr/pkg/core"
38 )
39
40
41 type pendingSend struct {
42 content string
43 urls []string
44 }
45
46 // mlsState is the MLS
47 // Package globals are immutable outside initialization, so it lives in
48 // one self-mutating type reached through a package-level pointer.
49 type mlsState struct {
50 myPK string
51 myKPP *mls.KeyPairPackage
52 peerKPs map[string]*mls.KeyPackage
53 mlsGroups map[string]*marmot.GroupState // nostrGroupIDHex -> GroupState
54 initStarted bool
55 initDone bool
56 relayURLs []string
57 unreadCount int32
58
59 pendingIncoming []string // events queued before initDone
60
61 cryptoCBs map[int32]func(string, string)
62 nextCryptoID int32
63 storeCBs map[int32]func(string, string) // reqID -> fn(data, err)
64 nextStoreID int32
65
66 // peer -> queued (content, urls) waiting for KP fetch
67 pendingKPFetch map[string][]pendingSend
68 kpFetchTimers map[string]int32
69
70 // groupIDHex -> queued event JSONs for messages that arrived before Welcome
71 pendingGroupMsgs map[string][]string
72
73 // peer -> recent DM records (JSON strings), ring buffer per peer
74 dmHistory map[string][]string
75 }
76
77 var mlsSt *mlsState
78
79 func initState() {
80 if mlsSt != nil {
81 return
82 }
83 // mlsSt and everything it holds live as long as this worker: build them
84 // in the root arena, not in this call's frame arena which dies on
85 // return.
86 runtime.SovereignSetArena(runtime.RootArena())
87 mlsSt = &mlsState{}
88 mlsSt.peerKPs = map[string]*mls.KeyPackage{}
89 mlsSt.mlsGroups = map[string]*marmot.GroupState{}
90 mlsSt.cryptoCBs = map[int32]func(string, string){}
91 mlsSt.storeCBs = map[int32]func(string, string){}
92 mlsSt.pendingKPFetch = map[string][]pendingSend{}
93 mlsSt.kpFetchTimers = map[string]int32{}
94 mlsSt.pendingGroupMsgs = map[string][]string{}
95 mlsSt.dmHistory = map[string][]string{}
96 runtime.SovereignRestoreArena(runtime.RootArena())
97 }
98
99 func main() {
100 initState()
101 mlsw.WorkerOnMessage(handleMessage)
102 }
103
104 func handleMessage(msg string) {
105 w := mw.New(msg)
106 cmd := w.Str()
107 println("[mls-worker] handleMessage cmd=" | cmd)
108 switch cmd {
109 case "M_SET_PUBKEY":
110 mlsSt.myPK = w.Str()
111 println("[mls-worker] SET_PUBKEY pk=" | mlsSt.myPK[:16] | "...")
112 case "M_INIT", "MLS_INIT":
113 urlsJSON := w.Raw()
114 mlsSt.relayURLs = parseStrArr(urlsJSON)
115 println("[mls-worker] INIT urls=" | urlsJSON)
116 handleInit(mlsSt.relayURLs)
117 case "M_SEND_DM", "MLS_SEND_DM":
118 peer := w.Str()
119 content := w.Str()
120 urlsJSON := w.Raw()
121 urls := parseStrArr(urlsJSON)
122 if len(urls) == 0 {
123 urls = mlsSt.relayURLs
124 }
125 println("[mls-worker] SEND_DM peer=" | peer[:16] | "... content_len=" | itoi(len(content)) | " urls=" | itoi(len(urls)))
126 handleSendDM(peer, content, urls)
127 case "M_INCOMING":
128 evJSON := w.Raw()
129 if !mlsSt.initDone {
130 println("[mls-worker] INCOMING queued (mlsSt.initDone=false), pending=" | itoi(len(mlsSt.pendingIncoming)+1))
131 mlsSt.pendingIncoming = push(mlsSt.pendingIncoming, evJSON)
132 return
133 }
134 println("[mls-worker] INCOMING processing")
135 processIncoming(evJSON)
136 case "M_CRYPTO_RESULT":
137 reqID := int32(w.Num())
138 result := mw.Unescape(w.Str())
139 errMsg := mw.Unescape(w.Str())
140 println("[mls-worker] CRYPTO_RESULT reqID=" | itoi(reqID) | " err=" | errMsg)
141 if fn, ok2 := mlsSt.cryptoCBs[reqID]; ok2 {
142 delete(mlsSt.cryptoCBs, reqID)
143 fn(result, errMsg)
144 }
145 case "M_STORE_RESULT":
146 _ = w.Str() // tag (unused - keyed by reqID)
147 reqID := int32(w.Num())
148 data := w.Str()
149 println("[mls-worker] STORE_RESULT reqID=" | itoi(reqID) | " data_len=" | itoi(len(data)))
150 if fn, ok2 := mlsSt.storeCBs[reqID]; ok2 {
151 delete(mlsSt.storeCBs, reqID)
152 fn(data, "")
153 } else {
154 println("[mls-worker] STORE_RESULT: no callback for reqID=" | itoi(reqID))
155 }
156 case "M_FETCH_KP_RESULT":
157 peer := w.Str()
158 evJSON := w.Raw()
159 println("[mls-worker] FETCH_KP_RESULT peer=" | peer[:16] | "... evJSON_len=" | itoi(len(evJSON)))
160 handleFetchKPResult(peer, evJSON)
161 case "MLS_DM_LIST":
162 println("[mls-worker] DM_LIST requested")
163 handleDMList()
164 case "MLS_DM_HISTORY":
165 peer := w.Str()
166 println("[mls-worker] DM_HISTORY peer=" | peer[:16] | "...")
167 handleDMHistory(peer)
168 default:
169 println("[mls-worker] UNHANDLED cmd=" | cmd)
170 }
171 }
172
173 // --- Init ---
174
175 func handleInit(urls []string) {
176 if mlsSt.initStarted {
177 println("[mls-worker] handleInit: already started, skipping")
178 return
179 }
180 if mlsSt.myPK == "" {
181 println("[mls-worker] handleInit: no pubkey, aborting")
182 return
183 }
184 mlsSt.initStarted = true
185 println("[mls-worker] handleInit: loading KPP from store")
186 storeReq("S_MLS_LOAD_KPP", "", func(kppBase64, _ string) {
187 if kppBase64 != "" {
188 println("[mls-worker] handleInit: got stored KPP, len=" | itoi(len(kppBase64)))
189 raw := helpers.Base64Decode(kppBase64)
190 if raw != nil {
191 if kpp, err := mls.UnmarshalKeyPairPackage(raw); err == nil {
192 mlsSt.myKPP = kpp
193 println("[mls-worker] handleInit: KPP loaded from store")
194 } else {
195 println("[mls-worker] handleInit: KPP unmarshal err=" | err.Error())
196 }
197 }
198 }
199 if mlsSt.myKPP == nil {
200 println("[mls-worker] handleInit: generating new KPP")
201 pubBytes := helpers.HexDecode(mlsSt.myPK)
202 if pubBytes == nil {
203 println("[mls-worker] handleInit: invalid pubkey hex")
204 return
205 }
206 now := mlsw.WorkerNowSeconds()
207 kpp, err := marmot.GenerateKeyPackage(pubBytes, now)
208 if err != nil {
209 println("[mls-worker] handleInit: generate KPP err=" | err.Error())
210 return
211 }
212 mlsSt.myKPP = kpp
213 println("[mls-worker] handleInit: KPP generated, saving")
214 raw, err := kpp.Marshal()
215 if err == nil {
216 storeFireForget("S_MLS_SAVE_KPP", jstr(helpers.Base64Encode(raw)))
217 }
218 }
219 println("[mls-worker] handleInit: publishing key package")
220 publishKeyPackage(urls, func() {
221 println("[mls-worker] handleInit: KP published, loading groups")
222 loadGroups(func() {
223 mlsSt.initDone = true
224 println("[mls-worker] handleInit: INIT DONE, groups=" | itoi(len(mlsSt.mlsGroups)) | " pending=" | itoi(len(mlsSt.pendingIncoming)))
225 pending := mlsSt.pendingIncoming
226 mlsSt.pendingIncoming = nil
227 for _, ev := range pending {
228 processIncoming(ev)
229 }
230 groupIDsJSON := buildGroupIDsJSON()
231 urlsJSON := buildURLsJSON(urls)
232 println("[mls-worker] handleInit: subscribing groups=" | groupIDsJSON)
233 mlsw.WorkerPost(`["M_SUBSCRIBE",` | urlsJSON | `,` | groupIDsJSON | `]`)
234 mlsw.WorkerPost(`["MLS_UNREAD_COUNT",0]`)
235 })
236 })
237 })
238 }
239
240 func publishKeyPackage(urls []string, done func()) {
241 now := mlsw.WorkerNowSeconds()
242 ev, err := marmot.BuildKeyPackageEvent(mlsSt.myKPP, mlsSt.myPK, now, urls)
243 if err != nil {
244 println("[mls-worker] publishKP: build err=" | err.Error())
245 done()
246 return
247 }
248 evJSON := ev.ToJSON()
249 println("[mls-worker] publishKP: unsigned kind=" | itoi(int32(ev.Kind)) | " evJSON(80)=" | evJSON[:80])
250 requestCrypto("signEvent", "", evJSON, func(signed, errMsg string) {
251 if errMsg != "" {
252 println("[mls-worker] publishKP: sign err=" | errMsg)
253 } else if signed != "" {
254 println("[mls-worker] publishKP: signed(80)=" | signed[:80])
255 mlsw.WorkerPost(`["M_PUBLISH",` | signed | `,` | buildURLsJSON(urls) | `]`)
256 } else {
257 println("[mls-worker] publishKP: sign returned empty")
258 }
259 done()
260 })
261 }
262
263 func loadGroups(done func()) {
264 storeReq("S_MLS_LIST_GROUPS", "", func(groupsJSON, _ string) {
265 ids := parseStrArr(groupsJSON)
266 if len(ids) == 0 {
267 done()
268 return
269 }
270 remaining := len(ids)
271 for _, id := range ids {
272 gid := id
273 storeReq("S_MLS_LOAD_GROUP", jstr(gid), func(stateBase64, _ string) {
274 if stateBase64 != "" {
275 raw := helpers.Base64Decode(stateBase64)
276 if raw != nil {
277 if gs, err := marmot.UnmarshalGroupState(raw); err == nil {
278 mlsSt.mlsGroups[gid] = gs
279 }
280 }
281 }
282 remaining--
283 if remaining == 0 {
284 done()
285 }
286 })
287 }
288 })
289 }
290
291 // --- Incoming events ---
292
293 func processIncoming(evJSON string) {
294 ev := nostr.ParseEvent(evJSON)
295 if ev == nil {
296 println("[mls-worker] processIncoming: parse failed")
297 return
298 }
299 println("[mls-worker] processIncoming: kind=" | itoi(int32(ev.Kind)) | " from=" | ev.PubKey[:16] | "... id=" | ev.ID[:16] | "...")
300 switch ev.Kind {
301 case marmot.KindKeyPackage:
302 if kp, err := marmot.EventToKeyPackage(ev); err == nil {
303 cs := kp.CipherSuiteValue()
304 if cs != 3 {
305 println("[mls-worker] processIncoming: skip KP cs=" | itoi(int32(cs)) | " from " | ev.PubKey[:16] | "...")
306 break
307 }
308 if len(mlsSt.peerKPs) > 200 {
309 for k := range mlsSt.peerKPs {
310 delete(mlsSt.peerKPs, k)
311 break
312 }
313 }
314 mlsSt.peerKPs[ev.PubKey] = kp
315 println("[mls-worker] processIncoming: cached KP for " | ev.PubKey[:16] | "...")
316 } else {
317 println("[mls-worker] processIncoming: KP parse err=" | err.Error())
318 }
319 case marmot.KindGiftWrap:
320 println("[mls-worker] processIncoming: gift wrap, decrypting")
321 requestCrypto("nip44.decrypt", mlsSt.myPK, ev.Content, func(plaintext, errMsg string) {
322 if errMsg != "" {
323 println("[mls-worker] processIncoming: gift wrap decrypt err=" | errMsg)
324 return
325 }
326 if plaintext == "" {
327 println("[mls-worker] processIncoming: gift wrap decrypt empty")
328 return
329 }
330 inner := nostr.ParseEvent(plaintext)
331 if inner == nil {
332 println("[mls-worker] processIncoming: inner event parse failed")
333 return
334 }
335 println("[mls-worker] processIncoming: inner kind=" | itoi(int32(inner.Kind)))
336 switch inner.Kind {
337 case marmot.KindWelcome:
338 processWelcome(inner, ev.PubKey)
339 case marmot.KindGroupMessage:
340 processGroupMessage(inner)
341 default:
342 println("[mls-worker] processIncoming: unexpected inner kind=" | itoi(int32(inner.Kind)))
343 }
344 })
345 case marmot.KindGroupMessage:
346 processGroupMessage(ev)
347 default:
348 println("[mls-worker] processIncoming: unhandled kind=" | itoi(int32(ev.Kind)))
349 }
350 }
351
352 func processWelcome(rumorEv *nostr.Event, senderPubHex string) {
353 println("[mls-worker] processWelcome: from=" | senderPubHex[:16] | "...")
354 if mlsSt.myKPP == nil {
355 println("[mls-worker] processWelcome: no KPP, aborting")
356 return
357 }
358 welcome, err := marmot.RumorToWelcome(rumorEv)
359 if err != nil {
360 println("[mls-worker] processWelcome: parse err=" | err.Error())
361 return
362 }
363 peerPub := helpers.HexDecode(senderPubHex)
364 if peerPub == nil {
365 println("[mls-worker] processWelcome: bad sender hex")
366 return
367 }
368 now := mlsw.WorkerNowSeconds()
369 gs, err := marmot.JoinDMGroup(welcome, mlsSt.myKPP, peerPub, now)
370 if err != nil {
371 println("[mls-worker] processWelcome: join err=" | err.Error())
372 return
373 }
374 groupIDHex := helpers.HexEncode(gs.NostrGroupID)
375 println("[mls-worker] processWelcome: joined group=" | groupIDHex[:16] | "...")
376 mlsSt.mlsGroups[groupIDHex] = gs
377 saveGroup(groupIDHex, gs)
378 mlsw.WorkerPost(`["M_UPDATE_GROUPS",` | buildGroupIDsJSON() | `]`)
379
380 if queued, ok2 := mlsSt.pendingGroupMsgs[groupIDHex]; ok2 {
381 println("[mls-worker] processWelcome: draining " | itoi(len(queued)) | " pending messages")
382 delete(mlsSt.pendingGroupMsgs, groupIDHex)
383 for _, evJSON := range queued {
384 processIncoming(evJSON)
385 }
386 }
387 }
388
389 func processGroupMessage(ev *nostr.Event) {
390 hTag := nostr.TagsGetFirst(ev.Tags, "h")
391 if hTag == nil {
392 println("[mls-worker] processGroupMessage: no 'h' tag")
393 return
394 }
395 groupIDHex := string(nostr.TagValue(hTag))
396 println("[mls-worker] processGroupMessage: group=" | groupIDHex[:16] | "... from=" | ev.PubKey[:16] | "...")
397 gs, ok := mlsSt.mlsGroups[groupIDHex]
398 if !ok {
399 q := mlsSt.pendingGroupMsgs[groupIDHex]
400 if len(q) >= 64 {
401 println("[mls-worker] processGroupMessage: unknown group, pending queue full, dropping")
402 return
403 }
404 mlsSt.pendingGroupMsgs[groupIDHex] = push(q, ev.ToJSON())
405 println("[mls-worker] processGroupMessage: unknown group, queued (pending=" | itoi(len(mlsSt.pendingGroupMsgs[groupIDHex])) | ")")
406 return
407 }
408 secret, err := marmot.DeriveExporterSecret(gs.Group)
409 if err != nil {
410 println("[mls-worker] processGroupMessage: derive secret err=" | err.Error())
411 return
412 }
413 _, mlsCipher, err := marmot.EventToMessage(ev, secret)
414 if err != nil {
415 println("[mls-worker] processGroupMessage: EventToMessage err=" | err.Error())
416 return
417 }
418 plaintext, _, err := gs.Group.UnmarshalAndProcessMessage(mlsCipher)
419 if err != nil {
420 println("[mls-worker] processGroupMessage: process err=" | err.Error())
421 return
422 }
423 saveGroup(groupIDHex, gs)
424
425 peer := helpers.HexEncode(gs.PeerPub)
426 println("[mls-worker] processGroupMessage: decrypted msg from " | peer[:16] | "... len=" | itoi(len(plaintext)))
427 dmJSON := buildDMRecord(peer, ev.PubKey, plaintext, ev.CreatedAt, "mls", ev.ID)
428 storeDM(peer, dmJSON)
429 mlsw.WorkerPost(`["MLS_DM_RECEIVED",` | dmJSON | `]`)
430 mlsSt.unreadCount++
431 mlsw.WorkerPost(`["MLS_UNREAD_COUNT",` | itoi(mlsSt.unreadCount) | `]`)
432 }
433
434 // --- Send DM ---
435
436 func handleSendDM(peer, content string, urls []string) {
437 groupIDHex := dmGroupIDHex(peer)
438 println("[mls-worker] handleSendDM: peer=" | peer[:16] | "... groupID=" | groupIDHex[:16] | "...")
439 if gs, ok2 := mlsSt.mlsGroups[groupIDHex]; ok2 {
440 println("[mls-worker] handleSendDM: existing group found, sending")
441 sendToExistingGroup(peer, content, urls, gs, groupIDHex)
442 return
443 }
444 if kp, ok2 := mlsSt.peerKPs[peer]; ok2 {
445 println("[mls-worker] handleSendDM: have peer KP, creating group")
446 createGroupAndSend(peer, content, urls, kp)
447 return
448 }
449 println("[mls-worker] handleSendDM: no KP, fetching from relay")
450 mlsSt.pendingKPFetch[peer] = push(mlsSt.pendingKPFetch[peer], pendingSend{content: content, urls: urls})
451 if _, fetching := mlsSt.kpFetchTimers[peer]; !fetching {
452 startKPFetch(peer, urls)
453 }
454 }
455
456 func startKPFetch(peer string, urls []string) {
457 mlsw.WorkerPost(`["M_FETCH_KP",` | jstr(peer) | `,` | buildURLsJSON(urls) | `]`)
458 p := peer
459 handle := mlsw.WorkerSetTimeout(15000, func() {
460 delete(mlsSt.kpFetchTimers, p)
461 if pending, ok2 := mlsSt.pendingKPFetch[p]; ok2 {
462 delete(mlsSt.pendingKPFetch, p)
463 for range pending {
464 mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(p) | `,0,"peer does not support MLS messaging"]`)
465 }
466 }
467 })
468 mlsSt.kpFetchTimers[peer] = handle
469 }
470
471 func handleFetchKPResult(peer, evJSON string) {
472 if h, ok2 := mlsSt.kpFetchTimers[peer]; ok2 {
473 mlsw.WorkerClearTimeout(h)
474 delete(mlsSt.kpFetchTimers, peer)
475 }
476 if evJSON != "" && evJSON != "null" {
477 ev := nostr.ParseEvent(evJSON)
478 if ev != nil {
479 if fkp, err := marmot.EventToKeyPackage(ev); err == nil {
480 cs := fkp.CipherSuiteValue()
481 if cs != 3 {
482 println("[mls-worker] handleFetchKPResult: skip KP cs=" | itoi(int32(cs)) | " for " | peer[:16] | "...")
483 } else {
484 mlsSt.peerKPs[peer] = fkp
485 println("[mls-worker] handleFetchKPResult: got KP for " | peer[:16] | "...")
486 }
487 } else {
488 println("[mls-worker] handleFetchKPResult: parse KP err=" | err.Error())
489 }
490 } else {
491 println("[mls-worker] handleFetchKPResult: parse event failed")
492 }
493 } else {
494 println("[mls-worker] handleFetchKPResult: no event returned for " | peer[:16] | "...")
495 }
496 pending, ok := mlsSt.pendingKPFetch[peer]
497 if !ok {
498 println("[mls-worker] handleFetchKPResult: no pending sends")
499 return
500 }
501 delete(mlsSt.pendingKPFetch, peer)
502 kp, hasKP := mlsSt.peerKPs[peer]
503 println("[mls-worker] handleFetchKPResult: hasKP=" | bstr(hasKP) | " pending=" | itoi(len(pending)))
504 for _, ps := range pending {
505 if !hasKP {
506 mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,"peer does not support MLS messaging"]`)
507 continue
508 }
509 createGroupAndSend(peer, ps.content, ps.urls, kp)
510 }
511 }
512
513 func bstr(b bool) (s string) {
514 if b {
515 return "true"
516 }
517 return "false"
518 }
519
520 func createGroupAndSend(peer, content string, urls []string, peerKP *mls.KeyPackage) {
521 if mlsSt.myKPP == nil {
522 println("[mls-worker] createGroupAndSend: no KPP")
523 mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,"not initialized"]`)
524 return
525 }
526 selfPub := helpers.HexDecode(mlsSt.myPK)
527 peerPub := helpers.HexDecode(peer)
528 if selfPub == nil || peerPub == nil {
529 println("[mls-worker] createGroupAndSend: bad pubkey hex")
530 mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,"invalid pubkey"]`)
531 return
532 }
533 now := mlsw.WorkerNowSeconds()
534 peerCS := peerKP.CipherSuiteValue()
535 println("[mls-worker] createGroupAndSend: selfCS=" | itoi(int32(mlsSt.myKPP.Public.CipherSuiteValue())) | " peerCS=" | itoi(int32(peerCS)))
536 if peerCS != 3 {
537 println("[mls-worker] createGroupAndSend: unsupported peer cipher suite " | itoi(int32(peerCS)))
538 mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,"unsupported peer cipher suite"]`)
539 return
540 }
541 gs, welcome, err := marmot.CreateDMGroup(mlsSt.myKPP, peerKP, selfPub, peerPub, "", urls)
542 if err != nil {
543 println("[mls-worker] createGroupAndSend: create err=" | err.Error())
544 mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,` | jstr(err.Error()) | `]`)
545 return
546 }
547 groupIDHex := helpers.HexEncode(gs.NostrGroupID)
548 println("[mls-worker] createGroupAndSend: group=" | groupIDHex[:16] | "... sending welcome")
549 mlsSt.mlsGroups[groupIDHex] = gs
550
551 rumorEv := marmot.WelcomeToRumor(welcome, selfPub, now, "", urls)
552 rumorJSON := rumorEv.ToJSON()
553 requestCrypto("nip44.encrypt", peer, rumorJSON, func(sealed, errMsg string) {
554 if errMsg != "" {
555 println("[mls-worker] createGroupAndSend: gift-wrap encrypt err=" | errMsg)
556 mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,"gift-wrap encrypt failed"]`)
557 return
558 }
559 println("[mls-worker] createGroupAndSend: gift-wrap encrypted, signing")
560 wrapEv := &nostr.Event{
561 PubKey: mlsSt.myPK,
562 CreatedAt: mlsw.WorkerNowSeconds(),
563 Kind: marmot.KindGiftWrap,
564 Tags: [][]string{[]string{"p", peer}},
565 Content: sealed,
566 }
567 wrapJSON := wrapEv.ToJSON()
568 requestCrypto("signEvent", "", wrapJSON, func(signedWrap, errMsg string) {
569 if errMsg != "" {
570 println("[mls-worker] createGroupAndSend: sign wrap err=" | errMsg)
571 } else if signedWrap != "" {
572 println("[mls-worker] createGroupAndSend: publishing welcome (kind 1059)")
573 mlsw.WorkerPost(`["M_PUBLISH",` | signedWrap | `,` | buildURLsJSON(urls) | `]`)
574 }
575 p := peer
576 sendToExistingGroup(p, content, urls, gs, groupIDHex)
577 })
578 })
579
580 saveGroup(groupIDHex, gs)
581 mlsw.WorkerPost(`["M_UPDATE_GROUPS",` | buildGroupIDsJSON() | `]`)
582 }
583
584 func sendToExistingGroup(peer, content string, urls []string, gs *marmot.GroupState, groupIDHex string) {
585 now := mlsw.WorkerNowSeconds()
586 println("[mls-worker] sendToExistingGroup: creating app message")
587 appMsg, err := gs.Group.CreateApplicationMessage([]byte(content))
588 if err != nil {
589 println("[mls-worker] sendToExistingGroup: createAppMsg err=" | err.Error())
590 mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,` | jstr(err.Error()) | `]`)
591 return
592 }
593 secret, err := marmot.DeriveExporterSecret(gs.Group)
594 if err != nil {
595 println("[mls-worker] sendToExistingGroup: deriveSecret err=" | err.Error())
596 mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,` | jstr(err.Error()) | `]`)
597 return
598 }
599 ev445, err := marmot.MessageToEvent(gs.NostrGroupID, appMsg, secret, now)
600 if err != nil {
601 println("[mls-worker] sendToExistingGroup: MessageToEvent err=" | err.Error())
602 mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,0,` | jstr(err.Error()) | `]`)
603 return
604 }
605 println("[mls-worker] sendToExistingGroup: publishing kind 445 id=" | ev445.ID[:16] | "...")
606 mlsw.WorkerPost(`["M_PUBLISH",` | ev445.ToJSON() | `,` | buildURLsJSON(urls) | `]`)
607 saveGroup(groupIDHex, gs)
608
609 dmJSON := buildDMRecord(peer, mlsSt.myPK, content, now, "mls", ev445.ID)
610 storeDM(peer, dmJSON)
611 mlsw.WorkerPost(`["MLS_DM_SENT",` | jstr(peer) | `,1,""]`)
612 mlsw.WorkerPost(`["MLS_DM_RECEIVED",` | dmJSON | `]`)
613 }
614
615 // --- Store helpers ---
616
617 func saveGroup(groupIDHex string, gs *marmot.GroupState) {
618 raw, err := marmot.MarshalGroupState(gs)
619 if err != nil {
620 return
621 }
622 b64 := helpers.Base64Encode(raw)
623 storeFireForget("S_MLS_SAVE_GROUP", jstr(groupIDHex) | `,` | jstr(b64))
624 }
625
626 func storeReq(op, extraArgs string, fn func(string, string)) {
627 mlsSt.nextStoreID++
628 id := mlsSt.nextStoreID
629 mlsSt.storeCBs[id] = fn
630 msg := `["M_STORE_REQ",` | jstr(op) | `,` | itoi(id)
631 if extraArgs != "" {
632 msg = msg | `,` | extraArgs
633 }
634 mlsw.WorkerPost(msg | `]`)
635 }
636
637 func storeFireForget(op, args string) {
638 mlsSt.nextStoreID++
639 id := mlsSt.nextStoreID
640 msg := `["M_STORE_REQ",` | jstr(op) | `,` | itoi(id)
641 if args != "" {
642 msg = msg | `,` | args
643 }
644 mlsw.WorkerPost(msg | `]`)
645 }
646
647 func requestCrypto(method, peer, data string, fn func(string, string)) {
648 mlsSt.nextCryptoID++
649 id := mlsSt.nextCryptoID
650 mlsSt.cryptoCBs[id] = fn
651 mlsw.WorkerPost(`["M_CRYPTO_REQ",` | itoi(id) | `,` | jstr(method) | `,` | jstr(peer) | `,` | jstr(data) | `]`)
652 cbID := id
653 mlsw.WorkerSetTimeout(15000, func() {
654 if _, ok2 := mlsSt.cryptoCBs[cbID]; ok2 {
655 delete(mlsSt.cryptoCBs, cbID)
656 fn("", "timeout")
657 }
658 })
659 }
660
661 // --- DM list / history ---
662
663 func handleDMList() {
664 // Build conversation list from in-memory group states.
665 list := "["
666 first := true
667 for _, gs := range mlsSt.mlsGroups {
668 if !first {
669 list = list | ","
670 }
671 first = false
672 peer := helpers.HexEncode(gs.PeerPub)
673 list = list | `{"peer":` | jstr(peer) | `}`
674 }
675 list = list | "]"
676 mlsw.WorkerPost(`["MLS_DM_LIST",` | list | `]`)
677 }
678
679 func handleDMHistory(peer string) {
680 msgs := mlsSt.dmHistory[peer]
681 if len(msgs) == 0 {
682 mlsw.WorkerPost(`["MLS_DM_HISTORY",` | jstr(peer) | `,[]` | `]`)
683 return
684 }
685 // renderer expects newest-first (reverses to oldest-first for display)
686 arr := "["
687 for i := len(msgs) - 1; i >= 0; i-- {
688 if i < len(msgs)-1 {
689 arr = arr | ","
690 }
691 arr = arr | msgs[i]
692 }
693 arr = arr | "]"
694 mlsw.WorkerPost(`["MLS_DM_HISTORY",` | jstr(peer) | `,` | arr | `]`)
695 }
696
697 func storeDM(peer, dmJSON string) {
698 h := mlsSt.dmHistory[peer]
699 if len(h) >= 256 {
700 h = h[1:]
701 }
702 mlsSt.dmHistory[peer] = push(h, dmJSON)
703 }
704
705 // --- Helpers ---
706
707 func dmGroupIDHex(peer string) (s string) {
708 selfPub := helpers.HexDecode(mlsSt.myPK)
709 peerPub := helpers.HexDecode(peer)
710 if selfPub == nil || peerPub == nil {
711 return ""
712 }
713 id := marmot.DMGroupID(selfPub, peerPub)
714 return helpers.HexEncode(id)
715 }
716
717 func buildGroupIDsJSON() (s string) {
718 s := "["
719 first := true
720 for id := range mlsSt.mlsGroups {
721 if !first {
722 s = s | ","
723 }
724 first = false
725 s = s | jstr(id)
726 }
727 return s | "]"
728 }
729
730 func buildURLsJSON(urls []string) (s string) {
731 s := "["
732 for i, u := range urls {
733 if i > 0 {
734 s = s | ","
735 }
736 s = s | jstr(u)
737 }
738 return s | "]"
739 }
740
741 func buildDMRecord(peer, from, content string, createdAt int64, protocol, eventID string) (s string) {
742 return `{"id":` | jstr(eventID) |
743 `,"peer":` | jstr(peer) |
744 `,"from":` | jstr(from) |
745 `,"content":` | jstr(content) |
746 `,"created_at":` | itoa(createdAt) |
747 `,"protocol":` | jstr(protocol) |
748 `,"eventId":` | jstr(eventID) |
749 `}`
750 }
751
752 func parseStrArr(json string) (ss []string) {
753 var out []string
754 i := 0
755 for i < len(json) && json[i] != '[' {
756 i++
757 }
758 if i >= len(json) {
759 return nil
760 }
761 i++
762 for {
763 for i < len(json) && (json[i] == ' ' || json[i] == ',' || json[i] == '\n') {
764 i++
765 }
766 if i >= len(json) || json[i] == ']' {
767 break
768 }
769 if json[i] != '"' {
770 break
771 }
772 i++
773 start := i
774 for i < len(json) && json[i] != '"' {
775 if json[i] == '\\' {
776 i++
777 }
778 i++
779 }
780 if i >= len(json) {
781 break
782 }
783 out = push(out, json[start:i])
784 i++
785 }
786 return out
787 }
788
789 func itoa(n int64) (s string) { return helpers.Itoa(n) }
790 func itoi(n int32) (s string) { return helpers.Itoa(int64(n)) }
791 func jstr(s string) (sv string) { return helpers.JsonString(s) }
792
793