aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_webrtc_transport.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-30 11:57:03 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-30 11:57:03 +0200
commit5612dbbac41609b3f84784f57f1de538262db9a9 (patch)
tree571e0bbd18c31be32ca33db35ca42f8aff672cc9 /packages/meshbay-node/tests/test_webrtc_transport.py
parentd3ad243c4ae3a273f623bd5fc631e3266aa4d0e4 (diff)
downloadmeshbay-5612dbbac41609b3f84784f57f1de538262db9a9.tar.gz
fix(node): the roster, not the key alone, decides who gets a session
The handshake opened a session for anyone holding the group key with a hub token naming the group; the roster was consulted only when wrapping the key in a join. A member revoked or unpinned on the node but still a member on the hub kept a full session with the key they already held — and was handed the chat epoch their removal had just opened, since chat keys go to any session. An honest client never met this (it asks for the key through join_request every time); one that kept the key did not have to. - After the proof, the node asks the roster and refuses with `not_authorized_for_group` unless the account is an active member of the group or the node's operator. - A removal from any door — MNP, the node page, the CLI — now opens a new chat epoch in each group the person could read, broadcasts it, and closes every connection they hold (`ops.members._after_removal`). The CLI and the node page did neither. - Design §5.2, protocol §6.1, §6.3, §14.2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/tests/test_webrtc_transport.py')
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py95
1 files changed, 95 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py
index 7a6f517..803c143 100644
--- a/packages/meshbay-node/tests/test_webrtc_transport.py
+++ b/packages/meshbay-node/tests/test_webrtc_transport.py
@@ -1546,6 +1546,8 @@ async def test_a_link_needs_the_operator(sk_node, sk_hub, gek, shared_dir, tmp_p
)
transport._ctx["roster"] = roster
transport._ctx["has_admin_authority"] = False
+ # A member this node admitted — the handshake consults the roster.
+ await roster.set_member(TEST_GROUP, "user-001", ROLE_MEMBER, "active", "test")
pc, ch, q = await _setup_peer(transport, sk_hub, gek, "peer-member")
try:
ch.send(_pack({"type": MNP.INVITE_LINK_CREATE, "v": MNP_VERSION}))
@@ -2167,3 +2169,96 @@ async def test_a_large_user_blob_round_trips_whole(
assert resp["type"] == MNP.USER_BLOB_RESP
assert len(resp["blob_enc"]) == len(body), "truncated"
assert resp["blob_enc"] == body
+
+
+# ── The roster decides who gets a session, not the key alone ────────────────
+
+async def _roster_transport(sk_node, sk_hub, gek, shared_dir, tmp_path):
+ indexer = DirectoryIndexer(roots=one_root(shared_dir), group_id="g",
+ sk_node=sk_node, gek=gek)
+ await indexer.initial_scan()
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ transport = WebRTCTransport(
+ sk_node=sk_node, hub_pk_pem=_hub_pk_pem(sk_hub), gek=gek,
+ roots=one_root(shared_dir), index=indexer.index, stun_servers=[])
+ transport._ctx["roster"] = roster
+ return transport, roster
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("state", ["revoked", "absent"])
+async def test_holding_the_key_is_not_enough_without_the_roster(
+ sk_node, sk_hub, gek, shared_dir, tmp_path, state):
+ """
+ A member revoked on this node — or never admitted by it — who still holds
+ the group key and is still a member on the hub. The proof verifies; the
+ node must refuse anyway, or revocation waits for a key rotation and the
+ new chat epoch it opened is handed straight back.
+ """
+ transport, roster = await _roster_transport(sk_node, sk_hub, gek, shared_dir, tmp_path)
+ if state == "revoked":
+ await roster.set_member(TEST_GROUP, "user-001", ROLE_MEMBER, "revoked", "test")
+ pc, ch, q = await _open_channel(transport, "peer-kept-key")
+ try:
+ token = _token(sk_hub, "user-001", "peer-kept-key", TEST_GROUP)
+ reply = await _do_mnp_handshake(ch, q, token, gek, pc, TEST_GROUP)
+ assert reply.get("type") == "error", reply
+ assert reply.get("code") == "not_authorized_for_group"
+ ch.send(_pack({"type": MNP.INDEX_SYNC, "v": MNP_VERSION}))
+ after = await asyncio.wait_for(q.get(), timeout=5.0)
+ assert after.get("detail") == "Handshake required"
+ finally:
+ await roster.close()
+ await pc.close()
+ await transport.close_all()
+
+
+@pytest.mark.asyncio
+async def test_an_admitted_member_still_gets_a_session(
+ sk_node, sk_hub, gek, shared_dir, tmp_path):
+ transport, roster = await _roster_transport(sk_node, sk_hub, gek, shared_dir, tmp_path)
+ await roster.set_member(TEST_GROUP, "user-001", ROLE_MEMBER, "active", "test")
+ try:
+ pc, ch, q = await _setup_peer(transport, sk_hub, gek, "peer-admitted")
+ await pc.close()
+ finally:
+ await roster.close()
+ await transport.close_all()
+
+
+@pytest.mark.asyncio
+async def test_revoking_from_the_node_closes_the_session_and_moves_the_chat_epoch(
+ sk_node, sk_hub, gek, shared_dir, tmp_path):
+ """The loopback door (CLI, node page) did neither before."""
+ from meshbay_node import ops
+ from meshbay_node.bundle_store import BundleStore
+
+ transport, roster = await _roster_transport(sk_node, sk_hub, gek, shared_dir, tmp_path)
+ await roster.set_member(TEST_GROUP, "user-001", ROLE_MEMBER, "active", "test")
+ store = BundleStore(tmp_path / "bundles.db")
+ await store.open()
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
+ sk_x = X25519PrivateKey.generate()
+ state = {"roster": roster, "bundle_store": store, "webrtc": transport,
+ "groups_ctx": {},
+ "pk_x25519_raw": sk_x.public_key().public_bytes(
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw),
+ "sk_x25519_raw": sk_x.private_bytes(
+ serialization.Encoding.Raw, serialization.PrivateFormat.Raw,
+ serialization.NoEncryption())}
+ try:
+ pc, ch, q = await _setup_peer(transport, sk_hub, gek, "peer-to-remove")
+ assert "peer-to-remove" in transport._sessions
+ before = await store.latest_chat_epoch(TEST_GROUP)
+
+ await ops.revoke_member(state, "user-001", TEST_GROUP)
+
+ assert await store.latest_chat_epoch(TEST_GROUP) == before + 1
+ assert "peer-to-remove" not in transport._sessions or \
+ transport._sessions["peer-to-remove"]._pc.connectionState == "closed"
+ await pc.close()
+ finally:
+ await store.close()
+ await roster.close()
+ await transport.close_all()