aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_webrtc_transport.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-03 16:16:55 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-03 16:16:55 +0200
commit675beed6ff688733a9598f9d82d41578f48316be (patch)
tree78dd4f8dff312f0ad99bd63bc679bf402591c5ed /packages/meshbay-node/tests/test_webrtc_transport.py
parent15087b0e8fdb872602310119f14680aaa443fd93 (diff)
downloadmeshbay-675beed6ff688733a9598f9d82d41578f48316be.tar.gz
feat!: MNP 1.0 — seal index and handshake_ack under the group key
`index_sync`, `index_delta` and the `handshake_ack` config payload now travel sealed under a GEK-derived subkey (`meshbay_common/groupbox.py`, mirrored by `sealGroup`/`openGroup` in `crypto.js`). Only `type`, `v`, `group_id` and the ack's `node_pk`/`proof`/`sig` stay in clear — a receiver must route and authenticate before it would trust a decryption. Verify, then decrypt. The ack line is integrity, not confidentiality: the signed handshake transcript names no ack field, so `is_node_admin`, `enabled_apps`, `video_root` and the rest were authenticated by the DTLS channel alone. The index line is defence in depth against a repeat of C1/C6 — a peer served before the handshake completes now gets ciphertext, not filenames. Nothing against an observer, the hub, or a member; that is the whole claim. `index_progress` stays clear (D3, counters only). Chat is out of scope. Failure is fatal: a payload that does not open ends the session naming the message type — never an empty index or an empty `enabled_apps`, both of which are legitimate states. Version negotiation ships here too (phase 15.6, brought forward): `v` + `v_min` on `handshake` and `handshake_challenge`, refused with `version_too_old` / `version_too_new` / `version_unreadable`. The flag day was already being paid for; the next breaking change now costs a refusal message. BREAKING CHANGE: breaks the WebRTC wire every deployed client speaks. Hub and every node must deploy together; the SPA is served by the hub, so a browser picks up the new client on reload. See MESHBAY_NODE_PROTOCOL.md §11.1a, §13.1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HkzbhmMmK8PqQBtGz5zCvY
Diffstat (limited to 'packages/meshbay-node/tests/test_webrtc_transport.py')
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py71
1 files changed, 69 insertions, 2 deletions
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py
index a4c7f61..dc74752 100644
--- a/packages/meshbay-node/tests/test_webrtc_transport.py
+++ b/packages/meshbay-node/tests/test_webrtc_transport.py
@@ -33,6 +33,7 @@ from meshbay_common.crypto import (
unwrap_gek,
unwrap_gek_aes,
)
+from meshbay_common.groupbox import PURPOSE_ACK, PURPOSE_INDEX, unseal
from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes
from meshbay_common.protocol import MNP
TEST_GROUP = "g"
@@ -358,13 +359,20 @@ async def test_webrtc_datachannel_file_transfer(sk_node, sk_hub, gek, shared_dir
ack = await _handshake_with_gek_proof(channel, received, sk_hub, gek,
browser_pc=browser_pc)
assert ack["type"] == MNP.HANDSHAKE_ACK
+ # 1b) The ack's configuration is sealed under the group key (MNP 1.0), and the
+ # signed handshake transcript names no ack field — so this envelope is the only
+ # thing authenticating `is_node_admin` and the rest.
+ config = unseal(gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, TEST_GROUP, ack)
+ assert "is_node_admin" in config
+ assert "is_node_admin" not in 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 "entries" in idx_msg
- assert len(idx_msg["entries"]) > 0
+ assert "entries" not in idx_msg, "the index travels in the clear"
+ payload = unseal(gek, PURPOSE_INDEX, MNP.INDEX_SYNC, TEST_GROUP, idx_msg)
+ assert len(payload["entries"]) > 0
# 3) Request file chunk
entry = next(e for e in indexer.index.entries if e.name == "test.bin")
@@ -442,6 +450,65 @@ async def test_webrtc_invalid_jwt_rejected(sk_node, sk_hub, gek, shared_dir):
@pytest.mark.asyncio
+async def test_webrtc_old_client_is_refused_with_a_code(sk_node, sk_hub, gek, shared_dir):
+ """
+ A version mismatch must present as a refusal, not as a missing field.
+
+ An 0.x client reaching a 1.0 node would otherwise get a `handshake_ack` with
+ no `enabled_apps` and apply its documented fallback — show every app — and an
+ `index_sync` with no `entries` it would read as an empty group. Both are
+ confident wrong answers. The check runs *before* the token, so it costs
+ nothing and reports the real reason (L2).
+ """
+ hub_pk_pem = _hub_pk_pem(sk_hub)
+ indexer = DirectoryIndexer(roots=one_root(shared_dir), group_id=TEST_GROUP,
+ sk_node=sk_node, gek=gek)
+ await indexer.initial_scan()
+
+ transport = WebRTCTransport(
+ sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek,
+ roots=one_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-old")
+ await browser_pc.setRemoteDescription(
+ RTCSessionDescription(sdp=answer_sdp, type="answer"))
+ await asyncio.sleep(0.5)
+
+ # A perfectly valid token — the refusal must not depend on it, and must not
+ # be reported as an authorization problem either.
+ channel.send(_pack({
+ "type": MNP.HANDSHAKE,
+ "v": "0.15",
+ "token": _make_jwt(sk_hub, groups=[TEST_GROUP]),
+ "group_id": TEST_GROUP,
+ "nonce": base64.b64encode(os.urandom(32)).decode(),
+ }))
+
+ msg = await asyncio.wait_for(received.get(), timeout=5.0)
+ assert msg["type"] == "error"
+ # The client matches on the code; the text may be reworded.
+ assert msg["code"] == "version_too_old"
+ assert "0.15" 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)