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