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