test_mls_dm.py raw

   1  """End-to-end test: two users exchange MLS DMs through a local relay without auth.
   2  
   3  Requires: relay binary, WASM app built, geckodriver in PATH.
   4  Run: ./test/run.sh -k mls_dm --headed   (visible) or without --headed (headless)
   5  """
   6  
   7  import json
   8  import os
   9  import signal
  10  import subprocess
  11  import time
  12  
  13  import pytest
  14  from selenium import webdriver
  15  from selenium.webdriver.common.by import By
  16  from selenium.webdriver.common.keys import Keys
  17  from selenium.webdriver.firefox.options import Options
  18  from selenium.webdriver.firefox.service import Service
  19  from selenium.webdriver.support.ui import WebDriverWait
  20  
  21  from conftest import Helpers, RELAY_BIN, STATIC_DIR
  22  
  23  MLS_DATA_DIR = "/tmp/smesh-mls-test"
  24  MLS_PORT = 23335
  25  
  26  
  27  def _bech32_encode_nsec(seckey_bytes):
  28      """Encode 32 bytes as bech32 nsec1..."""
  29      CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
  30  
  31      def _convertbits(data, frombits, tobits, pad=True):
  32          acc, bits, ret = 0, 0, []
  33          maxv = (1 << tobits) - 1
  34          for value in data:
  35              acc = (acc << frombits) | value
  36              bits += frombits
  37              while bits >= tobits:
  38                  bits -= tobits
  39                  ret.append((acc >> bits) & maxv)
  40          if pad and bits:
  41              ret.append((acc << (tobits - bits)) & maxv)
  42          return ret
  43  
  44      def _bech32_polymod(values):
  45          GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
  46          chk = 1
  47          for v in values:
  48              b = chk >> 25
  49              chk = ((chk & 0x1ffffff) << 5) ^ v
  50              for i in range(5):
  51                  chk ^= GEN[i] if ((b >> i) & 1) else 0
  52          return chk
  53  
  54      def _bech32_create_checksum(hrp, data):
  55          values = [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp] + data
  56          polymod = _bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1
  57          return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
  58  
  59      hrp = "nsec"
  60      data5 = _convertbits(list(seckey_bytes), 8, 5)
  61      checksum = _bech32_create_checksum(hrp, data5)
  62      return hrp + "1" + "".join(CHARSET[d] for d in data5 + checksum)
  63  
  64  
  65  def _start_relay():
  66      """Start relay with ORLY_MARMOT_OPEN=true, return info dict."""
  67      if not os.path.exists(RELAY_BIN):
  68          pytest.skip(f"Relay binary not found: {RELAY_BIN}")
  69  
  70      if os.path.exists(MLS_DATA_DIR):
  71          subprocess.run(["rm", "-rf", MLS_DATA_DIR], check=True)
  72      os.makedirs(MLS_DATA_DIR, exist_ok=True)
  73  
  74      env = os.environ.copy()
  75      env["ORLY_DATA_DIR"] = MLS_DATA_DIR
  76      env["ORLY_LISTEN"] = "127.0.0.1"
  77      env["ORLY_PORT"] = str(MLS_PORT)
  78      env["ORLY_STATIC_DIR"] = STATIC_DIR
  79      env["ORLY_HTTP_GUARD_ENABLED"] = "false"
  80      env["ORLY_MARMOT_OPEN"] = "true"
  81  
  82      proc = subprocess.Popen(
  83          [RELAY_BIN], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE
  84      )
  85  
  86      import urllib.request
  87      url = f"http://127.0.0.1:{MLS_PORT}"
  88      for _ in range(30):
  89          try:
  90              urllib.request.urlopen(url, timeout=1)
  91              break
  92          except Exception:
  93              time.sleep(0.2)
  94      else:
  95          proc.kill()
  96          pytest.fail("MLS test relay failed to start")
  97  
  98      return {
  99          "proc": proc,
 100          "url": url,
 101          "ws": f"ws://127.0.0.1:{MLS_PORT}",
 102      }
 103  
 104  
 105  def _stop_relay(info):
 106      proc = info["proc"]
 107      if proc.poll() is not None:
 108          stderr = proc.stderr.read().decode(errors="replace")
 109          if stderr:
 110              print(f"\n=== MLS RELAY STDERR ===\n{stderr[-2000:]}\n=== END ===\n")
 111          return
 112      proc.send_signal(signal.SIGTERM)
 113      try:
 114          proc.wait(timeout=5)
 115      except subprocess.TimeoutExpired:
 116          proc.kill()
 117  
 118  
 119  def _make_browser(headed):
 120      opts = Options()
 121      if not headed:
 122          opts.add_argument("--headless")
 123      opts.set_preference("xpinstall.signatures.required", False)
 124      opts.set_preference("extensions.autoDisableScopes", 0)
 125      opts.set_preference("devtools.console.stdout.content", True)
 126      svc = Service(log_output="/dev/null")
 127      driver = webdriver.Firefox(options=opts, service=svc)
 128      driver.set_page_load_timeout(60)
 129      driver.set_script_timeout(30)
 130      driver.implicitly_wait(3)
 131      return driver
 132  
 133  
 134  def _setup_user(driver, relay_url, nsec):
 135      """Load app, create vault with given nsec, return hex pubkey."""
 136      driver.get(relay_url)
 137      h = Helpers(driver)
 138      h.wait_wasm_ready()
 139      h.wait_signer_ready()
 140  
 141      status = h.vault_status()
 142      if status == "none":
 143          h.js_async("""
 144              var cb = arguments[arguments.length - 1];
 145              window.__smesh_create_vault(arguments[0])
 146                  .then(function(ok) { cb(ok); })
 147                  .catch(function() { cb(false); });
 148          """, "testpass123", timeout=30)
 149      elif status == "locked":
 150          h.js_async("""
 151              var cb = arguments[arguments.length - 1];
 152              window.__smesh_unlock_vault(arguments[0])
 153                  .then(function(ok) { cb(ok); })
 154                  .catch(function() { cb(false); });
 155          """, "testpass123", timeout=30)
 156  
 157      h.js_async("""
 158          var cb = arguments[arguments.length - 1];
 159          window.__smesh_add_identity(arguments[0])
 160              .then(function(r) { cb(!!r); })
 161              .catch(function() { cb(false); });
 162      """, nsec, timeout=10)
 163  
 164      pk = h.get_public_key()
 165      assert pk and len(pk) == 64, f"Failed to get pubkey after adding identity: {pk}"
 166      return pk
 167  
 168  
 169  def _query_relay(ws_url, filt, timeout=5):
 170      """Query relay via WebSocket, return list of events."""
 171      import websocket
 172      ws = websocket.create_connection(ws_url, timeout=timeout)
 173      ws.send(json.dumps(["REQ", "diag", filt]))
 174      events = []
 175      deadline = time.time() + timeout
 176      while time.time() < deadline:
 177          try:
 178              raw = ws.recv()
 179          except Exception:
 180              break
 181          msg = json.loads(raw)
 182          if msg[0] == "EOSE":
 183              break
 184          if msg[0] == "EVENT":
 185              events.append(msg[2])
 186      ws.close()
 187      return events
 188  
 189  
 190  def _poll_relay(ws_url, filt, min_count, timeout=15):
 191      """Poll relay until at least min_count events match filter."""
 192      deadline = time.time() + timeout
 193      while time.time() < deadline:
 194          evs = _query_relay(ws_url, filt, timeout=3)
 195          if len(evs) >= min_count:
 196              return evs
 197          time.sleep(1)
 198      return _query_relay(ws_url, filt, timeout=3)
 199  
 200  
 201  def _wait_for_message(driver, text, timeout=30):
 202      """Wait for a message bubble containing text to appear."""
 203      def _check(d):
 204          bubbles = d.find_elements(
 205              By.XPATH,
 206              "//div[contains(@style, 'inline-block') and contains(@style, 'borderRadius')]"
 207          )
 208          for b in bubbles:
 209              if text in b.text:
 210                  return True
 211          return False
 212  
 213      try:
 214          WebDriverWait(driver, timeout).until(_check)
 215          return True
 216      except Exception:
 217          return False
 218  
 219  
 220  def _navigate_to_dm(driver, relay_url, peer_hex):
 221      """Navigate to DM thread with peer using URL path."""
 222      from nostr_helpers import _convertbits_for_bech32, _bech32_encode
 223      # Convert hex to npub
 224      peer_bytes = bytes.fromhex(peer_hex)
 225      npub = _hex_to_npub(peer_hex)
 226      driver.get(f"{relay_url}/msg/{npub}")
 227      time.sleep(1)
 228  
 229  
 230  def _hex_to_npub(hex_pubkey):
 231      """Convert hex pubkey to npub bech32."""
 232      CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
 233  
 234      def convertbits(data, frombits, tobits, pad=True):
 235          acc, bits, ret = 0, 0, []
 236          maxv = (1 << tobits) - 1
 237          for value in data:
 238              acc = (acc << frombits) | value
 239              bits += frombits
 240              while bits >= tobits:
 241                  bits -= tobits
 242                  ret.append((acc >> bits) & maxv)
 243          if pad and bits:
 244              ret.append((acc << (tobits - bits)) & maxv)
 245          return ret
 246  
 247      def polymod(values):
 248          GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
 249          chk = 1
 250          for v in values:
 251              b = chk >> 25
 252              chk = ((chk & 0x1ffffff) << 5) ^ v
 253              for i in range(5):
 254                  chk ^= GEN[i] if ((b >> i) & 1) else 0
 255          return chk
 256  
 257      def create_checksum(hrp, data):
 258          values = [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp] + data
 259          p = polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1
 260          return [(p >> 5 * (5 - i)) & 31 for i in range(6)]
 261  
 262      hrp = "npub"
 263      data5 = convertbits(list(bytes.fromhex(hex_pubkey)), 8, 5)
 264      checksum = create_checksum(hrp, data5)
 265      return hrp + "1" + "".join(CHARSET[d] for d in data5 + checksum)
 266  
 267  
 268  def _send_message(driver, text):
 269      """Type into compose textarea and send."""
 270      textarea = WebDriverWait(driver, 10).until(
 271          lambda d: d.find_element(By.TAG_NAME, "textarea")
 272      )
 273      textarea.clear()
 274      textarea.send_keys(text)
 275      textarea.send_keys(Keys.ENTER)
 276  
 277  
 278  # ── The Test ──────────────────────────────────────
 279  
 280  
 281  @pytest.fixture
 282  def mls_env(request):
 283      """Spin up relay + two browsers, yield everything, tear down."""
 284      headed = request.config.getoption("--headed", default=False)
 285  
 286      relay = _start_relay()
 287  
 288      alice_driver = _make_browser(headed)
 289      bob_driver = _make_browser(headed)
 290  
 291      # Generate two distinct keypairs
 292      import hashlib
 293      alice_sec = hashlib.sha256(b"mls-test-alice-key").digest()
 294      bob_sec = hashlib.sha256(b"mls-test-bob-key").digest()
 295      alice_nsec = _bech32_encode_nsec(alice_sec)
 296      bob_nsec = _bech32_encode_nsec(bob_sec)
 297  
 298      yield {
 299          "relay": relay,
 300          "alice": alice_driver,
 301          "bob": bob_driver,
 302          "alice_nsec": alice_nsec,
 303          "bob_nsec": bob_nsec,
 304      }
 305  
 306      alice_driver.quit()
 307      bob_driver.quit()
 308      _stop_relay(relay)
 309  
 310  
 311  def test_mls_dm_bidirectional(mls_env):
 312      """Two users exchange MLS DMs through local relay without NIP-42 auth."""
 313      relay = mls_env["relay"]
 314      alice = mls_env["alice"]
 315      bob = mls_env["bob"]
 316      url = relay["url"]
 317      ws = relay["ws"]
 318  
 319      # 1. Setup identities
 320      pk_alice = _setup_user(alice, url, mls_env["alice_nsec"])
 321      pk_bob = _setup_user(bob, url, mls_env["bob_nsec"])
 322      print(f"\nAlice pubkey: {pk_alice}")
 323      print(f"Bob pubkey:   {pk_bob}")
 324      assert pk_alice != pk_bob, "Alice and Bob must have distinct keys"
 325  
 326      # 2. Both navigate to messages with each other (triggers MLS_INIT + KP publish)
 327      _navigate_to_dm(alice, url, pk_bob)
 328      _navigate_to_dm(bob, url, pk_alice)
 329  
 330      # 3. Poll for KeyPackages on relay (both should publish kind 443)
 331      kps = _poll_relay(ws, {"kinds": [443], "authors": [pk_alice, pk_bob]}, 2, timeout=20)
 332      print(f"KeyPackages on relay: {len(kps)}")
 333      if len(kps) < 2:
 334          # Diagnostic: check individually
 335          kp_a = _query_relay(ws, {"kinds": [443], "authors": [pk_alice]})
 336          kp_b = _query_relay(ws, {"kinds": [443], "authors": [pk_bob]})
 337          pytest.fail(
 338              f"Expected 2 KeyPackages, got {len(kps)}. "
 339              f"Alice KPs: {len(kp_a)}, Bob KPs: {len(kp_b)}"
 340          )
 341  
 342      # 4. Alice sends message
 343      _send_message(alice, "hello from alice")
 344      print("Alice sent message")
 345  
 346      # 5. Poll for gift-wrap (Welcome) to Bob
 347      wraps = _poll_relay(ws, {"kinds": [1059], "#p": [pk_bob]}, 1, timeout=15)
 348      print(f"Gift-wraps to Bob: {len(wraps)}")
 349      if not wraps:
 350          pytest.fail("No gift-wrap (Welcome) delivered to Bob - group creation failed")
 351  
 352      # 6. Poll for kind 445 (encrypted message)
 353      msgs445 = _poll_relay(ws, {"kinds": [445]}, 1, timeout=15)
 354      print(f"Kind 445 messages: {len(msgs445)}")
 355      if not msgs445:
 356          pytest.fail("No kind 445 (MLS message) published - send failed after group creation")
 357  
 358      # 7. Bob should see the message
 359      assert _wait_for_message(bob, "hello from alice", timeout=30), \
 360          "Bob did not receive 'hello from alice'"
 361      print("Bob received Alice's message")
 362  
 363      # 8. Bob replies
 364      _send_message(bob, "hello from bob")
 365      print("Bob sent reply")
 366  
 367      # 9. Poll for Bob's reply on relay
 368      msgs445_2 = _poll_relay(ws, {"kinds": [445]}, 2, timeout=15)
 369      print(f"Kind 445 messages after reply: {len(msgs445_2)}")
 370  
 371      # 10. Alice should see Bob's reply
 372      assert _wait_for_message(alice, "hello from bob", timeout=30), \
 373          "Alice did not receive 'hello from bob'"
 374      print("Alice received Bob's reply")
 375      print("\nMLS DM bidirectional exchange: PASSED")
 376