diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-01 19:50:22 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-01 19:50:22 +0200 |
| commit | 24d29e910c8d1649a1cd51969f27b921b94d97e3 (patch) | |
| tree | be378f126fcc41fa9c4f12bc0a7426ca563bbb25 /packages/meshbay-hub/tests/test_federation.py | |
| parent | 9ee9d7dfca50a64257163236f11b3cd58021963f (diff) | |
| download | meshbay-24d29e910c8d1649a1cd51969f27b921b94d97e3.tar.gz | |
fix(hub): constrain what a federated peer hub can do (MHP)
A registered peer was trusted with more than "advertise your own
public groups":
- `receive_directory` set `source_hub` from `body.hub_id`, so a peer
could relay or spoof a third hub's groups into our directory. It is
now bound to the token's verified `iss`. The push is also capped
(500 groups/request, 2000/peer), rows are type- and length-checked,
and a federated id that collides with a local group is refused so it
cannot shadow one.
- `receive_revocation` forwarded the peer's token to local nodes,
which reject a token signed by another hub's key — a silent no-op,
and there is no local node hosting a federated group anyway. It now
verifies the inner token against the sending peer's key and, for
`target == "group"`, prunes our copy of the peer's directory entry
when `source_hub` matches. A peer cannot revoke our users or a group
it did not advertise.
- The state-changing endpoints (`POST /mhp/directory`, `/mhp/revoke`)
now reject a replayed `jti` within the token's TTL. Audience binding
is unavailable — the sending side that would set `aud` is unbuilt —
and this covers the replay concern in its place; the idempotent
`GET /mhp/directory` is not affected.
Third security review, finding M4.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
Diffstat (limited to 'packages/meshbay-hub/tests/test_federation.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_federation.py | 179 |
1 files changed, 179 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_federation.py b/packages/meshbay-hub/tests/test_federation.py new file mode 100644 index 0000000..6035b0c --- /dev/null +++ b/packages/meshbay-hub/tests/test_federation.py @@ -0,0 +1,179 @@ +""" +MHP federation — what a registered peer hub may and may not do. + +A peer is trusted enough to advertise its own public groups into our directory +and to withdraw them. It is not trusted to speak for a third hub, to shadow a +local group, to revoke our users, or to replay a state-changing request. +""" + +import base64 +import hashlib +import time +import uuid + +import jwt +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_hub.api.deps import set_admin_usernames + + +def _auth_key(password: str, username: str) -> str: + salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest() + return base64.b64encode( + hashlib.pbkdf2_hmac("sha512", password.encode(), salt, 600_000, 32)).decode() + + +async def _admin(client, username="root"): + pw = "a-long-enough-passphrase" + await client.post("/v1/users/register", json={ + "username": username, "email": f"{username}@example.com", + "auth_key": _auth_key(pw, username)}) + set_admin_usernames([username]) + r = await client.post("/v1/users/login", json={ + "username": username, "auth_key": _auth_key(pw, username)}) + return {"Authorization": f"Bearer {r.json()['access_token']}"} + + +class Peer: + def __init__(self, hub_id: str): + self.hub_id = hub_id + self._sk = Ed25519PrivateKey.generate() + self.pk_pem = self._sk.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo).decode() + + def _sk_pem(self) -> bytes: + return self._sk.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, + serialization.NoEncryption()) + + def envelope(self, jti: str | None = None) -> str: + now = int(time.time()) + return jwt.encode( + {"iss": self.hub_id, "sub": self.hub_id, + "jti": jti or str(uuid.uuid4()), "iat": now, "exp": now + 300}, + self._sk_pem(), algorithm="EdDSA") + + def revocation(self, target: str, target_id: str) -> str: + return jwt.encode( + {"type": "revocation", "target": target, "target_id": target_id, + "iss": self.hub_id, "iat": int(time.time())}, + self._sk_pem(), algorithm="EdDSA") + + def header(self, **kw) -> dict: + return {"Authorization": f"Bearer {self.envelope(**kw)}"} + + +async def _register_peer(client, admin, peer: Peer): + r = await client.post("/mhp/peers", headers=admin, json={ + "hub_id": peer.hub_id, "hub_url": f"https://{peer.hub_id}", + "pk_hub_pem": peer.pk_pem}) + assert r.status_code == 201, r.text + + +# ── receive_directory ────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_unknown_peer_is_refused(client): + stranger = Peer("nobody.example") + r = await client.post("/mhp/directory", headers=stranger.header(), + json={"hub_id": "nobody.example", "groups": []}) + assert r.status_code == 401 + + +@pytest.mark.asyncio +async def test_source_hub_is_the_signer_not_the_body(client): + admin = await _admin(client) + peer = Peer("peer-a.example") + await _register_peer(client, admin, peer) + + r = await client.post("/mhp/directory", headers=peer.header(), json={ + "hub_id": "peer-b.example", # claims to relay another hub + "groups": [{"id": "g-1", "name": "Shared", "join_policy": "open"}]}) + assert r.status_code == 202 + + listing = (await client.get("/v1/groups")).json()["groups"] + row = next(g for g in listing if g["id"] == "g-1") + assert row["source"] == "peer-a.example" # the signer, not "peer-b.example" + + +@pytest.mark.asyncio +async def test_a_federated_id_cannot_shadow_a_local_group(client): + admin = await _admin(client) + peer = Peer("peer-a.example") + await _register_peer(client, admin, peer) + + owner = await _admin(client, "owner") + r = await client.post("/v1/groups", headers=owner, json={ + "name": "mine", "visibility": "public", "join_policy": "open"}) + local_id = r.json()["group_id"] + + r = await client.post("/mhp/directory", headers=peer.header(), json={ + "hub_id": peer.hub_id, + "groups": [{"id": local_id, "name": "evil twin", "join_policy": "open"}]}) + assert r.status_code == 202 + assert r.json()["accepted"] == 0 + + +@pytest.mark.asyncio +async def test_a_state_changing_token_cannot_be_replayed(client): + admin = await _admin(client) + peer = Peer("peer-a.example") + await _register_peer(client, admin, peer) + + env = peer.envelope(jti="fixed-jti") + h = {"Authorization": f"Bearer {env}"} + body = {"hub_id": peer.hub_id, + "groups": [{"id": "g-9", "name": "Once", "join_policy": "open"}]} + + assert (await client.post("/mhp/directory", headers=h, json=body)).status_code == 202 + assert (await client.post("/mhp/directory", headers=h, json=body)).status_code == 401 + + +# ── receive_revocation ───────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_a_peer_may_withdraw_its_own_group(client): + admin = await _admin(client) + peer = Peer("peer-a.example") + await _register_peer(client, admin, peer) + + await client.post("/mhp/directory", headers=peer.header(), json={ + "hub_id": peer.hub_id, + "groups": [{"id": "g-77", "name": "Bye", "join_policy": "open"}]}) + assert any(g["id"] == "g-77" for g in (await client.get("/v1/groups")).json()["groups"]) + + r = await client.post("/mhp/revoke", headers=peer.header(), + json={"token": peer.revocation("group", "g-77")}) + assert r.status_code == 202 and r.json()["pruned"] == 1 + assert not any(g["id"] == "g-77" for g in (await client.get("/v1/groups")).json()["groups"]) + + +@pytest.mark.asyncio +async def test_a_peer_cannot_withdraw_another_hubs_group(client): + admin = await _admin(client) + a, b = Peer("peer-a.example"), Peer("peer-b.example") + await _register_peer(client, admin, a) + await _register_peer(client, admin, b) + + await client.post("/mhp/directory", headers=a.header(), json={ + "hub_id": a.hub_id, + "groups": [{"id": "g-a", "name": "A's", "join_policy": "open"}]}) + + # b signs a revocation for a's group and presents it under b's envelope. + r = await client.post("/mhp/revoke", headers=b.header(), + json={"token": b.revocation("group", "g-a")}) + assert r.status_code == 202 and r.json()["pruned"] == 0 + assert any(g["id"] == "g-a" for g in (await client.get("/v1/groups")).json()["groups"]) + + +@pytest.mark.asyncio +async def test_federation_cannot_revoke_a_user(client): + admin = await _admin(client) + peer = Peer("peer-a.example") + await _register_peer(client, admin, peer) + + r = await client.post("/mhp/revoke", headers=peer.header(), + json={"token": peer.revocation("user", "some-user-id")}) + assert r.status_code == 202 and r.json()["pruned"] == 0 |