spore-commons/node/spore_node/api/routers/federation.py

226 lines
7.2 KiB
Python

"""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),
)