diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-10 22:12:59 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-10 22:12:59 +0200 |
| commit | 60c4570e72e36c2a9720593c8baec74ee2ab52d6 (patch) | |
| tree | e833e222a6a95e64e5b24a0813882636044198c2 /packages/meshbay-node/tests | |
| parent | 1a53eb4cc404ec94658fde0ae04cfe2ccf1810dc (diff) | |
| download | meshbay-60c4570e72e36c2a9720593c8baec74ee2ab52d6.tar.gz | |
feat: Phase 9.1–9.5 — WebRTC DataChannel transport for browser P2P
Browser clients can now connect P2P to nodes behind residential NAT via
WebRTC DataChannel with ICE/STUN. Validated on SFR Port-Restricted Cone
NAT + 4G CGNAT across three scenarios (WiFi LAN, 4G IPv6, 4G IPv4 STUN).
No TURN relay needed. Hub serves only as signaling relay (<1 KB).
New files:
- webrtc_server.py: aiortc-based WebRTC transport (node side)
- signaling.py: SDP/ICE relay endpoint (hub side)
- transport.js: browser WebRTC client with msgpack framing
- webrtc-test.html: spike test page for browser→NAT→node validation
- test_webrtc_transport.py: 4 tests (handshake, file transfer, auth, guard)
- meshbay-draft-v4.md: architecture spec updated for web client
Modified:
- hub_client.py: WebRTC offer handling via hub WebSocket
- revocation.py: node_id from WS auth + webrtc_answer routing
- pyproject.toml: aiortc>=1.9 dependency
123 tests passing (117 existing + 6 new).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/tests')
| -rw-r--r-- | packages/meshbay-node/tests/test_webrtc_transport.py | 326 |
1 files changed, 326 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py new file mode 100644 index 0000000..4c0fdbf --- /dev/null +++ b/packages/meshbay-node/tests/test_webrtc_transport.py @@ -0,0 +1,326 @@ +""" +Integration test: WebRTC DataChannel transport for browser clients. + +Phase 9 milestone 9.1 — spike: validate aiortc WebRTC DataChannel works +for MNP protocol exchange (handshake, index_sync, file_request, file_chunk). + +Uses local loopback (no STUN/ICE needed for localhost). +""" + +import asyncio +import base64 +import os +import struct +import time + +import blake3 +import jwt +import msgpack +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from aiortc import RTCPeerConnection, RTCSessionDescription + +from meshbay_common import MNP_VERSION +from meshbay_common.crypto import ( + generate_gek, + pk_to_b64, + chunk_key as derive_chunk_key, + decrypt_chunk, + verify_chunk_signature, +) +from meshbay_common.protocol import MNP +from meshbay_node.indexer import DirectoryIndexer +from meshbay_node.transport.webrtc_server import WebRTCTransport + + +@pytest.fixture +def sk_node(): + return Ed25519PrivateKey.generate() + + +@pytest.fixture +def sk_hub(): + return Ed25519PrivateKey.generate() + + +@pytest.fixture +def gek(): + return generate_gek() + + +@pytest.fixture +def shared_dir(tmp_path): + d = tmp_path / "shared" + d.mkdir() + (d / "test.bin").write_bytes(os.urandom(2048)) + (d / "hello.txt").write_bytes(b"hello webrtc " * 50) + return d + + +def _hub_pk_pem(sk_hub): + return sk_hub.public_key().public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo) + + +def _make_jwt(sk_hub, groups=None): + sk_pem = sk_hub.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + now = int(time.time()) + return jwt.encode({ + "iss": "test-hub", "sub": "user-001", + "pk_user": "test", "hub_id": "test-hub", + "jti": "test-jti-webrtc", "iat": now, "exp": now + 3600, + "groups": groups or [], + }, sk_pem, algorithm="EdDSA") + + +def _pack(obj: dict) -> bytes: + data = msgpack.packb(obj, use_bin_type=True) + return struct.pack(">I", len(data)) + data + + +def _unpack(raw: bytes) -> dict: + length = struct.unpack(">I", raw[:4])[0] + return msgpack.unpackb(raw[4:4 + length], raw=False) + + +@pytest.mark.asyncio +async def test_webrtc_datachannel_handshake(sk_node, sk_hub, gek, shared_dir): + """WebRTC DataChannel: browser sends MNP handshake, node responds with handshake_ack.""" + hub_pk_pem = _hub_pk_pem(sk_hub) + indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek) + await indexer.initial_scan() + + transport = WebRTCTransport( + sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek, + shared_root=shared_dir, index=indexer.index, + stun_servers=[], + ) + + browser_pc = RTCPeerConnection() + received = asyncio.Queue() + + channel = browser_pc.createDataChannel("mnp") + + @channel.on("message") + def on_msg(message): + if isinstance(message, str): + message = message.encode() + received.put_nowait(_unpack(message)) + + offer = await browser_pc.createOffer() + await browser_pc.setLocalDescription(offer) + + answer_sdp, ice_candidates = await transport.handle_offer( + browser_pc.localDescription.sdp, "peer-001") + + answer = RTCSessionDescription(sdp=answer_sdp, type="answer") + await browser_pc.setRemoteDescription(answer) + + await asyncio.sleep(0.5) + + token = _make_jwt(sk_hub) + channel.send(_pack({ + "type": MNP.HANDSHAKE, + "v": MNP_VERSION, + "token": token, + })) + + msg = await asyncio.wait_for(received.get(), timeout=5.0) + assert msg["type"] == MNP.HANDSHAKE_ACK + assert msg["v"] == MNP_VERSION + assert "node_pk" in msg + + await browser_pc.close() + await transport.close_all() + + +@pytest.mark.asyncio +async def test_webrtc_datachannel_file_transfer(sk_node, sk_hub, gek, shared_dir): + """WebRTC DataChannel: full file transfer — handshake, index, fetch chunk, decrypt.""" + hub_pk_pem = _hub_pk_pem(sk_hub) + indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek) + await indexer.initial_scan() + + transport = WebRTCTransport( + sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek, + shared_root=shared_dir, index=indexer.index, + stun_servers=[], + ) + + browser_pc = RTCPeerConnection() + received = asyncio.Queue() + + channel = browser_pc.createDataChannel("mnp") + + @channel.on("open") + def on_open(): + token = _make_jwt(sk_hub) + channel.send(_pack({ + "type": MNP.HANDSHAKE, + "v": MNP_VERSION, + "token": token, + })) + + buf = bytearray() + + @channel.on("message") + def on_msg(message): + if isinstance(message, str): + message = message.encode() + buf.extend(message) + while len(buf) >= 4: + length = struct.unpack(">I", buf[:4])[0] + if len(buf) < 4 + length: + break + msg_bytes = bytes(buf[4:4 + length]) + del buf[:4 + length] + received.put_nowait(msgpack.unpackb(msg_bytes, raw=False)) + + offer = await browser_pc.createOffer() + await browser_pc.setLocalDescription(offer) + + answer_sdp, _ = await transport.handle_offer( + browser_pc.localDescription.sdp, "peer-002") + await browser_pc.setRemoteDescription( + RTCSessionDescription(sdp=answer_sdp, type="answer")) + + # 1) Handshake ack + ack = await asyncio.wait_for(received.get(), timeout=5.0) + assert ack["type"] == MNP.HANDSHAKE_ACK + + # 2) Request index + channel.send(_pack({"type": MNP.INDEX_SYNC, "v": MNP_VERSION})) + idx_msg = await asyncio.wait_for(received.get(), timeout=5.0) + assert idx_msg["type"] == MNP.INDEX_SYNC + assert "index_b64" in idx_msg + + # 3) Request file chunk + entry = next(e for e in indexer.index.entries if e.name == "test.bin") + channel.send(_pack({ + "type": MNP.FILE_REQUEST, + "v": MNP_VERSION, + "file_id": entry.id, + "chunk_index": 0, + })) + + chunk_msg = await asyncio.wait_for(received.get(), timeout=5.0) + assert chunk_msg["type"] == MNP.FILE_CHUNK + + # 4) Verify and decrypt + ct = base64.b64decode(chunk_msg["ct_b64"]) + nonce = base64.b64decode(chunk_msg["nonce_b64"]) + ct_hash = base64.b64decode(chunk_msg["ct_hash_b64"]) + pt_hash = base64.b64decode(chunk_msg["pt_hash_b64"]) + sig = base64.b64decode(chunk_msg["sig_b64"]) + file_hash = base64.b64decode(chunk_msg["file_hash_b64"]) + + pk_node = sk_node.public_key() + verify_chunk_signature(pk_node, 0, nonce, ct_hash, sig) + assert blake3.blake3(ct).digest() == ct_hash + + ckey = derive_chunk_key(gek, file_hash, 0) + plaintext = decrypt_chunk(ckey, nonce, ct) + assert blake3.blake3(plaintext).digest() == pt_hash + + original = (shared_dir / "test.bin").read_bytes() + assert plaintext == original + + await browser_pc.close() + await transport.close_all() + + +@pytest.mark.asyncio +async def test_webrtc_invalid_jwt_rejected(sk_node, sk_hub, gek, shared_dir): + """WebRTC DataChannel: invalid JWT is rejected with error.""" + hub_pk_pem = _hub_pk_pem(sk_hub) + indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek) + await indexer.initial_scan() + + transport = WebRTCTransport( + sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek, + shared_root=shared_dir, index=indexer.index, + stun_servers=[], + ) + + browser_pc = RTCPeerConnection() + received = asyncio.Queue() + + channel = browser_pc.createDataChannel("mnp") + + @channel.on("message") + def on_msg(message): + if isinstance(message, str): + message = message.encode() + received.put_nowait(_unpack(message)) + + offer = await browser_pc.createOffer() + await browser_pc.setLocalDescription(offer) + + answer_sdp, _ = await transport.handle_offer( + browser_pc.localDescription.sdp, "peer-003") + await browser_pc.setRemoteDescription( + RTCSessionDescription(sdp=answer_sdp, type="answer")) + + await asyncio.sleep(0.5) + + channel.send(_pack({ + "type": MNP.HANDSHAKE, + "v": MNP_VERSION, + "token": "invalid.jwt.token", + })) + + msg = await asyncio.wait_for(received.get(), timeout=5.0) + assert msg["type"] == "error" + assert "JWT" in msg["detail"] or "Invalid" in msg["detail"] + + await browser_pc.close() + await transport.close_all() + + +@pytest.mark.asyncio +async def test_webrtc_request_before_handshake_rejected(sk_node, sk_hub, gek, shared_dir): + """WebRTC DataChannel: request without handshake is rejected.""" + hub_pk_pem = _hub_pk_pem(sk_hub) + indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek) + await indexer.initial_scan() + + transport = WebRTCTransport( + sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek, + shared_root=shared_dir, index=indexer.index, + stun_servers=[], + ) + + browser_pc = RTCPeerConnection() + received = asyncio.Queue() + + channel = browser_pc.createDataChannel("mnp") + + @channel.on("message") + def on_msg(message): + if isinstance(message, str): + message = message.encode() + received.put_nowait(_unpack(message)) + + offer = await browser_pc.createOffer() + await browser_pc.setLocalDescription(offer) + + answer_sdp, _ = await transport.handle_offer( + browser_pc.localDescription.sdp, "peer-004") + await browser_pc.setRemoteDescription( + RTCSessionDescription(sdp=answer_sdp, type="answer")) + + await asyncio.sleep(0.5) + + channel.send(_pack({"type": MNP.INDEX_SYNC, "v": MNP_VERSION})) + + msg = await asyncio.wait_for(received.get(), timeout=5.0) + assert msg["type"] == "error" + assert "Handshake required" in msg["detail"] + + await browser_pc.close() + await transport.close_all() |