// mlsinterop — Moxie side of the Marmot/MLS interop REPL. // // Wire protocol: JSON lines on stdin → JSON lines on stdout. // State persists in-memory across commands. // // On startup emits: {"ready":true,"pubkey":""} // // Commands (all include "cmd" field): // generate_key_package {relay} → event_json // process_key_package {event_json} → pubkey // create_group {member_kp_event_json, name, relay} → rumor_json, mls_group_id_hex, nostr_group_id_hex // process_welcome {rumor_json} → mls_group_id_hex, nostr_group_id_hex // create_message {mls_group_id_hex, content} → event_json // process_message {event_json} → content, kind, pubkey // get_group_info {} → mls_group_id_hex, nostr_group_id_hex // // Cipher suite: 0x0003 (ChaCha20-Poly1305). Rust/MDK uses 0x0001; pure // Moxie↔Moxie roundtrip works, Moxie↔Rust fails at key-package parse // until suite alignment is addressed. package main import ( "smesh.lol/web/common/helpers" "smesh.lol/web/common/jsbridge/node" "smesh.lol/web/common/jsbridge/schnorr" "smesh.lol/web/common/jsbridge/subtle" "smesh.lol/web/common/marmot" "smesh.lol/web/common/mls" "smesh.lol/web/common/nostr" ) type state struct { nostrSec [32]byte nostrPub string // hex kpp *mls.KeyPairPackage gs *marmot.GroupState } type response struct { ok bool err string eventJSON string rumorJSON string pubkey string content string kind uint32 // Nostr event kind (wire width) mlsGroupIDHex string nostrGroupIDHex string } func main() { s := newState() node.WriteLine(`{"ready":true,"pubkey":` | helpers.JsonString(s.nostrPub) | `}`) done := chan bool{} node.OnLine(func(line string) { trimmed := trimSpace(line) if len(trimmed) == 0 { return } node.WriteLine(serializeResponse(handle(s, trimmed))) }) node.OnClose(func() { done <- true }) <-done } func newState() *state { s := &state{} subtle.RandomBytes(s.nostrSec[:]) pub, ok := schnorr.PubKeyFromSecKey(s.nostrSec[:]) if !ok { panic("mlsinterop: failed to derive pubkey") } s.nostrPub = helpers.HexEncode(pub) return s } func handle(s *state, line string) response { cmd := helpers.JsonGetString(line, "cmd") switch cmd { case "generate_key_package": return cmdGenerateKeyPackage(s, line) case "process_key_package": return cmdProcessKeyPackage(line) case "create_group": return cmdCreateGroup(s, line) case "process_welcome": return cmdProcessWelcome(s, line) case "create_message": return cmdCreateMessage(s, line) case "process_message": return cmdProcessMessage(s, line) case "get_group_info": return cmdGetGroupInfo(s) } return errResponse("unknown cmd: " | cmd) } // --- Commands --- func cmdGenerateKeyPackage(s *state, line string) response { relay := helpers.JsonGetString(line, "relay") nowUnix := node.NowSeconds() pubBytes := helpers.HexDecode(s.nostrPub) if pubBytes == nil { return errResponse("decode own pubkey") } kpp, err := marmot.GenerateKeyPackage(pubBytes, nowUnix) if err != nil { return errResponse("generate key package: " | err.Error()) } s.kpp = kpp var relays []string if relay != "" { relays = []string{relay} } ev, err := marmot.KeyPackageToEvent(kpp, s.nostrSec, nowUnix, relays) if err != nil { return errResponse("build kind 443 event: " | err.Error()) } r := okResponse() r.eventJSON = ev.ToJSON() r.pubkey = s.nostrPub return r } func cmdProcessKeyPackage(line string) response { evJSON := helpers.JsonGetString(line, "event_json") ev := nostr.ParseEvent(evJSON) if ev == nil { return errResponse("parse kind 443 event JSON") } if ev.Kind != marmot.KindKeyPackage { return errResponse("wrong kind: expected 443") } kp, err := marmot.EventToKeyPackage(ev) if err != nil { return errResponse("event to key package: " | err.Error()) } _ = kp r := okResponse() r.pubkey = ev.PubKey return r } func cmdCreateGroup(s *state, line string) response { if s.kpp == nil { return errResponse("no local key package — call generate_key_package first") } memberJSON := helpers.JsonGetString(line, "member_kp_event_json") name := helpers.JsonGetString(line, "name") relay := helpers.JsonGetString(line, "relay") memberEv := nostr.ParseEvent(memberJSON) if memberEv == nil { return errResponse("parse member KP event JSON") } peerKP, err := marmot.EventToKeyPackage(memberEv) if err != nil { return errResponse("member event to key package: " | err.Error()) } peerPub := helpers.HexDecode(memberEv.PubKey) if peerPub == nil { return errResponse("decode peer pubkey") } selfPub := helpers.HexDecode(s.nostrPub) if selfPub == nil { return errResponse("decode self pubkey") } var relays []string if relay != "" { relays = []string{relay} } gs, welcome, err := marmot.CreateDMGroup(s.kpp, peerKP, selfPub, peerPub, name, relays) if err != nil { return errResponse("create DM group: " | err.Error()) } s.gs = gs nowUnix := node.NowSeconds() rumor := marmot.WelcomeToRumor(welcome, selfPub, nowUnix, memberEv.ID, relays) r := okResponse() r.rumorJSON = rumor.ToJSON() r.mlsGroupIDHex = helpers.HexEncode(gs.MLSGroupID) r.nostrGroupIDHex = helpers.HexEncode(gs.NostrGroupID) return r } func cmdProcessWelcome(s *state, line string) response { if s.kpp == nil { return errResponse("no local key package — call generate_key_package first") } rumorJSON := helpers.JsonGetString(line, "rumor_json") rumor := nostr.ParseEvent(rumorJSON) if rumor == nil { return errResponse("parse rumor JSON") } welcome, err := marmot.RumorToWelcome(rumor) if err != nil { return errResponse("rumor to welcome: " | err.Error()) } peerPub := helpers.HexDecode(rumor.PubKey) if peerPub == nil { return errResponse("decode rumor sender pubkey") } gs, err := marmot.JoinDMGroup(welcome, s.kpp, peerPub, node.NowSeconds()) if err != nil { return errResponse("join DM group: " | err.Error()) } s.gs = gs r := okResponse() r.mlsGroupIDHex = helpers.HexEncode(gs.MLSGroupID) r.nostrGroupIDHex = helpers.HexEncode(gs.NostrGroupID) return r } func cmdCreateMessage(s *state, line string) response { if s.gs == nil { return errResponse("no group — create or join one first") } wantIDHex := helpers.JsonGetString(line, "mls_group_id_hex") haveIDHex := helpers.HexEncode(s.gs.MLSGroupID) if wantIDHex != "" && wantIDHex != haveIDHex { return errResponse("mls_group_id_hex mismatch: want " | wantIDHex | " have " | haveIDHex) } content := helpers.JsonGetString(line, "content") mlsCiphertext, err := s.gs.Group.CreateApplicationMessage([]byte(content)) if err != nil { return errResponse("create application message: " | err.Error()) } exporterSecret, err := marmot.DeriveExporterSecret(s.gs.Group) if err != nil { return errResponse("derive exporter secret: " | err.Error()) } ev, err := marmot.MessageToEvent(s.gs.NostrGroupID, mlsCiphertext, exporterSecret, node.NowSeconds()) if err != nil { return errResponse("build kind 445 event: " | err.Error()) } r := okResponse() r.eventJSON = ev.ToJSON() return r } func cmdProcessMessage(s *state, line string) response { if s.gs == nil { return errResponse("no group — create or join one first") } evJSON := helpers.JsonGetString(line, "event_json") ev := nostr.ParseEvent(evJSON) if ev == nil { return errResponse("parse kind 445 event JSON") } exporterSecret, err := marmot.DeriveExporterSecret(s.gs.Group) if err != nil { return errResponse("derive exporter secret: " | err.Error()) } _, mlsCiphertext, err := marmot.EventToMessage(ev, exporterSecret) if err != nil { return errResponse("event to message: " | err.Error()) } plaintext, _, err := s.gs.Group.UnmarshalAndProcessMessage(mlsCiphertext) if err != nil { return errResponse("process MLS message: " | err.Error()) } r := okResponse() r.content = string(plaintext) r.kind = marmot.KindGroupMessage r.pubkey = ev.PubKey return r } func cmdGetGroupInfo(s *state) response { if s.gs == nil { return errResponse("no group") } r := okResponse() r.mlsGroupIDHex = helpers.HexEncode(s.gs.MLSGroupID) r.nostrGroupIDHex = helpers.HexEncode(s.gs.NostrGroupID) return r } // --- Response helpers --- func okResponse() response { return response{ok: true} } func errResponse(msg string) response { return response{ok: false, err: msg} } func serializeResponse(r response) string { buf := []byte{:0:256} if r.ok { buf = append(buf, `{"ok":true`...) } else { buf = append(buf, `{"ok":false`...) } if r.err != "" { buf = append(buf, `,"error":`...) buf = append(buf, helpers.JsonString(r.err)...) } if r.eventJSON != "" { buf = append(buf, `,"event_json":`...) buf = append(buf, helpers.JsonString(r.eventJSON)...) } if r.rumorJSON != "" { buf = append(buf, `,"rumor_json":`...) buf = append(buf, helpers.JsonString(r.rumorJSON)...) } if r.pubkey != "" { buf = append(buf, `,"pubkey":`...) buf = append(buf, helpers.JsonString(r.pubkey)...) } if r.content != "" { buf = append(buf, `,"content":`...) buf = append(buf, helpers.JsonString(r.content)...) } if r.kind != 0 { buf = append(buf, `,"kind":`...) buf = append(buf, helpers.Itoa(int64(r.kind))...) } if r.mlsGroupIDHex != "" { buf = append(buf, `,"mls_group_id_hex":`...) buf = append(buf, helpers.JsonString(r.mlsGroupIDHex)...) } if r.nostrGroupIDHex != "" { buf = append(buf, `,"nostr_group_id_hex":`...) buf = append(buf, helpers.JsonString(r.nostrGroupIDHex)...) } buf = append(buf, '}') return string(buf) } func trimSpace(s string) string { i, j := 0, len(s) for i < j && isSpace(s[i]) { i++ } for j > i && isSpace(s[j-1]) { j-- } return s[i:j] } func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\r' || c == '\n' }