Phase 15: Server-to-Server Transport

Goal

Two servers can authenticate and establish an encrypted channel via the /federation WebSocket endpoint. The handshake is mutual Ed25519 with X25519 key exchange, reusing the existing crypto stack.

What Was Added

Peer-to-Peer Key Exchange (crypto/kex.rs)

Federation Session Keys (crypto/kdf.rs)

Federation WebSocket Handler (transport/federation_handler.rs)

New module handling the /federation WebSocket route:

Handshake protocol (4 messages, no round-trip waste):

Server A                              Server B
    │──── Hello {ephemeral, server_id} ──→│
    │←─── Hello {ephemeral, server_id} ───│
    │                                     │
    │──── Signature {sig, signing_key, ──→│
    │      kex_key}                       │
    │←─── Signature {sig, signing_key, ───│
    │      kex_key}                       │
    │                                     │
    │  Both verify peer signature against │
    │  Ed25519 signing key                │
    │  Both derive session keys via       │
    │  peer_key_exchange + HKDF           │
    │                                     │
    │──── Ready {server_id, "connected"} ─→│
    │←─── Ready {server_id, "connected"} ─│
    │                                     │
    │═══════ encrypted channel ═══════════│
    │    (heartbeat/ack keepalive)        │

Frame types: - FederationFrame::Hello — protocol version, ephemeral public key, server ID - FederationFrame::Signature — Ed25519 signature, signing key, KEX key - FederationFrame::Ready — server ID, connection status - FederationFrame::Heartbeat / FederationFrame::Ack — keepalive

Server Integration

Three new methods on Server that keep private keys encapsulated: - federation_handshake_init() — generates ephemeral keypair, returns server ID and public bytes - federation_sign_handshake(my_eph, peer_eph) — signs the handshake transcript with the server's Ed25519 key - federation_derive_session_keys(peer_kex, my_eph, peer_eph) — derives FederationSessionKeys using peer_key_exchange

The server binary now merges both routers:

let client_router = build_router(state.clone());
let federation_router = build_federation_router(state.clone());
let app = merge_routers(client_router, federation_router);

Single Port

Both client (/xudanu) and federation (/federation) endpoints share the same port. No separate listener needed.

Design Decisions

Same Port, Different Route

Adding /federation as a route on the existing axum router is simpler than running a separate listener. Deployment stays one-port. The federation handler has its own auth flow (mutual Ed25519 handshake) completely independent of client sessions.

Private Keys Stay in Server

The Server struct keeps server_keypair private. Three new methods (federation_handshake_init, federation_sign_handshake, federation_derive_session_keys) perform all crypto operations inside the with_server closure. The federation handler never sees private keys.

Canonical Transcript Ordering

canonical_transcript(eph_a, eph_b) uses lexicographic ordering (lower public key first) so both sides compute the same hash regardless of who initiated the connection. This is critical for the symmetric peer_key_exchange.

Static-Static DH + Ephemeral Transcript

peer_key_exchange uses static-static X25519 DH for the shared secret, with ephemeral keys mixed in via the transcript hash. This provides: - Authentication: bound to long-term static keys (verified via Ed25519 signature) - Forward secrecy: ephemeral keys change per-connection, mixed into the key derivation - Simplicity: no need for ephemeral-ephemeral DH (which x25519-dalek's EphemeralSecret makes awkward since it consumes self)

Tests

Unit Tests

Integration Test

Test Counts

What's Next

Phase 16 will add content replication: G-Set CRDT for immutable content, push/pull sync between servers, blake3 verification on receipt.