diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-13 22:14:47 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-13 22:14:47 +0200 |
| commit | 413837a0845240241ed7e9d9ac1f3b1dc45a2f40 (patch) | |
| tree | 842eb464b141be64a0576564b8cfe64e8090761c /packages | |
| parent | 532d762678e0eda7ac08fcc6a361069114f12d79 (diff) | |
| download | meshbay-413837a0845240241ed7e9d9ac1f3b1dc45a2f40.tar.gz | |
fix(hub): federation is closed until two hubs have run it
Every MHP route answers a stated 503. `federation.FEDERATION_ENABLED` is the
only thing that decides it, applied as a dependency on the router so the six
routes that exist and any added later are covered by construction — a gate you
have to remember to write in each handler is the shape C6 is the standing
lesson about.
The protocol is not what is wrong with it. What is wrong is that nothing has
ever run it: two hubs have never completed one authenticated request between
them. AV14 was two defects in the same path — an issuer signing with a key
bound before it was loaded, naming itself after the reference deployment
whatever the instance was called, and a verifier naming no audience for the
`aud` the issuer sets, which PyJWT refuses outright. Both stood for a month
behind a green suite, and both were found by reading rather than by running,
because a second implementation of a peer proves the protocol and nothing about
two machines.
Four of the six routes carry no authentication of their own — the MHP token is
the authentication — and two of those write, a directory push and a revocation.
That is the surface being closed until somebody stands up a second hub.
A constant and not a `hub_settings` row, deliberately: a switch in the admin
panel invites an operator to turn on a feature that has never worked between
two machines, where this takes an edit, a deploy, and reading the comment above
it. `/v1/hub/info` reports the state, because the `mhp_version` beside it would
otherwise be a claim the hub does not honour.
The protocol tests open the gate for their own duration and say why; the one
that runs with the flag as it ships asserts all six routes refuse. §7.6 states
the closure, §15.2 carries federation between two hubs as not built.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMxEQadpzPkYLFf5CYKhpW
Diffstat (limited to 'packages')
4 files changed, 108 insertions, 2 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/federation.py b/packages/meshbay-hub/src/meshbay_hub/api/federation.py index 327102e..9e252c6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/federation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/federation.py @@ -40,7 +40,54 @@ from meshbay_hub.db.models import FederatedGroup, Group, HubPeer, User log = logging.getLogger(__name__) -router = APIRouter(prefix="/mhp", tags=["federation"]) +# ── Federation is closed ────────────────────────────────────────────────────── +# +# **Every MHP endpoint refuses, and this flag is the only thing that decides it.** +# +# Not because the protocol is wrong, but because nothing has ever run it. Two +# hubs have never completed a single authenticated request between them: until +# 2026-09-12 `_issue_mhp_token` bound the hub's signing key at import — before +# `load_hub_keypair` runs — so this hub signed with `None` and called itself +# `meshbay.org` whatever it was named, while the verifier named no audience for +# the `aud` the issuer sets, which PyJWT refuses outright (**AV14**). Both were +# found by reading, and they were found because a test had written down, in its +# own words, that the real issuer "cannot be used from a test" and had signed +# its own tokens instead. What else is in here of that shape is not known, and +# the way to know is to stand up a second hub — not to leave the door open +# meanwhile. +# +# The surface being closed is worth naming: four of the six routes carry no +# authentication of their own (the MHP token *is* the authentication), two of +# those write — a directory push and a revocation — and every one of them is +# reachable by anyone who can reach the hub. +# +# **A constant and not a setting, deliberately.** A row in `hub_settings` and a +# switch in the admin panel would invite an operator to turn on a feature that +# has never worked between two machines. This takes an edit and a deploy, by +# somebody who has read this. The refusal is a stated 503 rather than a 404 +# because a peer hub deserves a reason it can act on, which is §5.6's rule for +# the wire one level up. +# +# **To re-open it:** set this True, stand up a second hub, and run the exchange +# both ways. `test_federation.py` covers the protocol and proves nothing about +# two machines; §15.2 carries federation as not built until that has happened. +FEDERATION_ENABLED = False + + +def _federation_open() -> None: + """Refuse every route on this router while federation is closed. + + A router dependency rather than a line in each handler: it covers the six + routes that exist and every one anybody adds later. A gate you have to + remember to write is the shape **C6** is the standing lesson about. + """ + if not FEDERATION_ENABLED: + raise HTTPException(status_code=503, + detail="Federation is not enabled on this hub") + + +router = APIRouter(prefix="/mhp", tags=["federation"], + dependencies=[Depends(_federation_open)]) # One push may not dump the world, and one peer may not fill the table. MAX_FEDERATED_GROUPS_PER_PUSH = 500 diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py index a48313d..5a3eb4b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py @@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from meshbay_common import MNP_VERSION, MHP_VERSION from meshbay_hub import __version__, hub_settings +from meshbay_hub.api import federation from meshbay_hub.auth import hub_public_key_pem from meshbay_hub.config import HubConfig from meshbay_hub.db.engine import get_db, get_engine @@ -32,6 +33,12 @@ async def hub_info(db: AsyncSession = Depends(get_db)): # reachable before the group list loads. The hub enforces it regardless # of what any client does with this flag. "allow_public_groups": await hub_settings.public_groups_allowed(db), + # Stated, because `mhp_version` above is otherwise a claim this hub does + # not honour: every MHP route refuses while federation is closed (see + # `api/federation.py`). A peer finds out at `/mhp/info` either way, + # which answers 503 — this is the same answer somewhere an operator can + # read it without opening the source. + "federation": federation.FEDERATION_ENABLED, "captcha_site_key": _cfg.captcha.site_key if _cfg and _cfg.captcha.enabled else "", } diff --git a/packages/meshbay-hub/tests/test_federation.py b/packages/meshbay-hub/tests/test_federation.py index 54f86a7..17d4161 100644 --- a/packages/meshbay-hub/tests/test_federation.py +++ b/packages/meshbay-hub/tests/test_federation.py @@ -4,6 +4,17 @@ 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. + +**Federation is switched off in the code** (`federation.FEDERATION_ENABLED`), +so every route answers 503 as the hub ships. These tests open it for their own +duration and exercise the protocol underneath, which is what will be wanted the +day it is re-opened — and they are also the reason it is closed. They pass, and +they passed while two hubs could not complete one authenticated request between +them: a second implementation of a peer proves the protocol and nothing about +two machines, which is the same sentence this repo already writes about a +second implementation of the client. + +The one test that runs with the gate as it ships is the first one below. """ import base64 @@ -18,6 +29,36 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from meshbay_hub.api.deps import set_admin_usernames +@pytest.fixture(autouse=True) +def _federation_open(monkeypatch): + """Open the gate for the protocol tests, and only for them.""" + from meshbay_hub.api import federation + monkeypatch.setattr(federation, "FEDERATION_ENABLED", True) + + +async def test_every_mhp_route_is_closed_as_the_hub_ships(client, monkeypatch): + """The gate itself, with the flag as it ships. + + Four of these six take no authentication of their own — the MHP token is + the authentication — and two of them write. A refusal that has to be + written into each handler is one somebody adds a route without; this is a + dependency on the router, so a route added later is closed before it is + written. + """ + from meshbay_hub.api import federation + monkeypatch.setattr(federation, "FEDERATION_ENABLED", False) + + for method, path in (("get", "/mhp/info"), + ("get", "/mhp/directory"), + ("post", "/mhp/directory"), + ("post", "/mhp/revoke"), + ("get", "/mhp/peers"), + ("post", "/mhp/peers")): + r = await getattr(client, method)(path, **({} if method == "get" else {"json": {}})) + assert r.status_code == 503, f"{method.upper()} {path}: {r.status_code}" + assert "not enabled" in r.text + + def _auth_key(password: str, username: str) -> str: salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest() return base64.b64encode( diff --git a/packages/meshbay-hub/tests/test_public_groups_toggle.py b/packages/meshbay-hub/tests/test_public_groups_toggle.py index e52fa85..2a57429 100644 --- a/packages/meshbay-hub/tests/test_public_groups_toggle.py +++ b/packages/meshbay-hub/tests/test_public_groups_toggle.py @@ -228,7 +228,18 @@ def _mhp_token(hub_id): @pytest.mark.asyncio -async def test_disabled_empties_the_federation_export(client): +async def test_disabled_empties_the_federation_export(client, monkeypatch): + """What the export advertises, for the day federation re-opens. + + MHP is switched off in the code and every route answers 503 + (`federation.FEDERATION_ENABLED`, §7.6), so this opens the gate for its own + duration. The subject is the public-groups switch, not federation: with + public groups off, the export must advertise nothing, and that has to stay + true whether the door is open today or not. + """ + from meshbay_hub.api import federation + monkeypatch.setattr(federation, "FEDERATION_ENABLED", True) + admin = await _admin(client) owner = await _user(client, "ivan") await _create_public(client, owner, "exported-square") |