diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-10 03:57:55 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-10 03:57:55 +0200 |
| commit | 1a53eb4cc404ec94658fde0ae04cfe2ccf1810dc (patch) | |
| tree | 04a23f81d3eea49de7414bf973600d33913795f9 /packages/meshbay-hub/src/meshbay_hub/api/federation.py | |
| parent | 4b3e8c3b8b9d10c8ac333dd8db614a7569052472 (diff) | |
| download | meshbay-1a53eb4cc404ec94658fde0ae04cfe2ccf1810dc.tar.gz | |
feat(hub): Phase 8 — Hub v2 security hardening + production readiness
8.1 Config-based admin authz (require_admin on all admin endpoints)
8.2 Email encrypted at rest (AES-256-GCM, HKDF from hub Ed25519 key)
8.3 Refresh token rotation with family-based reuse detection
8.4 Federation persistence (HubPeer model replaces in-memory dict)
8.5 Federation token verification now async (DB-backed)
8.6 CSAM hash check wired into swarm registration flow
8.7 Rate limiting on auth endpoints (5/10/20 per minute)
8.8 Healthcheck endpoint (GET /v1/health, no auth)
8.9 IP log cleanup background task (365-day retention)
8.10 Argon2id params bumped to 256 MB (pw_version, rehash on login)
Deployed to meshbay.org — schema migrated, existing emails encrypted.
117 tests pass (29 hub, 88 common+node).
Resolves security review items S1, S2, S5.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/federation.py')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/federation.py | 86 |
1 files changed, 41 insertions, 45 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/federation.py b/packages/meshbay-hub/src/meshbay_hub/api/federation.py index 88bb645..b6b4acf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/federation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/federation.py @@ -32,21 +32,15 @@ from sqlalchemy.ext.asyncio import AsyncSession from meshbay_common import MHP_VERSION from meshbay_hub import __version__ -from meshbay_hub.api.deps import get_current_user +from meshbay_hub.api.deps import require_admin from meshbay_hub.auth import _hub_id, _hub_sk_pem, hub_public_key_pem from meshbay_hub.db.engine import get_db -from meshbay_hub.db.models import Group, User -from sqlalchemy.ext.asyncio import AsyncSession +from meshbay_hub.db.models import FederatedGroup, Group, HubPeer, User log = logging.getLogger(__name__) router = APIRouter(prefix="/mhp", tags=["federation"]) -# ── In-memory peer registry ─────────────────────────────────────────────────── -# Production: move to DB table (peers: hub_id, hub_url, pk_hub_pem, trusted_since) - -_peers: dict[str, dict] = {} # hub_id → {url, pk_pem, trusted_since} - def _issue_mhp_token(target_hub_id: str) -> str: """Issue a short-lived JWT for authenticating to a peer hub.""" @@ -57,18 +51,19 @@ def _issue_mhp_token(target_hub_id: str) -> str: "aud": target_hub_id, "jti": str(uuid.uuid4()), "iat": now, - "exp": now + 300, # 5 minute window + "exp": now + 300, "mhp": MHP_VERSION, }, _hub_sk_pem, algorithm="EdDSA") -def _verify_mhp_token(token: str, expected_aud: str | None = None) -> dict: - """Verify a JWT from a peer hub.""" - # First decode without verification to get iss (sender hub_id) +async def _verify_mhp_token( + token: str, db: AsyncSession, expected_aud: str | None = None, +) -> dict: + """Verify a JWT from a peer hub using DB-stored public key.""" unverified = jwt.decode(token, options={"verify_signature": False}) sender_id = unverified.get("iss") - peer = _peers.get(sender_id) + peer = await db.get(HubPeer, sender_id) if not peer: raise PermissionError(f"Unknown hub: {sender_id!r}. Register as peer first.") @@ -77,7 +72,7 @@ def _verify_mhp_token(token: str, expected_aud: str | None = None) -> dict: options["audience"] = expected_aud decoded = jwt.decode( - token, peer["pk_pem"].encode(), + token, peer.pk_hub_pem.encode(), algorithms=["EdDSA"], options=options, ) @@ -104,12 +99,8 @@ async def export_directory( db: AsyncSession = Depends(get_db), authorization: str = Header(...), ): - """ - Export our public Mesh Directory to a peer hub. - Auth: Bearer JWT signed by peer hub's key. - """ try: - _verify_mhp_token(authorization.removeprefix("Bearer ")) + await _verify_mhp_token(authorization.removeprefix("Bearer "), db) except Exception as e: raise HTTPException(status_code=401, detail=str(e)) @@ -144,16 +135,11 @@ async def receive_directory( authorization: str = Header(...), db: AsyncSession = Depends(get_db), ): - """ - Receive a Mesh Directory update from a peer hub. - Persists groups to federated_groups table for cross-hub search. - """ try: - _verify_mhp_token(authorization.removeprefix("Bearer ")) + await _verify_mhp_token(authorization.removeprefix("Bearer "), db) except Exception as e: raise HTTPException(status_code=401, detail=str(e)) - from meshbay_hub.db.models import FederatedGroup from datetime import datetime, timezone now = datetime.now(timezone.utc) count = 0 @@ -179,24 +165,19 @@ async def receive_directory( # ── Revocation propagation ──────────────────────────────────────────────────── class RevocationPayload(BaseModel): - token: str # signed revocation JWT from originating hub - + token: str @router.post("/revoke", status_code=202) async def receive_revocation( body: RevocationPayload, authorization: str = Header(...), + db: AsyncSession = Depends(get_db), ): - """ - Receive a revocation from a peer hub. Verify and propagate to our nodes. - """ try: - _verify_mhp_token(authorization.removeprefix("Bearer ")) + await _verify_mhp_token(authorization.removeprefix("Bearer "), db) except Exception as e: raise HTTPException(status_code=401, detail=str(e)) - # The revocation token is signed by the ORIGINATING hub's key (not the relaying hub) - # For now: re-broadcast to our connected nodes from meshbay_hub.api.revocation import broadcast_revocation sent = await broadcast_revocation(body.token) log.info("Propagated revocation to %d local nodes", sent) @@ -208,30 +189,45 @@ async def receive_revocation( class PeerRegisterRequest(BaseModel): hub_id: str hub_url: str - pk_hub_pem: str # peer hub's Ed25519 public key PEM - + pk_hub_pem: str @router.post("/peers", status_code=201) async def register_peer( body: PeerRegisterRequest, - current_user: User = Depends(get_current_user), + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), ): - """Admin: register a trusted peer hub. Manual step — no auto-discovery.""" - _peers[body.hub_id] = { - "url": body.hub_url, - "pk_pem": body.pk_hub_pem, - "trusted_since": int(time.time()), - } + """Admin: register a trusted peer hub.""" + existing = await db.get(HubPeer, body.hub_id) + if existing: + existing.hub_url = body.hub_url + existing.pk_hub_pem = body.pk_hub_pem + else: + db.add(HubPeer( + hub_id=body.hub_id, + hub_url=body.hub_url, + pk_hub_pem=body.pk_hub_pem, + )) + await db.commit() log.info("Peer registered: %s (%s)", body.hub_id, body.hub_url) return {"status": "registered", "hub_id": body.hub_id} @router.get("/peers") -async def list_peers(current_user: User = Depends(get_current_user)): +async def list_peers( + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): """Admin: list registered peer hubs.""" + result = await db.execute(select(HubPeer)) + peers = result.scalars().all() return { "peers": [ - {"hub_id": hid, "url": p["url"], "trusted_since": p["trusted_since"]} - for hid, p in _peers.items() + { + "hub_id": p.hub_id, + "url": p.hub_url, + "trusted_since": p.trusted_since.isoformat(), + } + for p in peers ] } |