interop_test.go raw

   1  //go:build interop
   2  
   3  package marmot
   4  
   5  import (
   6  	"bufio"
   7  	"encoding/base64"
   8  	"encoding/json"
   9  	"fmt"
  10  	"io"
  11  	"os"
  12  	"os/exec"
  13  	"path/filepath"
  14  	"runtime"
  15  	"testing"
  16  
  17  	"github.com/emersion/go-mls"
  18  	"git.smesh.lol/orly/pkg/nostr/encoders/event"
  19  	"git.smesh.lol/orly/pkg/nostr/encoders/hex"
  20  	"git.smesh.lol/orly/pkg/nostr/interfaces/signer/p8k"
  21  )
  22  
  23  var interopBinaryPath string
  24  
  25  func TestMain(m *testing.M) {
  26  	// Locate testdata/interop relative to this source file.
  27  	_, thisFile, _, _ := runtime.Caller(0)
  28  	crateDir := filepath.Join(filepath.Dir(thisFile), "testdata", "interop")
  29  
  30  	interopBinaryPath = filepath.Join(crateDir, "target", "release", "mls-interop")
  31  
  32  	// Build if missing or stale (cargo handles staleness).
  33  	if _, err := exec.LookPath("cargo"); err == nil {
  34  		fmt.Println("building mls-interop (cargo build --release)...")
  35  		cmd := exec.Command("cargo", "build", "--release")
  36  		cmd.Dir = crateDir
  37  		cmd.Stdout = os.Stderr
  38  		cmd.Stderr = os.Stderr
  39  		if err := cmd.Run(); err != nil {
  40  			fmt.Fprintf(os.Stderr, "cargo build failed: %v\n", err)
  41  			os.Exit(1)
  42  		}
  43  	}
  44  
  45  	os.Exit(m.Run())
  46  }
  47  
  48  type rustProcess struct {
  49  	cmd    *exec.Cmd
  50  	stdin  io.WriteCloser
  51  	reader *bufio.Reader
  52  }
  53  
  54  type rustResponse struct {
  55  	OK              bool   `json:"ok"`
  56  	Error           string `json:"error,omitempty"`
  57  	EventJSON       string `json:"event_json,omitempty"`
  58  	RumorJSON       string `json:"rumor_json,omitempty"`
  59  	Pubkey          string `json:"pubkey,omitempty"`
  60  	Content         string `json:"content,omitempty"`
  61  	Kind            uint16 `json:"kind,omitempty"`
  62  	MLSGroupIDHex   string `json:"mls_group_id_hex,omitempty"`
  63  	NostrGroupIDHex string `json:"nostr_group_id_hex,omitempty"`
  64  }
  65  
  66  type rustInit struct {
  67  	Ready  bool   `json:"ready"`
  68  	Pubkey string `json:"pubkey"`
  69  }
  70  
  71  func startRust(t *testing.T) (*rustProcess, string) {
  72  	t.Helper()
  73  	if _, err := os.Stat(interopBinaryPath); os.IsNotExist(err) {
  74  		t.Skipf("interop binary not found at %s; build it from testdata/interop", interopBinaryPath)
  75  	}
  76  
  77  	cmd := exec.Command(interopBinaryPath)
  78  	cmd.Env = append(os.Environ(), "RUST_BACKTRACE=1")
  79  	stdin, err := cmd.StdinPipe()
  80  	if err != nil {
  81  		t.Fatalf("stdin pipe: %v", err)
  82  	}
  83  	stdout, err := cmd.StdoutPipe()
  84  	if err != nil {
  85  		t.Fatalf("stdout pipe: %v", err)
  86  	}
  87  	stderrFile, _ := os.Create("/tmp/interop-stderr.log")
  88  	cmd.Stderr = stderrFile
  89  	t.Cleanup(func() { stderrFile.Close() })
  90  	if err := cmd.Start(); err != nil {
  91  		t.Fatalf("start rust binary: %v", err)
  92  	}
  93  	t.Cleanup(func() {
  94  		stdin.Close()
  95  		cmd.Wait()
  96  	})
  97  
  98  	reader := bufio.NewReader(stdout)
  99  
 100  	line, err := reader.ReadString('\n')
 101  	if err != nil {
 102  		t.Fatalf("read init: %v", err)
 103  	}
 104  	var init rustInit
 105  	if err := json.Unmarshal([]byte(line), &init); err != nil {
 106  		t.Fatalf("parse init: %v (line: %s)", err, line)
 107  	}
 108  	if !init.Ready {
 109  		t.Fatal("rust binary not ready")
 110  	}
 111  
 112  	return &rustProcess{cmd: cmd, stdin: stdin, reader: reader}, init.Pubkey
 113  }
 114  
 115  func (r *rustProcess) send(t *testing.T, cmd interface{}) rustResponse {
 116  	t.Helper()
 117  	data, err := json.Marshal(cmd)
 118  	if err != nil {
 119  		t.Fatalf("marshal command: %v", err)
 120  	}
 121  	if _, err := fmt.Fprintf(r.stdin, "%s\n", data); err != nil {
 122  		t.Fatalf("write command: %v", err)
 123  	}
 124  	line, err := r.reader.ReadString('\n')
 125  	if err != nil {
 126  		t.Fatalf("read response: %v", err)
 127  	}
 128  	var resp rustResponse
 129  	if err := json.Unmarshal([]byte(line), &resp); err != nil {
 130  		t.Fatalf("parse response: %v (line: %s)", err, line)
 131  	}
 132  	return resp
 133  }
 134  
 135  // TestInterop_KeyPackageFormat validates Go kind 443 events are accepted
 136  // by Rust MDK, and Rust kind 443 events are parsed by Go.
 137  func TestInterop_KeyPackageFormat(t *testing.T) {
 138  	rust, _ := startRust(t)
 139  
 140  	goSign, err := p8k.New()
 141  	if err != nil {
 142  		t.Fatal(err)
 143  	}
 144  	if err := goSign.Generate(); err != nil {
 145  		t.Fatal(err)
 146  	}
 147  
 148  	// Go generates key package event
 149  	kpp, err := GenerateKeyPackage(&LocalCrypto{Sign: goSign})
 150  	if err != nil {
 151  		t.Fatalf("go GenerateKeyPackage: %v", err)
 152  	}
 153  	ev, err := KeyPackageToEvent(kpp, &LocalCrypto{Sign: goSign}, []string{"wss://relay.example.com"})
 154  	if err != nil {
 155  		t.Fatalf("go KeyPackageToEvent: %v", err)
 156  	}
 157  	evJSON, err := ev.MarshalJSON()
 158  	if err != nil {
 159  		t.Fatalf("marshal event: %v", err)
 160  	}
 161  
 162  	// First test: parse raw KP bytes directly through OpenMLS
 163  	kpRawB64 := base64.StdEncoding.EncodeToString(kpp.Public.RawBytes())
 164  	resp := rust.send(t, map[string]string{
 165  		"cmd":       "parse_kp_raw",
 166  		"kp_base64": kpRawB64,
 167  	})
 168  	if !resp.OK {
 169  		t.Fatalf("rust OpenMLS rejected Go raw KP bytes: %s", resp.Error)
 170  	}
 171  	t.Logf("Rust OpenMLS parsed raw Go KP: %s", resp.Content)
 172  
 173  	// Rust validates full event
 174  	resp = rust.send(t, map[string]string{
 175  		"cmd":        "process_key_package",
 176  		"event_json": string(evJSON),
 177  	})
 178  	if !resp.OK {
 179  		t.Fatalf("rust rejected Go key package: %s", resp.Error)
 180  	}
 181  	t.Logf("Rust accepted Go key package (pubkey: %s)", resp.Pubkey)
 182  
 183  	// Rust generates key package event
 184  	resp = rust.send(t, map[string]string{
 185  		"cmd":   "generate_key_package",
 186  		"relay": "wss://relay.example.com",
 187  	})
 188  	if !resp.OK {
 189  		t.Fatalf("rust generate_key_package failed: %s", resp.Error)
 190  	}
 191  
 192  	// Go parses it
 193  	var rustEv event.E
 194  	if err := rustEv.UnmarshalJSON([]byte(resp.EventJSON)); err != nil {
 195  		t.Fatalf("parse rust event: %v", err)
 196  	}
 197  	if rustEv.Kind != KindKeyPackage {
 198  		t.Fatalf("wrong kind: want %d, got %d", KindKeyPackage, rustEv.Kind)
 199  	}
 200  	rustKP, err := EventToKeyPackage(&rustEv)
 201  	if err != nil {
 202  		t.Fatalf("go EventToKeyPackage from rust: %v", err)
 203  	}
 204  	if rustKP == nil {
 205  		t.Fatal("nil key package from rust")
 206  	}
 207  	t.Log("Go parsed Rust key package")
 208  }
 209  
 210  // TestInterop_WelcomeAndMessage validates the full group lifecycle:
 211  // Rust creates group with Go's key package, Go joins via welcome,
 212  // then Rust sends a message that Go decrypts.
 213  func TestInterop_WelcomeAndMessage(t *testing.T) {
 214  	rust, _ := startRust(t)
 215  
 216  	goSign, err := p8k.New()
 217  	if err != nil {
 218  		t.Fatal(err)
 219  	}
 220  	if err := goSign.Generate(); err != nil {
 221  		t.Fatal(err)
 222  	}
 223  
 224  	// Go generates key package
 225  	goKPP, err := GenerateKeyPackage(&LocalCrypto{Sign: goSign})
 226  	if err != nil {
 227  		t.Fatalf("go GenerateKeyPackage: %v", err)
 228  	}
 229  	goKPEv, err := KeyPackageToEvent(goKPP, &LocalCrypto{Sign: goSign}, []string{"wss://relay.example.com"})
 230  	if err != nil {
 231  		t.Fatalf("go KeyPackageToEvent: %v", err)
 232  	}
 233  	goKPJSON, err := goKPEv.MarshalJSON()
 234  	if err != nil {
 235  		t.Fatalf("marshal kp event: %v", err)
 236  	}
 237  
 238  	// Rust creates group with Go's key package
 239  	resp := rust.send(t, map[string]string{
 240  		"cmd":                  "create_group",
 241  		"member_kp_event_json": string(goKPJSON),
 242  		"name":                 "interop-test",
 243  		"relay":                "wss://relay.example.com",
 244  	})
 245  	if !resp.OK {
 246  		t.Fatalf("rust create_group failed: %s", resp.Error)
 247  	}
 248  	t.Logf("Rust created group (mls_group_id: %s, nostr_group_id: %s)",
 249  		resp.MLSGroupIDHex, resp.NostrGroupIDHex)
 250  	rustMLSGroupID := resp.MLSGroupIDHex
 251  	rustNostrGroupID := resp.NostrGroupIDHex
 252  
 253  	// Parse the Rust welcome rumor (kind 444 unsigned event)
 254  	rumorJSON := resp.RumorJSON
 255  
 256  	var rumorEv event.E
 257  	if err := rumorEv.UnmarshalJSON([]byte(rumorJSON)); err != nil {
 258  		t.Fatalf("parse rust welcome rumor: %v", err)
 259  	}
 260  	if rumorEv.Kind != KindWelcome {
 261  		t.Fatalf("wrong rumor kind: want %d, got %d", KindWelcome, rumorEv.Kind)
 262  	}
 263  
 264  	// Check encoding tag
 265  	encodingTag := rumorEv.Tags.GetFirst([]byte("encoding"))
 266  	if encodingTag == nil {
 267  		t.Fatal("missing 'encoding' tag on welcome rumor")
 268  	}
 269  	if string(encodingTag.Value()) != "base64" {
 270  		t.Fatalf("wrong encoding: %s", string(encodingTag.Value()))
 271  	}
 272  
 273  	// Decode the welcome content
 274  	welcomeBytes, err := base64.StdEncoding.DecodeString(string(rumorEv.Content))
 275  	if err != nil {
 276  		t.Fatalf("base64 decode welcome: %v", err)
 277  	}
 278  
 279  	// Parse MLS Welcome
 280  	welcome, err := mls.UnmarshalWelcome(welcomeBytes)
 281  	if err != nil {
 282  		t.Fatalf("unmarshal welcome: %v", err)
 283  	}
 284  
 285  	// Go joins the group
 286  	goGS, err := JoinDMGroup(welcome, goKPP, nil)
 287  	if err != nil {
 288  		t.Fatalf("go JoinDMGroup: %v", err)
 289  	}
 290  	t.Logf("Go joined group (nostr_group_id: %s)", hex.Enc(goGS.NostrGroupID))
 291  
 292  	// Verify nostr_group_id matches
 293  	if hex.Enc(goGS.NostrGroupID) != rustNostrGroupID {
 294  		t.Fatalf("nostr_group_id mismatch: go=%s rust=%s",
 295  			hex.Enc(goGS.NostrGroupID), rustNostrGroupID)
 296  	}
 297  	t.Log("NostrGroupID matches between Go and Rust")
 298  
 299  	// Rust sends a message
 300  	resp = rust.send(t, map[string]string{
 301  		"cmd":              "create_message",
 302  		"mls_group_id_hex": rustMLSGroupID,
 303  		"content":          "hello from rust",
 304  	})
 305  	if !resp.OK {
 306  		t.Fatalf("rust create_message failed: %s", resp.Error)
 307  	}
 308  
 309  	// Go decrypts the Rust message
 310  	var msgEv event.E
 311  	if err := msgEv.UnmarshalJSON([]byte(resp.EventJSON)); err != nil {
 312  		t.Fatalf("parse rust message event: %v", err)
 313  	}
 314  	if msgEv.Kind != KindGroupMessage {
 315  		t.Fatalf("wrong message kind: want %d, got %d", KindGroupMessage, msgEv.Kind)
 316  	}
 317  
 318  	// Derive exporter secret
 319  	exporterSecret, err := goGS.DeriveExporterSecret()
 320  	if err != nil {
 321  		t.Fatalf("derive exporter secret: %v", err)
 322  	}
 323  
 324  	// Decrypt outer ChaCha20-Poly1305 layer
 325  	_, mlsCiphertext, err := EventToMessage(&msgEv, exporterSecret)
 326  	if err != nil {
 327  		t.Fatalf("go EventToMessage: %v", err)
 328  	}
 329  
 330  	// Decrypt inner MLS layer
 331  	plaintext, err := goGS.Decrypt(mlsCiphertext)
 332  	if err != nil {
 333  		t.Fatalf("go MLS decrypt: %v", err)
 334  	}
 335  
 336  	t.Logf("Decrypted MLS payload (%d bytes)", len(plaintext))
 337  
 338  	// MDK wraps content in a nostr event (kind 9 rumor)
 339  	var innerEv event.E
 340  	if err := innerEv.UnmarshalJSON(plaintext); err != nil {
 341  		t.Logf("Decrypted payload is not JSON event, raw: %s", string(plaintext))
 342  	} else {
 343  		t.Logf("Inner event kind=%d content=%q", innerEv.Kind, string(innerEv.Content))
 344  		if string(innerEv.Content) != "hello from rust" {
 345  			t.Errorf("content mismatch: want 'hello from rust', got %q", string(innerEv.Content))
 346  		}
 347  	}
 348  	t.Log("INTEROP SUCCESS: Go decrypted Rust MLS message")
 349  }
 350