summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/relay.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/relay.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/relay.py51
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,