mls-interop-test.mjs raw
1 // Node driver for Marmot/MLS interop tests.
2 //
3 // Spawns two JSON-over-stdio children (one Moxie, one Rust) and runs the
4 // bidirectional test sequence described in plans/floating-nibbling-wren.md §B2.
5 //
6 // Invoked by scripts/mls-interop-test.sh, which exports RUST_BIN, MOXIE_ENTRY,
7 // and optional SKIP_RUST / SKIP_MOXIE.
8 //
9 // Cipher-suite note: smesh uses MLS 0x0003 (ChaCha20-Poly1305); the Rust/MDK
10 // side uses 0x0001 (AES128-GCM). Moxie↔Rust scenarios will fail at parse until
11 // suite alignment lands. Each scenario is run independently so the failure is
12 // localized rather than aborting the suite.
13
14 import { spawn } from 'node:child_process';
15 import { createInterface } from 'node:readline';
16
17 const RUST_BIN = process.env.RUST_BIN || '';
18 const MOXIE_ENTRY = process.env.MOXIE_ENTRY || '';
19 const SKIP_RUST = !!process.env.SKIP_RUST;
20 const SKIP_MOXIE = !!process.env.SKIP_MOXIE;
21 const VERBOSE = !!process.env.VERBOSE;
22 const COMMAND_TIMEOUT_MS = Number(process.env.COMMAND_TIMEOUT_MS || 20000);
23 const READY_TIMEOUT_MS = Number(process.env.READY_TIMEOUT_MS || 20000);
24
25 if (SKIP_RUST && SKIP_MOXIE) {
26 console.error('error: both SKIP_RUST and SKIP_MOXIE set — nothing to test');
27 process.exit(2);
28 }
29 if (!SKIP_RUST && !RUST_BIN) {
30 console.error('error: RUST_BIN not set (and SKIP_RUST not set)');
31 process.exit(2);
32 }
33 if (!SKIP_MOXIE && !MOXIE_ENTRY) {
34 console.error('error: MOXIE_ENTRY not set (and SKIP_MOXIE not set)');
35 process.exit(2);
36 }
37
38 const RELAY = 'wss://relay.example.com';
39 const GROUP_NAME = 'interop-test';
40
41 class Child {
42 constructor(kind, tag) {
43 this.kind = kind; // 'rust' | 'moxie'
44 this.tag = tag;
45 this.proc = null;
46 this.ready = null;
47 this.buf = [];
48 this.waiter = null;
49 this.exited = false;
50 this.exitCode = null;
51 this._readyResolve = null;
52 this._readyReject = null;
53 this._readyTimer = null;
54 }
55
56 start() {
57 const spec = this.kind === 'rust'
58 ? { cmd: RUST_BIN, args: [] }
59 : { cmd: process.execPath, args: [MOXIE_ENTRY] };
60
61 this.proc = spawn(spec.cmd, spec.args, { stdio: ['pipe', 'pipe', 'pipe'] });
62
63 const rl = createInterface({ input: this.proc.stdout });
64 rl.on('line', (line) => {
65 if (VERBOSE) console.error(`[${this.tag} ←] ${line}`);
66 let obj;
67 try { obj = JSON.parse(line); } catch { return; }
68
69 if (obj.ready === true && this.ready === null) {
70 this.ready = obj;
71 if (this._readyResolve) {
72 clearTimeout(this._readyTimer);
73 const r = this._readyResolve;
74 this._readyResolve = null;
75 this._readyReject = null;
76 r(obj);
77 }
78 return;
79 }
80
81 if (this.waiter) {
82 const w = this.waiter;
83 this.waiter = null;
84 clearTimeout(w.timer);
85 w.resolve(obj);
86 } else {
87 this.buf.push(obj);
88 }
89 });
90
91 this.proc.stderr.on('data', (d) => {
92 if (VERBOSE) process.stderr.write(`[${this.tag} stderr] ${d}`);
93 });
94
95 this.proc.on('exit', (code, signal) => {
96 this.exited = true;
97 this.exitCode = code;
98 const reason = signal ? `signal ${signal}` : `code ${code}`;
99 if (this._readyReject) {
100 clearTimeout(this._readyTimer);
101 const r = this._readyReject;
102 this._readyResolve = null;
103 this._readyReject = null;
104 r(new Error(`${this.tag} exited (${reason}) before ready`));
105 }
106 if (this.waiter) {
107 const w = this.waiter;
108 this.waiter = null;
109 clearTimeout(w.timer);
110 w.reject(new Error(`${this.tag} exited (${reason}) while awaiting response`));
111 }
112 });
113
114 this.proc.on('error', (err) => {
115 if (this._readyReject) {
116 clearTimeout(this._readyTimer);
117 const r = this._readyReject;
118 this._readyResolve = null;
119 this._readyReject = null;
120 r(err);
121 }
122 });
123
124 return new Promise((resolve, reject) => {
125 this._readyResolve = resolve;
126 this._readyReject = reject;
127 this._readyTimer = setTimeout(() => {
128 this._readyResolve = null;
129 this._readyReject = null;
130 reject(new Error(`${this.tag} did not emit ready within ${READY_TIMEOUT_MS}ms`));
131 }, READY_TIMEOUT_MS);
132 });
133 }
134
135 send(cmd, args = {}) {
136 if (this.exited) {
137 return Promise.reject(new Error(`${this.tag}: already exited (code ${this.exitCode})`));
138 }
139 const payload = { cmd, ...args };
140 const line = JSON.stringify(payload) + '\n';
141 if (VERBOSE) {
142 const short = JSON.stringify(payload);
143 console.error(`[${this.tag} →] ${short.length > 220 ? short.slice(0, 220) + '…' : short}`);
144 }
145 this.proc.stdin.write(line);
146 if (this.buf.length > 0) return Promise.resolve(this.buf.shift());
147 return new Promise((resolve, reject) => {
148 const timer = setTimeout(() => {
149 this.waiter = null;
150 reject(new Error(`${this.tag}: command "${cmd}" timed out after ${COMMAND_TIMEOUT_MS}ms`));
151 }, COMMAND_TIMEOUT_MS);
152 this.waiter = { resolve, reject, timer };
153 });
154 }
155
156 stop() {
157 if (this.proc && !this.exited) {
158 try { this.proc.stdin.end(); } catch {}
159 this.proc.kill();
160 }
161 }
162 }
163
164 let passes = 0, fails = 0;
165 const failLog = [];
166
167 function pass(label, detail = '') {
168 passes++;
169 console.log(` PASS ${label}${detail ? ' — ' + detail : ''}`);
170 }
171 function fail(label, err) {
172 fails++;
173 const msg = err && err.message ? err.message
174 : err && err.error ? err.error
175 : String(err || 'unknown failure');
176 console.log(` FAIL ${label} — ${msg}`);
177 failLog.push(`${label}: ${msg}`);
178 }
179 function expect(res, label) {
180 if (!res || res.ok !== true) {
181 fail(label, res && res.error ? res.error : (res ? 'ok != true' : 'no response'));
182 return false;
183 }
184 pass(label);
185 return true;
186 }
187
188 async function scenario(label, aKind, bKind) {
189 console.log(`\n== ${label} ==`);
190 const a = new Child(aKind, `${label}:A(${aKind})`);
191 const b = new Child(bKind, `${label}:B(${bKind})`);
192 try {
193 await a.start();
194 await b.start();
195 pass('startup', `A=${a.ready.pubkey.slice(0, 12)}… B=${b.ready.pubkey.slice(0, 12)}…`);
196 } catch (err) {
197 fail('startup', err);
198 a.stop(); b.stop();
199 return;
200 }
201
202 try {
203 // ---- (a) KeyPackage round-trip ----
204 const aKp = await a.send('generate_key_package', { relay: RELAY });
205 if (!expect(aKp, 'A.generate_key_package')) return;
206
207 const bSees = await b.send('process_key_package', { event_json: aKp.event_json });
208 expect(bSees, 'B.process_key_package(A-KP)');
209
210 const bKp = await b.send('generate_key_package', { relay: RELAY });
211 if (!expect(bKp, 'B.generate_key_package')) return;
212
213 const aSees = await a.send('process_key_package', { event_json: bKp.event_json });
214 expect(aSees, 'A.process_key_package(B-KP)');
215
216 // ---- (b) Group creation + Welcome ----
217 // A creates a group that includes B (via B's KP), B joins via Welcome rumor.
218 const grp = await a.send('create_group', {
219 member_kp_event_json: bKp.event_json,
220 name: GROUP_NAME,
221 relay: RELAY,
222 });
223 if (!expect(grp, 'A.create_group(B-KP)')) return;
224
225 const bJoin = await b.send('process_welcome', { rumor_json: grp.rumor_json });
226 if (!expect(bJoin, 'B.process_welcome')) return;
227
228 if (bJoin.mls_group_id_hex === grp.mls_group_id_hex) {
229 pass('mls_group_id match', grp.mls_group_id_hex.slice(0, 16) + '…');
230 } else {
231 fail('mls_group_id match', `A=${grp.mls_group_id_hex} B=${bJoin.mls_group_id_hex}`);
232 }
233 if (bJoin.nostr_group_id_hex === grp.nostr_group_id_hex) {
234 pass('nostr_group_id match');
235 } else {
236 fail('nostr_group_id match',
237 `A=${grp.nostr_group_id_hex} B=${bJoin.nostr_group_id_hex}`);
238 }
239
240 // ---- (c) Message round-trip ----
241 const msgA = await a.send('create_message', {
242 mls_group_id_hex: grp.mls_group_id_hex,
243 content: 'hello from A',
244 });
245 if (expect(msgA, 'A.create_message')) {
246 const bRx = await b.send('process_message', { event_json: msgA.event_json });
247 if (expect(bRx, 'B.process_message')) {
248 if (bRx.content === 'hello from A') pass('B decrypts A plaintext');
249 else fail('B decrypts A plaintext', `got "${bRx.content}"`);
250 }
251 }
252
253 const msgB = await b.send('create_message', {
254 mls_group_id_hex: bJoin.mls_group_id_hex,
255 content: 'hello from B',
256 });
257 if (expect(msgB, 'B.create_message')) {
258 const aRx = await a.send('process_message', { event_json: msgB.event_json });
259 if (expect(aRx, 'A.process_message')) {
260 if (aRx.content === 'hello from B') pass('A decrypts B plaintext');
261 else fail('A decrypts B plaintext', `got "${aRx.content}"`);
262 }
263 }
264 } catch (err) {
265 fail('scenario', err);
266 } finally {
267 a.stop();
268 b.stop();
269 }
270 }
271
272 async function main() {
273 const scenarios = [];
274 // Moxie↔Moxie: baseline (works today per plans/floating-nibbling-wren.md).
275 if (!SKIP_MOXIE) scenarios.push(['Moxie ↔ Moxie', 'moxie', 'moxie']);
276 // Rust↔Rust: sanity check that MDK roundtrips itself.
277 if (!SKIP_RUST) scenarios.push(['Rust ↔ Rust', 'rust', 'rust']);
278 // Cross scenarios: the actual interop assertion.
279 if (!SKIP_RUST && !SKIP_MOXIE) {
280 scenarios.push(['Rust creates → Moxie joins', 'rust', 'moxie']);
281 scenarios.push(['Moxie creates → Rust joins', 'moxie', 'rust']);
282 }
283
284 for (const [label, aKind, bKind] of scenarios) {
285 await scenario(label, aKind, bKind);
286 }
287
288 console.log(`\n== Summary ==`);
289 console.log(` ${passes} passed, ${fails} failed`);
290 if (fails > 0) {
291 console.log('\nFailures:');
292 for (const f of failLog) console.log(` - ${f}`);
293 }
294 process.exit(fails === 0 ? 0 : 1);
295 }
296
297 main().catch((err) => {
298 console.error('driver error:', err && err.stack || err);
299 process.exit(2);
300 });
301