diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-12 09:47:34 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-12 16:36:54 +0200 |
| commit | bf7ff9ec311660318c8562abe03ef4db62475c98 (patch) | |
| tree | 2fd49c42c59e1207574bcf2842b726e4aec82c90 /packages/meshbay-hub/src/meshbay_hub/api/relay.py | |
| parent | 4cce50f09a73739387d5058a6f8183ebac65ae2c (diff) | |
| download | meshbay-bf7ff9ec311660318c8562abe03ef4db62475c98.tar.gz | |
fix: bound what one member can cost the others
An availability review, prompted by the group claim above: a participant
supplies input — who else bears the cost? Six answers where the cost fell on
someone other than the sender, and none of them needs an attacker.
AV3 `chat_notify` carried a `group_id` the hub believed, so any connected
node could write a notification to every member of any group on the
hub, carrying a display string of its choosing, with its account
having no relation to that group. This is the group claim again, two
hundred lines further down the same socket. Gated on what the node is
registered for, and metered: the fan-out is one write per member. The
budget expires by time rather than on disconnect, or reconnecting
would refill it and a node token is good for an hour.
AV4 A swarm source named its own `endpoint` as free text documented as
"ip:port", so an account could publish a third party's address — H6's
`peer_ip` defect, never applied here. Nothing dials a swarm source
today, which is the only reason it was not already a reflection
primitive. It is a transport and a port now, never a host, and the
number of hashes one account may claim is bounded: rows were keyed
(hash, account) with no cap at all.
AV5 `handle_webrtc_answer` resolved any pending `peer_id` from any node's
socket. The answer is the SDP a browser then connects to. That this
had not happened rested on a uuid4 being unguessable.
AV6 `relay_register` had no authentication of any kind: 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 promised signed JWTs and
`jwt` was imported and never used.
AV7 The node held unlimited peer connections and kept one that never
completed a handshake for the life of the daemon. H6 bounded what one
unauthenticated peer costs; the hub's cap is three offers in flight
per *account*, a limit on each caller and not on the machine, so an
operator's exposure grew with the size of their groups.
AV8 `invite-notify` put a request-supplied `group_name` into the subject
of an email the hub sends under its own domain, to any account, with
no rate limit. The name comes from the group row now.
The tests are two accounts each, in one file that says why: a one-member test
proves a one-member property, and every finding here needed a second person
to exist at all. Each was checked against the unfixed code. Two did not
survive that check and were rewritten — one re-enacted the disconnect path
instead of running it (hence `forget_node`), the other called the reaper
itself and would have passed with the call removed from `handle_offer`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
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, |