diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/relay.py')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/relay.py | 51 |
1 files changed, 41 insertions, 10 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/relay.py b/packages/meshbay-hub/src/meshbay_hub/api/relay.py index f82495b..c6ef26e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/relay.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/relay.py @@ -8,24 +8,25 @@ 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. -It registers its public key with the hub admin, then signs keepalive JWTs. +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:<relay_id>:<endpoint>:<timestamp>" — +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 -import uuid -import jwt -from fastapi import APIRouter, Depends, Header, HTTPException +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 get_current_user, require_admin -from meshbay_hub.auth import _hub_id, hub_public_key_pem +from meshbay_hub.api.deps import require_admin from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import User @@ -40,11 +41,13 @@ _relays: dict[str, dict] = {} # relay_id → {endpoint, pk, last_seen, capacit # ── Models ──────────────────────────────────────────────────────────────────── class RelayRegisterRequest(BaseModel): - """Relay self-registers with a signed JWT.""" + """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): @@ -54,20 +57,48 @@ class RelayAdminApproveRequest(BaseModel): # ── 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. - The relay's public key must already be in the approved list. + 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 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, |