diff options
Diffstat (limited to 'packages/meshbay-hub/tests/test_availability_between_members.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_availability_between_members.py | 388 |
1 files changed, 388 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_availability_between_members.py b/packages/meshbay-hub/tests/test_availability_between_members.py new file mode 100644 index 0000000..f2c9a4f --- /dev/null +++ b/packages/meshbay-hub/tests/test_availability_between_members.py @@ -0,0 +1,388 @@ +""" +Availability: what one participant can do to the others. + +The three security reviews asked who can *read* what, who can impersonate whom, +and what a hostile node can forge. None of them asked what a legitimate but +misconfigured or careless member costs everyone else — and that is the question +a group platform lives or dies on, because every member is invited by someone +who trusted them and none of them is an attacker. + +Written after 2026-09-11, where a member's unconfigured node was registered by +the hub as a host for a group it could not serve, and — being answered first — +made that group unopenable for everyone in it. Nothing was compromised and +nothing was forged. The group was simply gone. + +Each test below is two accounts, because that is the shape the single-node +suite could not express: `_make_user` twice, a group owned by one, the other +holding whatever the defect needs. A one-member test proves a one-member +property, and every finding here needed a second person to exist at all. +""" + +import base64 +import time + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + +from meshbay_common.crypto import pk_to_b64 + + +async def _make_user(client, username: str) -> dict: + sk_ed, sk_x = Ed25519PrivateKey.generate(), X25519PrivateKey.generate() + pk_ed, pk_x = pk_to_b64(sk_ed.public_key()), pk_to_b64(sk_x.public_key()) + r = await client.post("/v1/users/register", json={ + "username": username, + "email": f"{username}@example.test", + "auth_key": base64.b64encode(b"k" * 32).decode(), + "pk_user_ed25519": pk_ed, + "pk_user_x25519": pk_x, + }) + assert r.status_code == 201, r.text + user_id = r.json()["user_id"] + r = await client.post("/v1/users/login", json={ + "username": username, + "auth_key": base64.b64encode(b"k" * 32).decode(), + }) + assert r.status_code == 200, r.text + return {"user_id": user_id, "username": username, + "token": r.json()["access_token"], "pk_ed": pk_ed, "sk_ed": sk_ed} + + +async def _announce_node(client, user: dict) -> str: + ts = int(time.time()) + msg = f"meshbay:node_announce:{user['user_id']}:{user['pk_ed']}:{ts}".encode() + r = await client.post("/v1/nodes/announce", json={ + "pk_node": user["pk_ed"], "endpoint_hint": "test", "timestamp": ts, + "signature": base64.b64encode(user["sk_ed"].sign(msg)).decode(), + }, headers={"Authorization": f"Bearer {user['token']}"}) + assert r.status_code == 201, r.text + return r.json()["node_id"] + + +async def _make_group(client, owner: dict, name: str) -> str: + r = await client.post( + "/v1/groups", + json={"name": name, "visibility": "private", "join_policy": "invite"}, + headers={"Authorization": f"Bearer {owner['token']}"}) + assert r.status_code == 201, r.text + return r.json()["group_id"] + + +async def _add_member(client, owner: dict, group_id: str, member: dict) -> None: + r = await client.post( + f"/v1/groups/{group_id}/members/{member['username']}", + headers={"Authorization": f"Bearer {owner['token']}"}) + assert r.status_code == 201, r.text + + +# ── A member's node must not speak for a group it does not host ────────────── + +@pytest.mark.asyncio +async def test_a_members_node_cannot_notify_a_group_it_does_not_host(client): + """ + `chat_notify` carried a `group_id` the hub believed, so any connected node + could write a notification to every member of any group on the hub, with a + display string of its own choosing. The node's account needed no relation + to the group whatsoever — this is the same defect as the group claim, one + message further along the same socket. + """ + from meshbay_hub.api import revocation as rev + + owner = await _make_user(client, "av_owner1") + outsider = await _make_user(client, "av_outsider1") + owner_node = await _announce_node(client, owner) + outsider_node = await _announce_node(client, outsider) + group_id = await _make_group(client, owner, "not-yours") + + rev._node_groups[owner_node] = [group_id] + rev._node_groups[outsider_node] = [] # hosts nothing, as in the incident + try: + await rev._handle_chat_notify( + group_id, "Someone you do not know", outsider["user_id"], + node_id=outsider_node) + + r = await client.get("/v1/notifications", + headers={"Authorization": f"Bearer {owner['token']}"}) + assert r.status_code == 200, r.text + assert r.json()["notifications"] == [], ( + "an unrelated node wrote into this account's notifications") + + # And the node that does host it is still able to. + await rev._handle_chat_notify( + group_id, "A real member", outsider["user_id"], node_id=owner_node) + r = await client.get("/v1/notifications", + headers={"Authorization": f"Bearer {owner['token']}"}) + assert len(r.json()["notifications"]) == 1 + finally: + rev._node_groups.pop(owner_node, None) + rev._node_groups.pop(outsider_node, None) + + +@pytest.mark.asyncio +async def test_a_member_whose_node_hosts_nothing_still_cannot_notify(client): + """ + The incident's own shape, and the one a check on *membership* would have + missed: the second account is a real member of the group, invited by its + owner. Their node is simply not the one holding the files. Being entitled + to be in a group is not being entitled to speak for it. + """ + from meshbay_hub.api import revocation as rev + + owner = await _make_user(client, "av_owner2") + member = await _make_user(client, "av_member2") + owner_node = await _announce_node(client, owner) + member_node = await _announce_node(client, member) + group_id = await _make_group(client, owner, "shared-room") + await _add_member(client, owner, group_id, member) + + rev._node_groups[owner_node] = [group_id] + rev._node_groups[member_node] = [] + try: + await rev._handle_chat_notify( + group_id, "Sounds like a member", member["user_id"], + node_id=member_node) + r = await client.get("/v1/notifications", + headers={"Authorization": f"Bearer {owner['token']}"}) + assert r.json()["notifications"] == [], ( + "a member's empty node wrote into the group owner's notifications") + finally: + rev._node_groups.pop(owner_node, None) + rev._node_groups.pop(member_node, None) + + +@pytest.mark.asyncio +async def test_one_node_cannot_spend_the_hubs_database_on_notifications(): + """ + Each chat_notify is a query and a write per member of the group, spawned + without backpressure. The budget is what stops one group's node from + costing every other group on the instance. + """ + from meshbay_hub.api.revocation import ( + NOTIFY_BURST, _notify_budget, _notify_window) + + node = "budget-node" + _notify_window.pop(node, None) + try: + assert all(_notify_budget(node) for _ in range(NOTIFY_BURST)) + assert not _notify_budget(node), "the burst was not bounded" + + # A second node is unaffected — the budget is per node, not global. + assert _notify_budget("another-node") + finally: + _notify_window.pop(node, None) + _notify_window.pop("another-node", None) + + +@pytest.mark.asyncio +async def test_the_notify_budget_is_not_refilled_by_reconnecting(client): + """ + The obvious place to clear this is the socket's `finally`, beside the other + two registries — and that would make reconnecting the way around it. The + same node token is valid for an hour. + """ + from meshbay_hub.api import revocation as rev + + user = await _make_user(client, "av_reconnect") + node_id = await _announce_node(client, user) + rev._notify_window.pop(node_id, None) + try: + for _ in range(rev.NOTIFY_BURST): + rev._notify_budget(node_id) + assert not rev._notify_budget(node_id) + + # The disconnect path itself, not a re-enactment of it: `forget_node` + # is what the socket's `finally` calls, so adding a line there is + # caught here. + rev.forget_node(node_id) + assert node_id not in rev._connected_nodes + assert node_id not in rev._node_groups + + assert not rev._notify_budget(node_id), ( + "disconnecting refilled the budget, so reconnecting defeats it") + finally: + rev._notify_window.pop(node_id, None) + + +# ── A member must not aim other people's traffic ───────────────────────────── + +@pytest.mark.asyncio +async def test_a_swarm_source_cannot_name_someone_elses_address(client): + """ + `endpoint` was free text documented as "ip:port", so an account could + publish a third party's address as a source for any content. Nothing dials + a swarm source today, which is the only reason this was not already the + reflection primitive that `notify_incoming` was fixed for (H6). A port is + all a reader needs: where the node is comes from the node record, which is + stamped with the address its announce arrived from. + """ + user = await _make_user(client, "av_swarm1") + headers = {"Authorization": f"Bearer {user['token']}"} + + for bad in ("192.0.2.7:4433", "evil.example:53", "webrtc:0", "webrtc:70000", + "webrtc:4433 ", "http://example.test"): + r = await client.post("/v1/swarm/register", headers=headers, + json={"content_hash": "ab" * 32, "endpoint": bad}) + assert r.status_code == 422, f"{bad!r} was accepted: {r.text}" + + r = await client.post("/v1/swarm/register", headers=headers, + json={"content_hash": "ab" * 32, "endpoint": "webrtc:19010"}) + assert r.status_code == 201, r.text + + +@pytest.mark.asyncio +async def test_one_account_cannot_fill_the_swarm_table(client, monkeypatch): + """Rows are keyed (hash, account) with no cap — an invented hash each time.""" + import meshbay_hub.api.groups as groups_api + monkeypatch.setattr(groups_api, "MAX_SWARM_HASHES_PER_ACCOUNT", 3) + + user = await _make_user(client, "av_swarm2") + headers = {"Authorization": f"Bearer {user['token']}"} + for i in range(3): + r = await client.post("/v1/swarm/register", headers=headers, + json={"content_hash": f"{i:064x}", + "endpoint": "webrtc:19010"}) + assert r.status_code == 201, r.text + + r = await client.post("/v1/swarm/register", headers=headers, + json={"content_hash": f"{99:064x}", "endpoint": "webrtc:19010"}) + assert r.status_code == 429, r.text + + # Refreshing one already held is not a new claim and must still work. + r = await client.post("/v1/swarm/register", headers=headers, + json={"content_hash": f"{0:064x}", "endpoint": "webrtc:19011"}) + assert r.status_code == 201, r.text + + +# ── A member's node must not answer for another's ──────────────────────────── + +def test_a_node_cannot_answer_an_offer_it_was_never_sent(): + """ + `handle_webrtc_answer` resolved any pending `peer_id` from any node's + socket. The answer is the SDP the browser then connects to, so the check is + what keeps one node from standing in for the node a client asked for. That + it had not happened rested on a uuid4 being unguessable. + """ + import asyncio + + from meshbay_hub.api.signaling import ( + _answer_owner, _webrtc_answers, handle_webrtc_answer) + + loop = asyncio.new_event_loop() + try: + future = loop.create_future() + _webrtc_answers["peer-1"] = future + _answer_owner["peer-1"] = "the-node-asked-for" + + handle_webrtc_answer({"peer_id": "peer-1", "sdp": "v=0 impostor"}, + "a-different-node") + assert not future.done(), "another node resolved this offer" + + handle_webrtc_answer({"peer_id": "peer-1", "sdp": "v=0 genuine"}, + "the-node-asked-for") + assert future.done() and future.result()["sdp"] == "v=0 genuine" + finally: + _webrtc_answers.pop("peer-1", None) + _answer_owner.pop("peer-1", None) + loop.close() + + +# ── One account must not be able to mail another at will ───────────────────── + +@pytest.mark.asyncio +async def test_an_invite_email_says_what_the_hub_knows_not_what_it_is_told( + client, monkeypatch): + """ + `group_name` went from the request body into the subject line of an email + the hub sends under its own domain, and the invitee need not be a member of + anything — as they cannot be, being invited. So any account, having made a + group, could mail any other account arbitrary text. The name now comes from + the group row, and the code has to look like one. + """ + sent: list = [] + import meshbay_hub.mail as mail_mod + monkeypatch.setattr(mail_mod, "send_invite_notification", + lambda *a, **kw: sent.append(a)) + + owner = await _make_user(client, "av_inviter") + await _make_user(client, "av_invitee") + group_id = await _make_group(client, owner, "real-group-name") + headers = {"Authorization": f"Bearer {owner['token']}"} + + r = await client.post(f"/v1/groups/{group_id}/invite-notify", headers=headers, + json={"username": "av_invitee", "code": "not a code", + "group_name": "Your account is suspended"}) + assert r.status_code == 422, r.text + assert not sent + + r = await client.post(f"/v1/groups/{group_id}/invite-notify", headers=headers, + json={"username": "av_invitee", "code": "AB12-CD34", + "group_name": "Your account is suspended"}) + assert r.status_code == 200, r.text + assert sent, "the legitimate path stopped working" + assert sent[0][3] == "real-group-name", ( + "the sender chose the subject line of a message the hub signs") + + +# ── A relay is not authenticated by the key it publishes ───────────────────── + +@pytest.mark.asyncio +async def test_a_relay_must_prove_it_holds_the_approved_key(client, monkeypatch): + """ + `relay_register` had no `Depends` and verified nothing: it compared + `pk_relay` against the approved value, which is a **public** key. Anyone + who could read it could rewrite where the hub tells nodes to send relayed + traffic — an unauthenticated write to state other people's machines act + on. The module docstring said the relay "signs keepalive JWTs"; `jwt` was + imported and never used. + """ + from meshbay_hub.api import relay as relay_mod + + sk = Ed25519PrivateKey.generate() + pk = pk_to_b64(sk.public_key()) + relay_mod._relays["r1"] = {"pk": pk, "active": False} + try: + # The public key alone, which used to be enough. + r = await client.post("/v1/relays/register", json={ + "relay_id": "r1", "endpoint": "198.51.100.9:9999", + "pk_relay": pk, "capacity": 100}) + assert r.status_code == 400, r.text + assert relay_mod._relays["r1"].get("endpoint") is None + + # A signature over someone else's endpoint does not carry either: the + # endpoint is inside the signed message. + ts = int(time.time()) + sig = sk.sign(f"meshbay:relay_register:r1:10.0.0.1:4433:{ts}".encode()) + r = await client.post("/v1/relays/register", json={ + "relay_id": "r1", "endpoint": "198.51.100.9:9999", "pk_relay": pk, + "timestamp": ts, "signature": base64.b64encode(sig).decode()}) + assert r.status_code == 401, r.text + + endpoint = "203.0.113.4:4433" + sig = sk.sign(f"meshbay:relay_register:r1:{endpoint}:{ts}".encode()) + r = await client.post("/v1/relays/register", json={ + "relay_id": "r1", "endpoint": endpoint, "pk_relay": pk, + "timestamp": ts, "signature": base64.b64encode(sig).decode()}) + assert r.status_code == 201, r.text + assert relay_mod._relays["r1"]["endpoint"] == endpoint + finally: + relay_mod._relays.pop("r1", None) + + +@pytest.mark.asyncio +async def test_a_captured_relay_registration_is_not_replayable(client): + """Same reason /v1/nodes/announce bounds its timestamp.""" + from meshbay_hub.api import relay as relay_mod + + sk = Ed25519PrivateKey.generate() + pk = pk_to_b64(sk.public_key()) + relay_mod._relays["r2"] = {"pk": pk, "active": False} + try: + ts = int(time.time()) - 3600 + sig = sk.sign(f"meshbay:relay_register:r2:203.0.113.5:4433:{ts}".encode()) + r = await client.post("/v1/relays/register", json={ + "relay_id": "r2", "endpoint": "203.0.113.5:4433", "pk_relay": pk, + "timestamp": ts, "signature": base64.b64encode(sig).decode()}) + assert r.status_code == 401, r.text + finally: + relay_mod._relays.pop("r2", None) |