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