diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/groups.py | 14 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/signaling.py | 69 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_availability_between_members.py | 123 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_hub_api.py | 12 |
4 files changed, 191 insertions, 27 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index b903fcf..72ba194 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -124,7 +124,19 @@ async def group_online_nodes( # But when the hub has public groups switched off, an existing one keeps # working only for the people already in it: no non-member gets handed a # node to connect to. Members always have a row here, so they are unaffected. - if group.visibility == "public" and not await hub_settings.public_groups_allowed(db): + # + # A **private** group hands its node list to members and to nobody else. It + # used to answer any authenticated account that knew the id — which an + # ex-member knows for ever — with the ids and public keys of the machines + # hosting it. That is the "registered hub user with no membership" of §2.1 + # reaching an endpoint that did not check membership, and §7.4 already + # states the property for the public case: a non-member is handed no node. + # + # Nothing legitimate needs this before joining. An open join writes the + # membership row first (`POST /{id}/join`), and an invitation registers the + # invitee's membership when the code is created — so by the time either asks + # for a node, the row exists. + if group.visibility != "public" or not await hub_settings.public_groups_allowed(db): if not await db.get(GroupMember, (group_id, current_user.id)): raise HTTPException(status_code=403, detail="Not a member of this group") diff --git a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py index b4be3f2..bc03b45 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py @@ -103,32 +103,49 @@ async def webrtc_offer( # connection to a node just because it happens to host an open group. Members # of that group are unaffected — they match `shared` below. node_group_ids = set(_node_groups.get(node_id, [])) - if node_group_ids: - result = await db.execute( - select(GroupMember.group_id).where( - GroupMember.user_id == current_user.id, - GroupMember.group_id.in_(node_group_ids), - )) - shared = [gid for (gid,) in result.all()] - if not shared: - has_open = None - if await hub_settings.public_groups_allowed(db): - has_open = (await db.execute( - select(Group.id).where( - Group.id.in_(node_group_ids), - Group.join_policy == "open", - Group.status == "active", - ))).first() - if not has_open: - raise HTTPException(status_code=403, detail="Not a member of any group on this node") - else: - statuses = set((await db.execute( - select(Group.status).where(Group.id.in_(shared)))).scalars().all()) - if "active" not in statuses: - # Report the strongest state present — "revoked" is the signed, - # node-enforced one; "suspended" is the reversible hub flag. - state = "revoked" if "revoked" in statuses else next(iter(statuses), "suspended") - raise HTTPException(status_code=403, detail=f"Group is {state}") + # A node registered for no group shares no group with anybody, which is this + # check's own answer — and `if node_group_ids:` used to skip the whole thing, + # membership, group status and the public-group gate together. Since AV1 made + # an empty claim mean "no groups" rather than "all of my owner's", that is + # the *normal* registration of a node hosting nothing: exactly the + # unconfigured node left running that took a group down on 2026-09-11. So the + # machine least able to defend itself was the one any authenticated account + # could make allocate a peer connection and gather ICE, which is H6 restored + # in the one case AV1 made common. + # + # Nothing legitimate is lost by refusing here: a browser cannot complete a + # handshake with such a node anyway — `group_id` is mandatory (M1) and a node + # holding no group key refuses outright (NS8) — so this only declines work + # the node would decline one step later, at its own expense. + if not node_group_ids: + raise HTTPException(status_code=403, + detail="Not a member of any group on this node") + + result = await db.execute( + select(GroupMember.group_id).where( + GroupMember.user_id == current_user.id, + GroupMember.group_id.in_(node_group_ids), + )) + shared = [gid for (gid,) in result.all()] + if not shared: + has_open = None + if await hub_settings.public_groups_allowed(db): + has_open = (await db.execute( + select(Group.id).where( + Group.id.in_(node_group_ids), + Group.join_policy == "open", + Group.status == "active", + ))).first() + if not has_open: + raise HTTPException(status_code=403, detail="Not a member of any group on this node") + else: + statuses = set((await db.execute( + select(Group.status).where(Group.id.in_(shared)))).scalars().all()) + if "active" not in statuses: + # Report the strongest state present — "revoked" is the signed, + # node-enforced one; "suspended" is the reversible hub flag. + state = "revoked" if "revoked" in statuses else next(iter(statuses), "suspended") + raise HTTPException(status_code=403, detail=f"Group is {state}") if _pending_per_user.get(current_user.id, 0) >= MAX_PENDING_PER_USER: raise HTTPException(status_code=429, detail="Too many pending connections") diff --git a/packages/meshbay-hub/tests/test_availability_between_members.py b/packages/meshbay-hub/tests/test_availability_between_members.py index 282169d..bfbfcc0 100644 --- a/packages/meshbay-hub/tests/test_availability_between_members.py +++ b/packages/meshbay-hub/tests/test_availability_between_members.py @@ -518,3 +518,126 @@ async def test_a_list_cannot_be_asked_for_the_whole_table(client): r = await client.get("/v1/notifications?limit=20", headers=headers) assert r.status_code == 200, r.text + + +# ── A node that hosts nothing is not a free target ────────────────────────── + +@pytest.mark.asyncio +async def test_a_node_hosting_nothing_is_not_brokered_to_a_stranger(client): + """ + Signaling read its membership check as `if node_group_ids:` — so when the + set was empty, the membership check, the group-status check and the + public-group gate were all skipped and the offer was relayed. + + Since AV1, an empty claim is the *normal* registration of a node that hosts + nothing: the unconfigured node left running, which is the machine in this + register's founding incident. Each offer makes it allocate an + `RTCPeerConnection` and gather ICE, which is finding H6 restored in exactly + the case AV1 made common — and the stranger paying nothing for it. + """ + from meshbay_hub.api import revocation as rev + + owner = await _make_user(client, "av_sig_owner") + stranger = await _make_user(client, "av_sig_stranger") + node_id = await _announce_node(client, owner) + + class _FakeWS: + def __init__(self): + self.sent = [] + + async def send_text(self, text): + self.sent.append(text) + + ws = _FakeWS() + rev._connected_nodes[node_id] = ws + rev._node_groups[node_id] = [] # hosts nothing, as in the incident + try: + r = await client.post(f"/v1/nodes/{node_id}/webrtc/offer", + json={"sdp": "v=0\r\noffer", "ice_candidates": []}, + headers={"Authorization": f"Bearer {stranger['token']}"}) + assert r.status_code == 403, r.text + assert not ws.sent, ( + "the node was made to negotiate for someone with no group on it") + finally: + rev._connected_nodes.pop(node_id, None) + rev._node_groups.pop(node_id, None) + + +@pytest.mark.asyncio +async def test_a_member_still_reaches_the_node_they_share_a_group_with(client): + """The other half, so the test above is about the claim and not about + refusing everyone.""" + from meshbay_hub.api import revocation as rev + + owner = await _make_user(client, "av_sig_owner2") + member = await _make_user(client, "av_sig_member2") + group_id = await _make_group(client, owner, "shared-one") + await _add_member(client, owner, group_id, member) + node_id = await _announce_node(client, owner) + + answered = [] + + class _AnsweringWS: + async def send_text(self, text): + import json as _json + from meshbay_hub.api.signaling import handle_webrtc_answer + msg = _json.loads(text) + answered.append(msg) + handle_webrtc_answer({"peer_id": msg["peer_id"], "sdp": "v=0\r\nanswer", + "ice_candidates": []}, node_id) + + rev._connected_nodes[node_id] = _AnsweringWS() + rev._node_groups[node_id] = [group_id] + try: + r = await client.post(f"/v1/nodes/{node_id}/webrtc/offer", + json={"sdp": "v=0\r\noffer", "ice_candidates": []}, + headers={"Authorization": f"Bearer {member['token']}"}) + assert r.status_code == 200, r.text + assert answered + finally: + rev._connected_nodes.pop(node_id, None) + rev._node_groups.pop(node_id, None) + + +# ── A private group's hosts are not public knowledge ──────────────────────── + +@pytest.mark.asyncio +async def test_a_private_groups_node_list_is_for_its_members(client): + """ + `GET /v1/groups/{id}/nodes` checked membership only for a public group with + public groups switched off. A private group answered any authenticated + account that knew the id — which an ex-member knows for ever — with the ids + and public keys of the machines hosting it. + + §7.4 already states the property for the public case: a non-member is handed + no node to connect to. This is that sentence, for the groups the whole + design optimises for. + """ + from meshbay_hub.api import revocation as rev + + owner = await _make_user(client, "av_nodes_owner") + member = await _make_user(client, "av_nodes_member") + stranger = await _make_user(client, "av_nodes_stranger") + group_id = await _make_group(client, owner, "private-hosts") + await _add_member(client, owner, group_id, member) + node_id = await _announce_node(client, owner) + + rev._node_groups[node_id] = [group_id] + rev._connected_nodes[node_id] = object() + try: + for who in (owner, member): + r = await client.get( + f"/v1/groups/{group_id}/nodes", + headers={"Authorization": f"Bearer {who['token']}"}) + assert r.status_code == 200, r.text + assert [n["node_id"] for n in r.json()["nodes"]] == [node_id] + + r = await client.get( + f"/v1/groups/{group_id}/nodes", + headers={"Authorization": f"Bearer {stranger['token']}"}) + assert r.status_code == 403, ( + "a stranger who knows the group id learned which machines host it: " + + r.text) + finally: + rev._connected_nodes.pop(node_id, None) + rev._node_groups.pop(node_id, None) diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py index 5bb6af9..73f7fea 100644 --- a/packages/meshbay-hub/tests/test_hub_api.py +++ b/packages/meshbay-hub/tests/test_hub_api.py @@ -682,6 +682,17 @@ async def test_webrtc_signaling_roundtrip(client, app): fake_ws = FakeWS(node_id) _connected_nodes[node_id] = fake_ws + # The caller must share an active group with the node, which is what this + # test used to get away without: a node registered for no group skipped the + # membership check entirely, so this exercised the relay through the hole + # rather than through the door. Registering the group is what a real node + # does on its socket. + from meshbay_hub.api.revocation import _node_groups + r = await client.post("/v1/groups", json={"name": "sig-group"}, + headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 201, r.text + _node_groups[node_id] = [r.json()["group_id"]] + try: r = await client.post(f"/v1/nodes/{node_id}/webrtc/offer", json={"sdp": "v=0\r\noffer-sdp", "ice_candidates": []}, @@ -693,6 +704,7 @@ async def test_webrtc_signaling_roundtrip(client, app): assert "peer_id" in data finally: _connected_nodes.pop(node_id, None) + _node_groups.pop(node_id, None) # ── IP log cleanup (8.9) ──────────────────────────────────────────────────── |