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