From 609003e907e66e951db840e800fca77322bbde99 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 28 Sep 2026 23:10:48 +0200 Subject: refactor(hub): remove the closed relay registry Every /v1/relays route answered 503 and nothing called them; no TURN relay is needed. The proof-of-possession rule it carried stays as AV6. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- docs/MESHBAY_DESIGN.md | 3 +- packages/meshbay-hub/src/meshbay_hub/api/relay.py | 166 --------------------- packages/meshbay-hub/src/meshbay_hub/app.py | 2 - .../tests/test_availability_between_members.py | 89 ----------- .../tests/test_unauthenticated_surface.py | 2 - 6 files changed, 2 insertions(+), 262 deletions(-) delete mode 100644 packages/meshbay-hub/src/meshbay_hub/api/relay.py diff --git a/CLAUDE.md b/CLAUDE.md index 81bd883..cc79d43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -961,7 +961,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. **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. **Relays are closed the same way** (`relay.RELAYS_ENABLED`): nothing in the tree calls them | +| Notifications, federation, reports | `meshbay_hub/api/notifications.py`, `federation.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 | | Sign-in lockout | `meshbay_hub/login_throttle.py`, settings in `hub_settings.py` (`login.*`) | §7.7. **Every path that checks a passphrase calls `_take_login_attempt` first** — login, passphrase change, account deletion | | What answers without an account | `tests/test_unauthenticated_surface.py` — `PUBLIC` | §7.4. A new open route fails the suite until it is listed there with its reason | | Asset versioning | `meshbay_hub/api/webapp.py` — `_asset_version()` | the whole module graph is served under `/a//`, and the hash covers **every file under `static/`**, subdirectories included — nothing to register | diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 06ba2b5..2efedca 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -3310,7 +3310,7 @@ had already been asked. | **AV3** | **A node speaks only for the groups it is registered for.** `chat_notify` names a group and is checked against that node's set before a notification is written for anyone, and it is rate-limited per node — the fan-out is one write per member | | **AV4** | **Nobody names a third party's address.** Where a peer is comes from its node record, stamped with the address its announce arrived from | | **AV5** | **An answer is accepted only from the node the offer was sent to.** A `peer_id` is bound to its node, so no connected node can resolve another's pending offer | -| **AV6** | **A relay proves possession of its approved key.** A public key is not a password, and the register call is unauthenticated by design — it is not a user — so the proof is the only thing standing between a stranger and where nodes send relayed traffic | +| **AV6** | **A write that decides where other people's traffic goes proves possession of a key, never presents one.** A public key is not a password. The relay registry this was written for has been removed — nothing called it, and no TURN relay is needed (§11.1) — and the rule stands for whatever replaces it | | **AV7** | **A node bounds how many peers it holds and how long an unproven one lasts.** The hub's cap is per calling account, which is a limit on each member and not on the machine, so without this an operator's exposure grew with the size of their groups | | **AV8** | **One account cannot make the hub mail another at will.** The invitation email's subject comes from the group row, never from the request, and the endpoint is metered | | **AV9** | **No mail is sent from the event loop.** `smtplib` is synchronous and waits up to ten seconds; called from an async handler that wait is the whole instance's, not one request's. Every send goes through `mail.send_off_loop`. **Argon2 is held to the same rule**: every derivation runs on one dedicated worker thread (`auth.*_off_loop`), never on the loop and never two at a time, because two concurrent `lanes=4` derivations deadlock in OpenSSL. **So is the node's disk**: every filesystem call on a group's content — the stat as much as the read, since a stat is what wakes a sleeping disk — goes through `roots.off_disk`, onto one worker thread per root set. A spun-down or network-mounted root answers its first syscall in seconds, and on the loop that is every group, every stream and the hub socket waiting for a platter. **ffmpeg's own output too**, through `asyncio.to_thread` rather than that per-root thread: a temp file is not a group root and has no platter to serialise against, but a whole transcode read inline is still tens of megabytes of blocking read | @@ -3505,7 +3505,6 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows. | Forward secrecy in group chat | **Given up deliberately and on the record** (§4.5). If it becomes a requirement it belongs in 1:1 DM | | Metadata at the hub | Membership, and who posted in which group and when. A known leak, not a solved problem (§7.1) | | **QUIC** | Off by default, and **not at parity**: it serves the index and file chunks with no transfer lease, no leaseless ceiling, no root-availability check and no content blocklist, does its file I/O on the event loop, and returns exception text to the peer (**L3**). No client speaks it. Either it comes to parity or it goes; until then §5.1's "chat is the only gap" is the one sentence here that overstates the code | -| **The relay registry** | **Closed in the code**: `relay.RELAYS_ENABLED` is False and every `/v1/relays` route answers 503, as federation does. Nothing in the tree calls them, node or client, and §11.1 measured two ISPs with no TURN relay needed. Kept code that nothing calls is what **L7** says not to keep; it stays only as the proof-of-possession design (**AV6**) until a node needs a relay or it is deleted | | **A very high bitrate wedges the player against a small buffer ceiling** | Where even the *floor* read-ahead does not fit — ninety seconds plus the minute kept behind, at the file's bitrate, above what the engine will hold — the film stalls: measured on the harness at 9.3 Mbit/s against a 100 MB ceiling, 100.8 s of film played in 900 s of wall clock. **Predates the byte budget and is unchanged by it**, to the tenth of a second; what the budget did change there is the refusal count, 1560 → 2. The fix is not a bound at all, it is a second stage of buffer outside the SourceBuffer, which means gating the append path — the riskiest change in this area and not one to make alongside another | | **The reconnect backoff only wakes on `visibilitychange`** | So a tab that stays visible through an outage — which is what a screen wake lock guarantees while a film is playing — waits out the full backoff, up to 30 s, after the network is already back. Nothing listens for `online` | | **Per-device revocation has no CLI** | A device is revoked over MNP (`roster.revoke_device`), from a device the node has already pinned. On a headless node the operator's only lever is `member unpin`, which removes **every** device of that account — so the per-device control the roster is built around is reachable from an interface and from nowhere else. §6.7 listed a `meshbay-node member device list\|revoke` verb that was never written, and that listing is how this was found: `USERGUIDE.md` was the first document written by reading the CLI rather than this specification, and the verb it copied out did not run | diff --git a/packages/meshbay-hub/src/meshbay_hub/api/relay.py b/packages/meshbay-hub/src/meshbay_hub/api/relay.py deleted file mode 100644 index 7bb3f66..0000000 --- a/packages/meshbay-hub/src/meshbay_hub/api/relay.py +++ /dev/null @@ -1,166 +0,0 @@ -""" -MeshBay Hub — Mesh Relay registration protocol (5.3). - -Community-operated TURN relays register with hubs. -Nodes query the hub for available relays when UDP hole punching fails. - -Relay registration: - POST /v1/relays/register — relay announces itself (signed JWT) - GET /v1/relays — list active relays (for nodes) - -Relay authentication: relay generates an Ed25519 keypair at install time. An -admin approves the public key, and every register call carries an Ed25519 -signature over "meshbay:relay_register:::" — -the same proof-of-possession shape as /v1/nodes/announce. - -Relay is responsible for E2E encrypted QUIC traffic only (it cannot -read the application-layer content, only forward UDP packets). -""" - -import base64 -import logging -import time - -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel -from sqlalchemy.ext.asyncio import AsyncSession - -from meshbay_hub.api.deps import require_admin -from meshbay_hub.db.engine import get_db -from meshbay_hub.db.models import User - -log = logging.getLogger(__name__) - -# **Closed, the same way and for a similar reason as federation.** Nothing in the -# tree calls these routes — no node asks for a relay, no client offers one — and -# §11.1 measured two ISPs with no TURN relay needed. Two of the three take no -# account and answer anyone who can reach the hub, so a registry nothing uses -# was an unauthenticated surface kept for its own sake. A constant, not a -# setting: re-opening it means building the node side first, then flipping this. -RELAYS_ENABLED = False - - -def _relays_open() -> None: - """Refuse every route on this router while the registry is closed. - - On the router rather than in each handler, so a route added later is closed - before anybody remembers to write the check (C6). - """ - if not RELAYS_ENABLED: - raise HTTPException(status_code=503, - detail="The relay registry is not enabled on this hub") - - -router = APIRouter(prefix="/v1/relays", tags=["relay"], - dependencies=[Depends(_relays_open)]) - -# In-memory relay registry (production: DB table) -_relays: dict[str, dict] = {} # relay_id → {endpoint, pk, last_seen, capacity} - - -# ── Models ──────────────────────────────────────────────────────────────────── - -class RelayRegisterRequest(BaseModel): - """Relay self-registers, proving possession of its approved key.""" - relay_id: str - endpoint: str # "ip:port" (UDP) - pk_relay: str # base64 Ed25519 public key - capacity: int = 100 # max concurrent connections - timestamp: int | None = None # unix seconds - signature: str | None = None # base64 Ed25519 over the register message - - -class RelayAdminApproveRequest(BaseModel): - relay_id: str - pk_relay: str # admin approves by registering the relay's public key - - -# ── Relay endpoints ─────────────────────────────────────────────────────────── - -REGISTER_TIMESTAMP_WINDOW = 300 # seconds either side, as /v1/nodes/announce - - -@router.post("/register", status_code=201) -async def relay_register( - body: RelayRegisterRequest, - db: AsyncSession = Depends(get_db), -): - """ - Relay announces itself. Must be pre-approved by a hub admin, and must prove - it holds the private key that approval registered. - - This endpoint has no `Depends` on an account on purpose — a relay is not a - user — but it had no proof of anything either: it compared `pk_relay` - against the approved value, which is a **public** key, so anyone who could - read it could rewrite where the hub tells nodes to send relayed traffic. - The module docstring said "signs keepalive JWTs" and nothing verified a - signature; `jwt` was imported and never used. A key is not a password, and - the fix is the proof-of-possession pattern already used by - /v1/nodes/announce and /v1/nodes/auth. - """ - approved = _relays.get(body.relay_id) - if not approved or approved.get("pk") != body.pk_relay: - raise HTTPException(status_code=403, - detail="Relay not approved — ask the hub admin to " - "run POST /v1/relays/approve") - - if body.timestamp is None or not body.signature: - raise HTTPException( - status_code=400, - detail="register requires timestamp and signature (proof of possession)") - if abs(int(time.time()) - body.timestamp) > REGISTER_TIMESTAMP_WINDOW: - raise HTTPException(status_code=401, detail="Timestamp too old or too far ahead") - - message = (f"meshbay:relay_register:{body.relay_id}:" - f"{body.endpoint}:{body.timestamp}").encode() - try: - pk = Ed25519PublicKey.from_public_bytes(base64.b64decode(body.pk_relay)) - pk.verify(base64.b64decode(body.signature), message) - except Exception: - log.warning("Relay %s failed proof of possession", body.relay_id[:8]) - raise HTTPException(status_code=401, detail="Invalid relay key proof of possession") - - _relays[body.relay_id].update({ - "endpoint": body.endpoint, - "capacity": body.capacity, - "last_seen": int(time.time()), - "active": True, - }) - log.info("Relay registered: %s at %s", body.relay_id[:8], body.endpoint) - return {"status": "registered", "relay_id": body.relay_id} - - -@router.get("") -async def list_relays(): - """ - List active Mesh Relays. Called by nodes when UDP hole punching fails. - Returns only active relays (seen in the last 5 minutes). - """ - cutoff = int(time.time()) - 300 - active = [ - { - "relay_id": rid, - "endpoint": r["endpoint"], - "capacity": r["capacity"], - } - for rid, r in _relays.items() - if r.get("active") and r.get("last_seen", 0) > cutoff - ] - return {"relays": active, "count": len(active)} - - -@router.post("/approve", status_code=201) -async def admin_approve_relay( - body: RelayAdminApproveRequest, - current_user: User = Depends(require_admin), -): - """Admin: pre-approve a relay by registering its public key.""" - _relays[body.relay_id] = { - "pk": body.pk_relay, - "approved_by": current_user.username, - "approved_at": int(time.time()), - "active": False, # becomes True after first register call - } - log.info("Relay approved by %s: %s", current_user.username, body.relay_id[:8]) - return {"status": "approved", "relay_id": body.relay_id} diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py index 29de118..16a9d4a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/app.py +++ b/packages/meshbay-hub/src/meshbay_hub/app.py @@ -30,7 +30,6 @@ from meshbay_hub.api.middleware import limiter from meshbay_hub.api.moderation import router as moderation_router from meshbay_hub.api.nodes import router as nodes_router from meshbay_hub.api.notifications import router as notifications_router -from meshbay_hub.api.relay import router as relay_router from meshbay_hub.api.revocation import router as revocation_router from meshbay_hub.api.signaling import router as signaling_router from meshbay_hub.api.users import router as users_router @@ -202,7 +201,6 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI: app.include_router(moderation_router) app.include_router(federation_router) app.include_router(health_router) - app.include_router(relay_router) app.include_router(signaling_router) app.include_router(admin_router) app.include_router(notifications_router) diff --git a/packages/meshbay-hub/tests/test_availability_between_members.py b/packages/meshbay-hub/tests/test_availability_between_members.py index 2773d87..e66be31 100644 --- a/packages/meshbay-hub/tests/test_availability_between_members.py +++ b/packages/meshbay-hub/tests/test_availability_between_members.py @@ -271,75 +271,6 @@ async def test_an_invite_email_says_what_the_hub_knows_not_what_it_is_told( "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 - - # The registry ships closed (`relay.RELAYS_ENABLED`); the proof it demands - # is still what will be wanted the day it opens. - monkeypatch.setattr(relay_mod, "RELAYS_ENABLED", True) - 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_every_relay_route_is_closed_as_the_hub_ships(client): - """Nothing in the tree uses the registry, and two of its routes take no account. - - A dependency on the router, so a route added later is closed too. The flag - is read as shipped, not set here — a test that closes the gate itself - would keep passing the day somebody opens it. - """ - admin = await _make_user(client, "relayadmin") - from meshbay_hub.api.deps import set_admin_usernames - set_admin_usernames(["relayadmin"]) - auth = {"Authorization": f"Bearer {admin['token']}"} - - for method, path in (("get", "/v1/relays"), - ("post", "/v1/relays/register"), - ("post", "/v1/relays/approve")): - kwargs = {"headers": auth} if method == "get" else {"json": {}, "headers": auth} - r = await getattr(client, method)(path, **kwargs) - assert r.status_code == 503, (path, r.status_code, r.text) - - @pytest.mark.asyncio async def test_a_stranger_who_locks_your_name_does_not_sign_you_out(client, db_session): """AV26. The sign-in lockout is keyed by username, and usernames are public. @@ -392,26 +323,6 @@ async def test_a_stranger_who_locks_your_name_does_not_sign_you_out(client, db_s assert r.status_code == 200, r.text -@pytest.mark.asyncio -async def test_a_captured_relay_registration_is_not_replayable(client, monkeypatch): - """Same reason /v1/nodes/announce bounds its timestamp.""" - from meshbay_hub.api import relay as relay_mod - - monkeypatch.setattr(relay_mod, "RELAYS_ENABLED", True) - 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) - - # ── Mail: three paths out of the hub, one of them unmetered ────────────────── @pytest.mark.asyncio diff --git a/packages/meshbay-hub/tests/test_unauthenticated_surface.py b/packages/meshbay-hub/tests/test_unauthenticated_surface.py index 78615ca..563dfa8 100644 --- a/packages/meshbay-hub/tests/test_unauthenticated_surface.py +++ b/packages/meshbay-hub/tests/test_unauthenticated_surface.py @@ -42,8 +42,6 @@ PUBLIC = { ("POST", "/v1/nodes/auth"): "node sign-in — Ed25519 signature over a fresh timestamp", ("WS", "/v1/nodes/ws"): "a node-scoped JWT in the first message, within a timeout", ("GET", "/v1/groups"): "the public directory — empty when public groups are off", - ("GET", "/v1/relays"): "closed: 503 while relay.RELAYS_ENABLED is False", - ("POST", "/v1/relays/register"): "closed; when open, approved key + signature", ("GET", "/mhp/info"): "closed: 503 while federation.FEDERATION_ENABLED is False", ("GET", "/mhp/directory"): "closed; when open, an MHP token", ("POST", "/mhp/directory"): "closed; when open, an MHP token", -- cgit v1.2.3