"""Federation endpoints — peer handshake, trust tiers, event relay.""" import json import uuid from fastapi import APIRouter, HTTPException from pydantic import BaseModel from spore_node.db.connection import get_pool from spore_node.federation.crypto import ( generate_keypair, public_key_to_b64, b64_to_public_key, derive_shared_key, ) router = APIRouter(prefix="/federation", tags=["federation"]) VALID_TRUST_TIERS = ("trusted", "peer", "monitored") VALID_HANDSHAKE_STATES = ("pending", "proposed", "approved", "rejected") # Node's own keypair (generated on first handshake) _node_keypair: tuple[bytes, bytes] | None = None def _get_keypair() -> tuple[bytes, bytes]: global _node_keypair if _node_keypair is None: _node_keypair = generate_keypair() return _node_keypair class HandshakeRequest(BaseModel): peer_url: str peer_rid: str = "" peer_public_key: str = "" # base64-encoded X25519 public key trust_tier: str = "monitored" class PeerResponse(BaseModel): id: str node_rid: str node_url: str trust_tier: str handshake_status: str pending_events: int = 0 class HandshakeResponse(BaseModel): status: str peer_rid: str our_public_key: str message: str @router.post("/handshake", response_model=HandshakeResponse) async def initiate_handshake(data: HandshakeRequest): """Initiate or accept a federation handshake with a peer node.""" if data.trust_tier not in VALID_TRUST_TIERS: raise HTTPException(422, f"trust_tier must be one of {VALID_TRUST_TIERS}") pool = get_pool() _, our_public = _get_keypair() peer_rid = data.peer_rid or f"orn:koi-net.node:peer-{uuid.uuid4().hex[:8]}" # Store peer encryption config config = {} if data.peer_public_key: config["peer_public_key"] = data.peer_public_key config["our_public_key"] = public_key_to_b64(our_public) # Derive shared key for this peer our_private, _ = _get_keypair() shared = derive_shared_key(our_private, b64_to_public_key(data.peer_public_key)) config["encryption_ready"] = True try: await pool.execute( """ INSERT INTO koi_peers (node_rid, node_url, trust_tier, handshake_status, config) VALUES ($1, $2, $3, 'proposed', $4::jsonb) ON CONFLICT (node_rid) DO UPDATE SET node_url = EXCLUDED.node_url, trust_tier = EXCLUDED.trust_tier, handshake_status = 'proposed', config = EXCLUDED.config, updated_at = now() """, peer_rid, data.peer_url, data.trust_tier, json.dumps(config), ) except Exception as e: raise HTTPException(500, f"Failed to store peer: {e}") await _log_event(pool, peer_rid, "federation.handshake.proposed", {"peer_url": data.peer_url, "trust_tier": data.trust_tier}) return HandshakeResponse( status="proposed", peer_rid=peer_rid, our_public_key=public_key_to_b64(our_public), message=f"Handshake proposed with {data.peer_url}. Awaiting bilateral approval.", ) @router.patch("/peers/{peer_rid}/approve") async def approve_peer(peer_rid: str): """Approve a pending handshake — transitions to approved.""" pool = get_pool() row = await pool.fetchrow( "SELECT handshake_status FROM koi_peers WHERE node_rid = $1", peer_rid ) if not row: raise HTTPException(404, "Peer not found") if row["handshake_status"] == "approved": return {"status": "already_approved"} if row["handshake_status"] == "rejected": raise HTTPException(422, "Peer was rejected. Re-initiate handshake.") await pool.execute( "UPDATE koi_peers SET handshake_status = 'approved', updated_at = now() WHERE node_rid = $1", peer_rid, ) await _log_event(pool, peer_rid, "federation.handshake.approved", {}) return {"status": "approved", "peer_rid": peer_rid} @router.patch("/peers/{peer_rid}/reject") async def reject_peer(peer_rid: str): """Reject a pending handshake.""" pool = get_pool() row = await pool.fetchrow( "SELECT handshake_status FROM koi_peers WHERE node_rid = $1", peer_rid ) if not row: raise HTTPException(404, "Peer not found") await pool.execute( "UPDATE koi_peers SET handshake_status = 'rejected', updated_at = now() WHERE node_rid = $1", peer_rid, ) await _log_event(pool, peer_rid, "federation.handshake.rejected", {}) return {"status": "rejected", "peer_rid": peer_rid} @router.patch("/peers/{peer_rid}/trust") async def update_trust_tier(peer_rid: str, trust_tier: str): """Update a peer's trust tier.""" if trust_tier not in VALID_TRUST_TIERS: raise HTTPException(422, f"trust_tier must be one of {VALID_TRUST_TIERS}") pool = get_pool() row = await pool.fetchrow( "SELECT node_rid FROM koi_peers WHERE node_rid = $1", peer_rid ) if not row: raise HTTPException(404, "Peer not found") await pool.execute( "UPDATE koi_peers SET trust_tier = $1, updated_at = now() WHERE node_rid = $2", trust_tier, peer_rid, ) await _log_event(pool, peer_rid, "federation.trust_tier.changed", {"trust_tier": trust_tier}) return {"peer_rid": peer_rid, "trust_tier": trust_tier} @router.get("/peers", response_model=list[PeerResponse]) async def list_peers(trust_tier: str | None = None): """List all federation peers.""" pool = get_pool() if trust_tier: rows = await pool.fetch( "SELECT * FROM koi_peers WHERE trust_tier = $1 ORDER BY created_at", trust_tier, ) else: rows = await pool.fetch("SELECT * FROM koi_peers ORDER BY created_at") return [_peer_dict(r) for r in rows] @router.get("/peers/{peer_rid}", response_model=PeerResponse) async def get_peer(peer_rid: str): """Get a single peer by RID.""" pool = get_pool() row = await pool.fetchrow( "SELECT * FROM koi_peers WHERE node_rid = $1", peer_rid ) if not row: raise HTTPException(404, "Peer not found") return _peer_dict(row) @router.get("/events/pending") async def pending_events(): """Get count of pending relay events per peer.""" pool = get_pool() peers = await pool.fetch( "SELECT node_rid, node_url FROM koi_peers WHERE handshake_status = 'approved'" ) # Import relay lazily to avoid circular deps from spore_node.federation.relay import FederationRelay from spore_node.config import SporeConfig cfg = SporeConfig() relay = FederationRelay(cfg.redis_url) result = {} for peer in peers: count = await relay.pending_count(peer["node_rid"]) if count > 0: result[peer["node_rid"]] = count return result def _peer_dict(row) -> dict: d = dict(row) d["id"] = str(d["id"]) d["pending_events"] = 0 d.pop("config", None) d.pop("last_seen_at", None) d.pop("created_at", None) d.pop("updated_at", None) return d async def _log_event(pool, entity_rid: str, event_kind: str, payload: dict): await pool.execute( "INSERT INTO events (entity_rid, event_kind, payload) VALUES ($1, $2, $3::jsonb)", entity_rid, event_kind, json.dumps(payload), )