diff options
| -rw-r--r-- | CLAUDE.md | 2 | ||||
| -rw-r--r-- | docs/MESHBAY_DESIGN.md | 25 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/federation.py | 49 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/hub.py | 7 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_federation.py | 41 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_public_groups_toggle.py | 13 |
6 files changed, 133 insertions, 4 deletions
@@ -782,7 +782,7 @@ here are kept only where they are a rule about *editing* the code. | Signaling relay | `meshbay_hub/api/signaling.py` | §7.2 | | Groups, membership, presence, public-group quota | `meshbay_hub/api/groups.py` | §7.3 | | Admin API, instance policy, moderation | `meshbay_hub/api/admin.py`, `hub.py` | §7.4, §7.5 | -| Notifications, federation, relays, reports | `meshbay_hub/api/notifications.py`, `federation.py`, `relay.py`, `moderation.py` | §7.6 | +| Notifications, federation, relays, reports | `meshbay_hub/api/notifications.py`, `federation.py`, `relay.py`, `moderation.py` | §7.6. **Federation is off**: `federation.FEDERATION_ENABLED` is False and every MHP route answers 503, because no two hubs have ever completed a request between them. Its tests open the gate for themselves; `relay.py` is called by nothing in the tree at all | | Asset versioning | `meshbay_hub/api/webapp.py` — `_asset_version()` | the whole module graph is served under `/a/<hash>/`, and the hash covers **every file under `static/`**, subdirectories included — nothing to register | | Token lifetimes | `meshbay_hub/config.py` — `[jwt]` | 4 h access, 30 days refresh. **Production sets both in `~/.config/meshbay/hub.toml`** — changing the code default alone does nothing there | diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 32d688b..a511093 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -1687,7 +1687,29 @@ operationally. ### 7.6 Federation (MHP) -Peer hubs exchange directory rows and revocations. The trust rules: +> **Federation is closed in the code, and every MHP route refuses with a stated +> 503.** `federation.FEDERATION_ENABLED` is the only thing that decides it — a +> constant rather than a setting, because a switch in an admin panel invites an +> operator to turn on something that has never worked between two machines. +> `/v1/hub/info` reports it, since the `mhp_version` beside it would otherwise +> be a claim this hub does not honour. +> +> The reason is not the design below. It is that **nothing has ever run it**: +> two hubs have never completed one authenticated request between them +> (**AV14** — the issuer signed with a key bound before it was loaded and named +> itself after the reference deployment whatever it was called, and the verifier +> named no audience for the `aud` the issuer sets). Both were found by reading, +> and both stood for a month behind a green suite, because a second +> implementation of a peer proves the protocol and nothing about two machines — +> the sentence §12 already writes about a second implementation of the client. +> It re-opens when a second hub has been stood up and the exchange run both +> ways. +> +> What the closure does not touch: the public directory still reads whatever +> `federated_groups` holds, which is nothing, because nothing can arrive. + +Peer hubs exchange directory rows and revocations. The trust rules, which hold +when it re-opens: - a pushed row's **source is bound to the signer**, not taken from the payload; - the **token audience is checked**; @@ -2758,6 +2780,7 @@ account recovery, and the Windows port through packaging. | — | Tier 3 roster attestation (§3.3) | | — | Playlists (§9.10) | | — | Android client | +| — | **Federation between two hubs.** The protocol is written and switched off in the code (§7.6); what is not built is one run between two machines | ### 15.3 Open, and why each is where it is 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") |