main.mx raw
1 // mlsinterop — Moxie side of the Marmot/MLS interop REPL.
2 //
3 // Wire protocol: JSON lines on stdin → JSON lines on stdout.
4 // State persists in-memory across commands.
5 //
6 // On startup emits: {"ready":true,"pubkey":"<hex>"}
7 //
8 // Commands (all include "cmd" field):
9 // generate_key_package {relay} → event_json
10 // process_key_package {event_json} → pubkey
11 // create_group {member_kp_event_json, name, relay} → rumor_json, mls_group_id_hex, nostr_group_id_hex
12 // process_welcome {rumor_json} → mls_group_id_hex, nostr_group_id_hex
13 // create_message {mls_group_id_hex, content} → event_json
14 // process_message {event_json} → content, kind, pubkey
15 // get_group_info {} → mls_group_id_hex, nostr_group_id_hex
16 //
17 // Cipher suite: 0x0003 (ChaCha20-Poly1305). Rust/MDK uses 0x0001; pure
18 // Moxie↔Moxie roundtrip works, Moxie↔Rust fails at key-package parse
19 // until suite alignment is addressed.
20
21 package main
22
23 import (
24 "smesh.lol/web/common/helpers"
25 "smesh.lol/web/common/jsbridge/node"
26 "smesh.lol/web/common/jsbridge/schnorr"
27 "smesh.lol/web/common/jsbridge/subtle"
28 "smesh.lol/web/common/marmot"
29 "smesh.lol/web/common/mls"
30 "smesh.lol/web/common/nostr"
31 )
32
33 type state struct {
34 nostrSec [32]byte
35 nostrPub string // hex
36 kpp *mls.KeyPairPackage
37 gs *marmot.GroupState
38 }
39
40 type response struct {
41 ok bool
42 err string
43 eventJSON string
44 rumorJSON string
45 pubkey string
46 content string
47 kind uint32 // Nostr event kind (wire width)
48 mlsGroupIDHex string
49 nostrGroupIDHex string
50 }
51
52 func main() {
53 s := newState()
54 node.WriteLine(`{"ready":true,"pubkey":` | helpers.JsonString(s.nostrPub) | `}`)
55
56 done := chan bool{}
57
58 node.OnLine(func(line string) {
59 trimmed := trimSpace(line)
60 if len(trimmed) == 0 {
61 return
62 }
63 node.WriteLine(serializeResponse(handle(s, trimmed)))
64 })
65 node.OnClose(func() {
66 done <- true
67 })
68
69 <-done
70 }
71
72 func newState() *state {
73 s := &state{}
74 subtle.RandomBytes(s.nostrSec[:])
75 pub, ok := schnorr.PubKeyFromSecKey(s.nostrSec[:])
76 if !ok {
77 panic("mlsinterop: failed to derive pubkey")
78 }
79 s.nostrPub = helpers.HexEncode(pub)
80 return s
81 }
82
83 func handle(s *state, line string) response {
84 cmd := helpers.JsonGetString(line, "cmd")
85 switch cmd {
86 case "generate_key_package":
87 return cmdGenerateKeyPackage(s, line)
88 case "process_key_package":
89 return cmdProcessKeyPackage(line)
90 case "create_group":
91 return cmdCreateGroup(s, line)
92 case "process_welcome":
93 return cmdProcessWelcome(s, line)
94 case "create_message":
95 return cmdCreateMessage(s, line)
96 case "process_message":
97 return cmdProcessMessage(s, line)
98 case "get_group_info":
99 return cmdGetGroupInfo(s)
100 }
101 return errResponse("unknown cmd: " | cmd)
102 }
103
104 // --- Commands ---
105
106 func cmdGenerateKeyPackage(s *state, line string) response {
107 relay := helpers.JsonGetString(line, "relay")
108 nowUnix := node.NowSeconds()
109
110 pubBytes := helpers.HexDecode(s.nostrPub)
111 if pubBytes == nil {
112 return errResponse("decode own pubkey")
113 }
114 kpp, err := marmot.GenerateKeyPackage(pubBytes, nowUnix)
115 if err != nil {
116 return errResponse("generate key package: " | err.Error())
117 }
118 s.kpp = kpp
119
120 var relays []string
121 if relay != "" {
122 relays = []string{relay}
123 }
124 ev, err := marmot.KeyPackageToEvent(kpp, s.nostrSec, nowUnix, relays)
125 if err != nil {
126 return errResponse("build kind 443 event: " | err.Error())
127 }
128 r := okResponse()
129 r.eventJSON = ev.ToJSON()
130 r.pubkey = s.nostrPub
131 return r
132 }
133
134 func cmdProcessKeyPackage(line string) response {
135 evJSON := helpers.JsonGetString(line, "event_json")
136 ev := nostr.ParseEvent(evJSON)
137 if ev == nil {
138 return errResponse("parse kind 443 event JSON")
139 }
140 if ev.Kind != marmot.KindKeyPackage {
141 return errResponse("wrong kind: expected 443")
142 }
143 kp, err := marmot.EventToKeyPackage(ev)
144 if err != nil {
145 return errResponse("event to key package: " | err.Error())
146 }
147 _ = kp
148 r := okResponse()
149 r.pubkey = ev.PubKey
150 return r
151 }
152
153 func cmdCreateGroup(s *state, line string) response {
154 if s.kpp == nil {
155 return errResponse("no local key package — call generate_key_package first")
156 }
157 memberJSON := helpers.JsonGetString(line, "member_kp_event_json")
158 name := helpers.JsonGetString(line, "name")
159 relay := helpers.JsonGetString(line, "relay")
160
161 memberEv := nostr.ParseEvent(memberJSON)
162 if memberEv == nil {
163 return errResponse("parse member KP event JSON")
164 }
165 peerKP, err := marmot.EventToKeyPackage(memberEv)
166 if err != nil {
167 return errResponse("member event to key package: " | err.Error())
168 }
169 peerPub := helpers.HexDecode(memberEv.PubKey)
170 if peerPub == nil {
171 return errResponse("decode peer pubkey")
172 }
173 selfPub := helpers.HexDecode(s.nostrPub)
174 if selfPub == nil {
175 return errResponse("decode self pubkey")
176 }
177
178 var relays []string
179 if relay != "" {
180 relays = []string{relay}
181 }
182 gs, welcome, err := marmot.CreateDMGroup(s.kpp, peerKP, selfPub, peerPub, name, relays)
183 if err != nil {
184 return errResponse("create DM group: " | err.Error())
185 }
186 s.gs = gs
187
188 nowUnix := node.NowSeconds()
189 rumor := marmot.WelcomeToRumor(welcome, selfPub, nowUnix, memberEv.ID, relays)
190
191 r := okResponse()
192 r.rumorJSON = rumor.ToJSON()
193 r.mlsGroupIDHex = helpers.HexEncode(gs.MLSGroupID)
194 r.nostrGroupIDHex = helpers.HexEncode(gs.NostrGroupID)
195 return r
196 }
197
198 func cmdProcessWelcome(s *state, line string) response {
199 if s.kpp == nil {
200 return errResponse("no local key package — call generate_key_package first")
201 }
202 rumorJSON := helpers.JsonGetString(line, "rumor_json")
203 rumor := nostr.ParseEvent(rumorJSON)
204 if rumor == nil {
205 return errResponse("parse rumor JSON")
206 }
207 welcome, err := marmot.RumorToWelcome(rumor)
208 if err != nil {
209 return errResponse("rumor to welcome: " | err.Error())
210 }
211 peerPub := helpers.HexDecode(rumor.PubKey)
212 if peerPub == nil {
213 return errResponse("decode rumor sender pubkey")
214 }
215
216 gs, err := marmot.JoinDMGroup(welcome, s.kpp, peerPub, node.NowSeconds())
217 if err != nil {
218 return errResponse("join DM group: " | err.Error())
219 }
220 s.gs = gs
221
222 r := okResponse()
223 r.mlsGroupIDHex = helpers.HexEncode(gs.MLSGroupID)
224 r.nostrGroupIDHex = helpers.HexEncode(gs.NostrGroupID)
225 return r
226 }
227
228 func cmdCreateMessage(s *state, line string) response {
229 if s.gs == nil {
230 return errResponse("no group — create or join one first")
231 }
232 wantIDHex := helpers.JsonGetString(line, "mls_group_id_hex")
233 haveIDHex := helpers.HexEncode(s.gs.MLSGroupID)
234 if wantIDHex != "" && wantIDHex != haveIDHex {
235 return errResponse("mls_group_id_hex mismatch: want " | wantIDHex | " have " | haveIDHex)
236 }
237 content := helpers.JsonGetString(line, "content")
238
239 mlsCiphertext, err := s.gs.Group.CreateApplicationMessage([]byte(content))
240 if err != nil {
241 return errResponse("create application message: " | err.Error())
242 }
243 exporterSecret, err := marmot.DeriveExporterSecret(s.gs.Group)
244 if err != nil {
245 return errResponse("derive exporter secret: " | err.Error())
246 }
247 ev, err := marmot.MessageToEvent(s.gs.NostrGroupID, mlsCiphertext, exporterSecret, node.NowSeconds())
248 if err != nil {
249 return errResponse("build kind 445 event: " | err.Error())
250 }
251
252 r := okResponse()
253 r.eventJSON = ev.ToJSON()
254 return r
255 }
256
257 func cmdProcessMessage(s *state, line string) response {
258 if s.gs == nil {
259 return errResponse("no group — create or join one first")
260 }
261 evJSON := helpers.JsonGetString(line, "event_json")
262 ev := nostr.ParseEvent(evJSON)
263 if ev == nil {
264 return errResponse("parse kind 445 event JSON")
265 }
266 exporterSecret, err := marmot.DeriveExporterSecret(s.gs.Group)
267 if err != nil {
268 return errResponse("derive exporter secret: " | err.Error())
269 }
270 _, mlsCiphertext, err := marmot.EventToMessage(ev, exporterSecret)
271 if err != nil {
272 return errResponse("event to message: " | err.Error())
273 }
274
275 plaintext, _, err := s.gs.Group.UnmarshalAndProcessMessage(mlsCiphertext)
276 if err != nil {
277 return errResponse("process MLS message: " | err.Error())
278 }
279
280 r := okResponse()
281 r.content = string(plaintext)
282 r.kind = marmot.KindGroupMessage
283 r.pubkey = ev.PubKey
284 return r
285 }
286
287 func cmdGetGroupInfo(s *state) response {
288 if s.gs == nil {
289 return errResponse("no group")
290 }
291 r := okResponse()
292 r.mlsGroupIDHex = helpers.HexEncode(s.gs.MLSGroupID)
293 r.nostrGroupIDHex = helpers.HexEncode(s.gs.NostrGroupID)
294 return r
295 }
296
297 // --- Response helpers ---
298
299 func okResponse() response {
300 return response{ok: true}
301 }
302
303 func errResponse(msg string) response {
304 return response{ok: false, err: msg}
305 }
306
307 func serializeResponse(r response) string {
308 buf := []byte{:0:256}
309 if r.ok {
310 buf = append(buf, `{"ok":true`...)
311 } else {
312 buf = append(buf, `{"ok":false`...)
313 }
314 if r.err != "" {
315 buf = append(buf, `,"error":`...)
316 buf = append(buf, helpers.JsonString(r.err)...)
317 }
318 if r.eventJSON != "" {
319 buf = append(buf, `,"event_json":`...)
320 buf = append(buf, helpers.JsonString(r.eventJSON)...)
321 }
322 if r.rumorJSON != "" {
323 buf = append(buf, `,"rumor_json":`...)
324 buf = append(buf, helpers.JsonString(r.rumorJSON)...)
325 }
326 if r.pubkey != "" {
327 buf = append(buf, `,"pubkey":`...)
328 buf = append(buf, helpers.JsonString(r.pubkey)...)
329 }
330 if r.content != "" {
331 buf = append(buf, `,"content":`...)
332 buf = append(buf, helpers.JsonString(r.content)...)
333 }
334 if r.kind != 0 {
335 buf = append(buf, `,"kind":`...)
336 buf = append(buf, helpers.Itoa(int64(r.kind))...)
337 }
338 if r.mlsGroupIDHex != "" {
339 buf = append(buf, `,"mls_group_id_hex":`...)
340 buf = append(buf, helpers.JsonString(r.mlsGroupIDHex)...)
341 }
342 if r.nostrGroupIDHex != "" {
343 buf = append(buf, `,"nostr_group_id_hex":`...)
344 buf = append(buf, helpers.JsonString(r.nostrGroupIDHex)...)
345 }
346 buf = append(buf, '}')
347 return string(buf)
348 }
349
350 func trimSpace(s string) string {
351 i, j := 0, len(s)
352 for i < j && isSpace(s[i]) {
353 i++
354 }
355 for j > i && isSpace(s[j-1]) {
356 j--
357 }
358 return s[i:j]
359 }
360
361 func isSpace(c byte) bool {
362 return c == ' ' || c == '\t' || c == '\r' || c == '\n'
363 }
364