""" Federation and gossip protocol for Verisoo. Nodes sync their merkle logs via gossip: 1. Periodically exchange merkle roots with peers 2. If roots differ, request consistency proof 3. Fetch missing records and append to local log Eventually consistent - "eventually" depends on gossip frequency and topology. No central coordinator, no leader election, no consensus protocol. Just append-only logs that converge. """ from __future__ import annotations import asyncio import hashlib import json import logging from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Callable, Protocol from urllib.parse import urlparse from .exceptions import FederationError from .merkle import ConsistencyProof, InclusionProof, MerkleLog from .models import AttestationRecord logger = logging.getLogger(__name__) @dataclass class PeerInfo: """Information about a federation peer.""" url: str fingerprint: str # Ed25519 fingerprint for peer authentication last_seen: datetime | None = None last_root: str | None = None last_size: int = 0 healthy: bool = True consecutive_failures: int = 0 @dataclass class SyncStatus: """Result of a sync attempt with a peer.""" peer: str success: bool records_received: int = 0 our_size_before: int = 0 our_size_after: int = 0 their_size: int = 0 error: str | None = None class PeerTransport(Protocol): """Protocol for peer communication.""" async def get_status(self, peer: PeerInfo) -> tuple[str, int]: """Get peer's current root hash and log size.""" ... async def get_records( self, peer: PeerInfo, start_index: int, count: int ) -> list[AttestationRecord]: """Fetch records from peer.""" ... async def get_consistency_proof( self, peer: PeerInfo, old_size: int ) -> ConsistencyProof: """Get proof that peer's log is consistent with ours.""" ... async def push_records( self, peer: PeerInfo, records: list[AttestationRecord] ) -> int: """Push records to peer, returns number accepted.""" ... class GossipNode: """ A node in the Verisoo federation network. Manages: - Local merkle log - Peer connections - Periodic sync via gossip """ def __init__( self, log: MerkleLog, transport: PeerTransport, node_id: str | None = None, ) -> None: self.log = log self.transport = transport self.node_id = node_id or self._generate_node_id() self.peers: dict[str, PeerInfo] = {} self._running = False self._sync_task: asyncio.Task | None = None def add_peer(self, url: str, fingerprint: str) -> None: """Register a peer for gossip.""" peer = PeerInfo(url=url, fingerprint=fingerprint) self.peers[url] = peer logger.info(f"Added peer: {url} ({fingerprint[:16]}...)") def remove_peer(self, url: str) -> None: """Remove a peer from gossip.""" if url in self.peers: del self.peers[url] logger.info(f"Removed peer: {url}") async def start(self, interval_seconds: float = 60.0) -> None: """Start the gossip loop.""" if self._running: return self._running = True self._sync_task = asyncio.create_task(self._gossip_loop(interval_seconds)) logger.info(f"Gossip started, interval={interval_seconds}s") async def stop(self) -> None: """Stop the gossip loop.""" self._running = False if self._sync_task: self._sync_task.cancel() try: await self._sync_task except asyncio.CancelledError: pass logger.info("Gossip stopped") async def sync_with_peer(self, peer_url: str) -> SyncStatus: """ Sync with a specific peer. 1. Get their status (root, size) 2. If they have more records, fetch them 3. Verify consistency before accepting 4. Append new records to our log """ peer = self.peers.get(peer_url) if not peer: return SyncStatus( peer=peer_url, success=False, error="Unknown peer", ) our_size_before = self.log.size try: # Get peer status their_root, their_size = await self.transport.get_status(peer) # Update peer info peer.last_seen = datetime.now(timezone.utc) peer.last_root = their_root peer.last_size = their_size # Already in sync? if their_size <= our_size_before: if their_root == self.log.root_hash: peer.healthy = True peer.consecutive_failures = 0 return SyncStatus( peer=peer_url, success=True, our_size_before=our_size_before, our_size_after=our_size_before, their_size=their_size, ) # They have records we don't - fetch them if their_size > our_size_before: # First verify consistency if our_size_before > 0: proof = await self.transport.get_consistency_proof(peer, our_size_before) if not self._verify_consistency(proof): raise FederationError( f"Peer {peer_url} failed consistency check - possible fork" ) # Fetch new records new_records = await self.transport.get_records( peer, our_size_before, their_size - our_size_before ) # Append to our log for record in new_records: self.log.append(record) peer.healthy = True peer.consecutive_failures = 0 return SyncStatus( peer=peer_url, success=True, records_received=their_size - our_size_before, our_size_before=our_size_before, our_size_after=self.log.size, their_size=their_size, ) except Exception as e: peer.consecutive_failures += 1 if peer.consecutive_failures >= 3: peer.healthy = False logger.warning(f"Peer {peer_url} marked unhealthy after 3 failures") return SyncStatus( peer=peer_url, success=False, error=str(e), our_size_before=our_size_before, our_size_after=self.log.size, ) async def broadcast_record(self, record: AttestationRecord) -> dict[str, bool]: """ Push a new record to all peers. Returns dict of peer_url -> success. """ results = {} for url, peer in self.peers.items(): if not peer.healthy: results[url] = False continue try: accepted = await self.transport.push_records(peer, [record]) results[url] = accepted > 0 except Exception as e: logger.warning(f"Failed to push to {url}: {e}") results[url] = False return results async def _gossip_loop(self, interval: float) -> None: """Background task that periodically syncs with peers.""" while self._running: try: await self._gossip_round() except Exception as e: logger.error(f"Gossip round failed: {e}") await asyncio.sleep(interval) async def _gossip_round(self) -> None: """One round of gossip with all healthy peers.""" healthy_peers = [p for p in self.peers.values() if p.healthy] if not healthy_peers: return # Sync with all healthy peers concurrently tasks = [self.sync_with_peer(p.url) for p in healthy_peers] results = await asyncio.gather(*tasks, return_exceptions=True) # Log summary success_count = sum( 1 for r in results if isinstance(r, SyncStatus) and r.success ) logger.debug(f"Gossip round: {success_count}/{len(healthy_peers)} peers synced") def _verify_consistency(self, proof: ConsistencyProof) -> bool: """Verify a consistency proof from a peer.""" # Simplified: trust the proof structure for now # Full implementation would verify the merkle path return proof.old_size <= self.log.size def _generate_node_id(self) -> str: """Generate a random node ID.""" import secrets return hashlib.sha256(secrets.token_bytes(32)).hexdigest()[:16] # Placeholder for HTTP transport implementation class HttpTransport: """ HTTP-based peer transport. Endpoints expected on peers: - GET /status -> {"root": "...", "size": N} - GET /records?start=N&count=M -> [records...] - GET /consistency-proof?old_size=N -> proof - POST /records -> accept records, return count """ def __init__(self, timeout: float = 30.0) -> None: self.timeout = timeout # Will use aiohttp when federation extra is installed async def get_status(self, peer: PeerInfo) -> tuple[str, int]: """Get peer's current root hash and log size.""" raise NotImplementedError("Install verisoo[federation] for HTTP transport") async def get_records( self, peer: PeerInfo, start_index: int, count: int ) -> list[AttestationRecord]: raise NotImplementedError("Install verisoo[federation] for HTTP transport") async def get_consistency_proof( self, peer: PeerInfo, old_size: int ) -> ConsistencyProof: raise NotImplementedError("Install verisoo[federation] for HTTP transport") async def push_records( self, peer: PeerInfo, records: list[AttestationRecord] ) -> int: raise NotImplementedError("Install verisoo[federation] for HTTP transport")