Federation Trust Boundary Analysis

A security audit of the multi-server federation layer: trust boundaries, authorization gaps, risk areas, and concrete recommendations to bring federation access control in line with the single-server club model.

1. Executive Summary 2. Architecture Overview: Where the Boundaries Are 3. Boundary 1: Connection Admission 4. Boundary 2: Peer Authentication (Handshake) 5. Boundary 3: Read Access (Data Plane) 6. Boundary 4: Write Access (Data Plane) 7. Boundary 5: Membership (Web-of-Trust Join) 8. Boundary 6: Governance (PBFT Consensus) 9. Boundary 7: Identity Binding 10. Single-Server vs Federation: The Access Model Gap 11. The Gap Register (Prioritized) 12. Recommendations: Aligning Federation with the Club Model 13. Safe Deployment Patterns (Until Gaps Are Fixed)

1. Executive Summary

The federation layer establishes an encrypted, mutually-authenticated channel between servers using sound cryptography (X25519 + Ed25519 + ChaCha20-Poly1305). Once a peer passes the handshake, however, it operates as a privileged super-user with broad read/write access to the local data store. There is no per-work, per-blob, or per-edition authorization on federation requests — the club-based access control that protects content in the single-server model is bypassed entirely on the federation data plane.

Bottom line: A federated peer has broader access than any end-user. It can read private works (bypassing read_club), inject fabricated provenance, create works with no quota, and — through governance gaps — potentially rotate other servers' identity keys. The cryptography prevents outsiders from connecting, but a malicious or compromised peer faces almost no restrictions once connected.

This document maps every trust boundary, catalogs the authorization gaps by severity, and provides concrete recommendations for aligning federation access control with the existing single-server club model.

2. Architecture Overview: Where the Boundaries Are

Network (untrusted) Xudanu Server B1: Connection Admission — /federation route, no rate-limit, no IP filter B2: Handshake — X25519 ECDH + Ed25519 signatures + AEAD (SAFE) B3/B4: Data Plane — NO read_club checks, NO write authorization Read: ContentGet, BlobGet, ContentFetch, CrdtSyncPull, SyncPull(editions unfiltered) Write: SyncPush, CrdtSyncPush, StateSyncPush, provenance import Federation access is BROADER than end-user access B5: Membership OR-set CRDT merge (unauthenticated) Privilege insertion loop risk B6: Governance (PBFT) Sealed batches not re-validated Votes unsigned; quorum-of-1 default B7: server_id not bound to verifying key (TrustedServerRegistry unused) Single-Server Model (for comparison) Every operation checks KeyMaster authority + read_club + edit_club Rate limiting (per-club + per-IP), CSRF, SecurityMonitor, audit chain Provenance signed with per-author Ed25519 keys, verified before display The federation data plane skips ALL of these checks

Seven trust boundaries in the federation stack. Green = cryptographically sound. Red = authorization gaps. Purple = consensus layer with gaps.

3. Boundary 1: Connection Admission

The mechanism

The /federation WebSocket route (build_federation_router, federation_handler.rs:184) accepts connections with no middleware, no CSRF guard, no rate limiter, and no IP allowlist. The handler handle_federation_socket performs the handshake inline.

Trust assumption

Anyone who can reach the port may open the socket; security rests entirely on the cryptographic handshake (Boundary 2).

Risks

IssueImpactSeverity
Handshake frames (Hello/Signature) sent in cleartext before encryption beginsPassive observer learns server identities, public keys, and ephemeral keysMEDIUM
No rate limiting on handshake attemptsResource-exhaustion DoS: each failed attempt holds a WebSocket + 30s timeoutMEDIUM
Dialer uses ws:// (plaintext), not wss://Outbound handshake metadata exposed on the wireMEDIUM
64 MiB frame size limit with no per-type capsPeer can send 64 MiB garbage frames; connection stays alive through parse failuresLOW
Recommendation
Bind /federation behind TLS (enforce wss://). Add an explicit peer-address allowlist. Rate-limit handshake attempts per IP. Reduce frame size limit to something proportional to expected sync volumes (e.g., 4 MiB).

4. Boundary 2: Peer Authentication (Handshake)

The mechanism

Three-step handshake producing an encrypted channel:

  1. Hello: Exchange protocol version + X25519 ephemeral key + server_id
  2. Signature: Each side signs (my_eph, peer_eph) with its Ed25519 identity key, sends verifying key + kex key
  3. Ready: First encrypted frame; session keys derived via X25519 + HKDF with domain-separated labels

Then federation_is_peer_known (server.rs:11116) gates on the operator-configured known_peer_keys set.

What's safe: The cryptography is sound. Authenticated ECDH (X25519 + Ed25519), ChaCha20-Poly1305 AEAD with sequence numbers, domain separation between handshake directions. An outsider cannot forge a connection without a known peer key.

Risks

IssueImpactSeverity
federation_is_peer_known returns true unconditionally when federation is disabledIf operator forgets to enable federation but still exposes /federation, any valid-signature peer is accepted — and is_peer_known is the ONLY membership gate on the inbound pathHIGH
known_peer_keys has no expiry or rotationCompromised keys remain valid indefinitelyMEDIUM
TrustedServerRegistry / verify_server_identity never wired into federation pathThe well-designed identity-verification layer (signed manifests, expiry, constant-time comparison) is unusedHIGH
Membership (MembershipState) never consulted on inbound handlerA peer in known_peer_keys but ejected via governance still gets full data-plane accessHIGH
Recommendation
Wire TrustedServerRegistry into the handshake so peer keys are validated against signed manifests with expiry. Consult MembershipState::is_member() after the handshake and before any data-plane access. Default to denying peers when federation is disabled.

5. Boundary 3: Read Access (Data Plane)

This is the most critical area. After the handshake, an authenticated peer can issue read frames with no authorization check beyond "you passed the handshake".

FrameWhat it returnsread_club check?Severity
SyncPull (bulk)All works + all standalone editions + all blobsPartial — works filtered to public_club, but editions and blobs are not filteredCRITICAL
ContentGet { work_id }Full edition for a work by IDNone — any work_id returns contentCRITICAL
BlobGet { hash }Raw blob bytes + MIME typeNone — any hash returns the blobCRITICAL
ContentFetch { fingerprint }Edition or blob by content fingerprintNone — resolves fingerprint to work, no club checkCRITICAL
TranscludeQueryEntire transclusion index entriesNone — leaks full transclusion graphHIGH
CrdtSyncPull { work_ids }O-tree CRDT state for requested worksChecks server_id == peer_server_id only; work_ids not checked against read_clubCRITICAL
The core problem: Federation read access bypasses the club-based access model entirely. A federated peer can read private works, private editions, all blobs, and collaborative editing state — broader access than any end-user. The existing read_club filter on bulk SyncPull works signals the authors intended club-scoped replication, but the filter is missing on every other read path.
Recommendation: Apply read_club to every read path
Every federation read handler should check work.read_club against the peer's identity:
// In every federation read handler:
let work = self.works.get(&work_id).ok_or(...)?;
let read_club = work.work.current_edition().read_club()
    .unwrap_or(self.system_clubs.public_club);

// Option A: Only replicate public content
if read_club != self.system_clubs.public_club {
    return Err(FederationError::NotAuthorized);
}

// Option B: Check against a "federation read club" the peer belongs to
if !peer_clubs.contains(&read_club) {
    return Err(FederationError::NotAuthorized);
}
Also filter federation_export_editions and federation_export_blobs the same way federation_export_works already filters.

6. Boundary 4: Write Access (Data Plane)

FrameEffectAuth checkSeverity
SyncPushCreates new public works from pushed editionsNone — any peer mints arbitrary public works, no quotaCRITICAL
CrdtSyncPushModifies collaborative editing buffer for any work_id; imports span_provenanceChecks server_id == peer_server_id only; no edit-rights check; provenance imported verbatimCRITICAL
StateSyncPushMerges foreign ReconcileState; can move "current edition" pointerNone — peer can change which edition is presented as currentHIGH
SyncPush blobsImports blobs into blob storeBLAKE3 hash verified, but any peer can inject blobs with no quotaMEDIUM

Provenance injection (CRITICAL)

SpanProvenance on SyncWorkEntry and CrdtWorkUpdate is imported verbatim with no signature verification. A peer can attach fabricated provenance — claiming a span originated from any author or server — to locally-stored content. Since provenance is the integrity spine of the system, this undermines the trustworthiness of all federated content.

LWW "current edition" hijack

ReconcileState.current uses last-writer-wins by (timestamp, server_id). The timestamp is clamped to now + 3600s, but a peer that sets a near-future timestamp wins the "current" pointer for any work, effectively censoring other editions from the default view.

Recommendation: Verify provenance before accepting
// Before importing span_provenance from a peer:
for sp in &update.span_provenance {
    let element_fingerprints = collect_fingerprints(&edition, sp.start, sp.end);
    if !verify_span_provenance(&sp.provenance, &element_fingerprints) {
        tracing::warn!("peer {} sent invalid provenance, rejecting", peer_server_id);
        continue; // skip this span's provenance
    }
    // Optionally: verify that provenance.author_public_key corresponds
    // to the origin server's known signing key
}
Also: add edit-rights checks on CrdtSyncPush (peer must be in the work's edit_club), add quotas on SyncPush work creation, and clamp LWW timestamps to now (not now + 1h).

7. Boundary 5: Membership (Web-of-Trust Join)

The mechanism

MembershipState is an OR-set CRDT. Join requires ≥ min_endorsements valid endorsements from existing members. The default min_endorsements = 2.

Risks

IssueImpactSeverity
Membership OR-set merges are unauthenticated (MembershipSyncPush)A peer can inject membership entries for arbitrary server_idsCRITICAL
Endorser verification keys read from the merge resultPrivilege insertion loop: inject member → forge their endorsement → satisfy min_endorsements for further joinsCRITICAL
Bootstrap mode accepts joins with ZERO endorsementsIf left on, any peer that passes the key check joins with no endorsementMEDIUM
Membership not checked on inbound data planeEjected peers retain full data accessHIGH
Recommendation: Pin endorser keys to the registry
Endorser verification keys should come from the TrustedServerRegistry (signed manifests), not from the CRDT merge result. Membership sync should authenticate each entry against the registry before merging. This breaks the privilege-insertion loop because the attacker cannot forge registry signatures.

8. Boundary 6: Governance (PBFT Consensus)

The mechanism

Lightweight PBFT: Proposal → Prepare → Commit → Sealed. Transactions: Admit, Expel, KeyRegister, RoyaltyRecord.

Risks

IssueImpactSeverity
Sealed batches not re-validated on receiptReceiver checks only proposer_id + sequence; does not verify quorum or vote signaturesCRITICAL
PBFT votes carry no signaturesA Byzantine leader can fabricate a SealedBatch with fake vote listCRITICAL
KeyRegister replaces any member's keys with no old-key proofCombined with above, a leader can rotate all victim keys to attacker-controlled keysCRITICAL
Default cluster_size = 1 → quorum of 1Single-node "consensus" — leader alone reaches both prepare and commit quorumHIGH
Non-leader can push GovernancePrePrepareReceiver prepare-votes for it without checking proposer is the current leaderMEDIUM
RoyaltyRecord has no upper boundMalicious peer can pollute the royalty ledger with arbitrary amountsMEDIUM
Recommendation: Sign PBFT votes and verify quorum
  1. Sign every vote: PbftVote should include an Ed25519 signature over (view, seq, voter_id, phase). The receiver verifies each vote's signature against the voter's registry-pinned key.
  2. Verify quorum on sealed batches: On receiving GovernanceSealed, count distinct valid prepare + commit votes; reject if either count is below quorum_size.
  3. Require old-key proof for KeyRegister: The transaction must include a signature from the old key authorizing the rotation.
  4. Default cluster_size = 3 (minimum for meaningful PBFT). Refuse to start with quorum-of-1 in production mode.
  5. Verify proposer is the current leader before voting on a pre-prepare.

9. Boundary 7: Identity Binding

The problem

The authenticated principal is the Ed25519 verifying key (from the handshake), but authorization decisions throughout the frame handlers key off the self-declared server_id string from the Hello frame. There is no binding between the two.

Consequences

Recommendation: Bind server_id to verifying key
The TrustedServerRegistry in server_identity.rs already implements exactly the right mechanism: signed attestations binding server_id ↔ verifying_key with expiry and constant-time comparison. verify_server_identity() should be called during the handshake, after Hello, to verify that the declared server_id matches the authenticated verifying key. If the registry has no entry for this binding, the connection is refused.

10. Single-Server vs Federation: The Access Model Gap

The single-server model has a coherent, layered access control system. The federation data plane bypasses every layer:

Access control mechanismSingle-server (end-user)Federation (peer server)
AuthenticationPassword (Argon2id) or Ed25519 key + rate limiting (10 attempts / 5 min)X25519 + Ed25519 handshake (sound crypto)
read_club enforcementEvery read path checks session.has_authority(read_club)BYPASSED on ContentGet, BlobGet, ContentFetch, CrdtSyncPull
edit_club enforcementEvery write path checks session.has_authority(edit_club)BYPASSED on SyncPush, CrdtSyncPush, StateSyncPush
Provenance verificationverify_span_provenance() checked on display; signatures verified against author keysImported verbatim, no verification
Rate limitingPer-club login limit + per-session/per-IP sliding windowsNone on data plane
QuotaPersonal club limit (max_personal_clubs)None — unlimited work creation via SyncPush
Audit trailSecurityMonitor + tamper-evident security.log + attribution.logFederation operations not audited
CSRF protectionToken-based on WS upgradeN/A (different protocol)
End-User Path (single-server) 1. CSRF token check 2. Rate limit (per-club + per-IP) 3. Login + KeyMaster authority 4. read_club / edit_club check 5. SecurityMonitor audit event 6. Provenance signed with author's key Every layer enforced Federation Path (peer server) 1. X25519 + Ed25519 handshake (SAFE) 2–6. (skipped) No read_club check No edit_club check No rate limiting or audit Provenance imported without verification 5 layers bypassed

The end-user path enforces 6 layers of access control. The federation path enforces 1 (the handshake) and skips the rest.

11. The Gap Register (Prioritized)

#GapLocationImpactSeverity
C1ContentGet/ContentFetch/BlobGet/TranscludeQuery ignore read_clubserver.rs:11493, 11504, 11570, 11514Private content read by any peerCRITICAL
C2federation_export_editions / federation_export_blobs unfilteredserver.rs:11216, 11341Bulk exfiltration of all editions + blobsCRITICAL
C3CrdtSyncPull work_ids not club-checkedserver.rs:11414Private work CRDT state exposedCRITICAL
W1SyncPush/CrdtSyncPush import content + provenance with no authserver.rs:11231, 11437Content & provenance injectionCRITICAL
W2StateSyncPush LWW hijack of "current" editionserver.rs:11683Edition censorship/swapHIGH
M1Membership OR-set merge unauthenticated; endorser keys from merge resultfederation.rs:1698, server.rs:11987Privilege insertion loopCRITICAL
G1Sealed PBFT batches not re-validated; votes unsignedfederation_handler.rs:778Forged governance executionCRITICAL
G2KeyRegister needs no old-key proofserver.rs:12138Key theft via governanceCRITICAL
G3Default cluster_size=1 → quorum of 1federation.rs:234Single-node "consensus"HIGH
A1server_id not bound to verifying key on federation pathfederation_handler.rs:439Identity spoofingHIGH
A2federation_is_peer_known returns true when federation disabledserver.rs:11117Open peer acceptance if misconfiguredHIGH
A3TrustedServerRegistry / verify_server_identity never wired inserver_identity.rs:290Unused strong identity layerHIGH
T1Handshake frames in cleartext; ws:// not wss://federation_handler.rs:225, federation_active.rs:145Metadata leakage to passive observerMEDIUM
T2No rate limiting on handshake; 64 MiB framesfederation_handler.rs:184, 463DoS via resource exhaustionMEDIUM

12. Recommendations: Aligning Federation with the Club Model

The goal is to make federation access control at least as restrictive as end-user access control. Here's the roadmap:

Phase 1: Stop the Bleeding (quick fixes, 1-2 days)

Fix A2: Default-deny when federation is disabled
Change federation_is_peer_known to return false when federation mode is disabled. Currently it returns true, allowing any key-valid peer in.
Fix C2: Filter editions and blobs in bulk SyncPull
Apply the same read_club == public_club filter that federation_export_works already uses to federation_export_editions and federation_export_blobs. This is a few-line fix that closes the bulk exfiltration path.
Fix G3: Default cluster_size to 3
Change GovernanceState::new(1) to GovernanceState::new(3) and add a runtime warning if cluster_size < 3 in production mode.

Phase 2: Enforce Club Access Control (1-2 weeks)

Fixes C1, C3, W1: Add read_club and edit_club checks to all federation handlers

Introduce a FederationAuthorization struct that wraps the peer's identity and resolved clubs:

struct FederationAuthorization {
    peer_server_id: String,
    peer_verifying_key: [u8; 32],
    resolved_clubs: HashSet<BeId>,  // clubs this peer belongs to
}

impl FederationAuthorization {
    fn can_read(&self, work: &WorkState, server: &Server) -> bool {
        let read_club = work.read_club()
            .unwrap_or(server.system_clubs.public_club);
        read_club == server.system_clubs.public_club
            || self.resolved_clubs.contains(&read_club)
    }

    fn can_edit(&self, work: &WorkState, server: &Server) -> bool {
        let edit_club = work.edit_club()
            .unwrap_or(server.system_clubs.empty_club);
        self.resolved_clubs.contains(&edit_club)
    }
}

Check auth.can_read() before every read handler and auth.can_edit() before every write handler. This mirrors the single-server pattern exactly.

Fix W1: Verify provenance before importing

Before accepting span_provenance from a peer, call verify_span_provenance() to confirm the Ed25519 signature is valid for the claimed content. Reject invalid provenance rather than importing it verbatim. Optionally verify that the author_public_key corresponds to the origin server's registered signing key.

Phase 3: Fix Identity and Governance (2-4 weeks)

Fix A1, A3: Wire TrustedServerRegistry into the handshake

After Hello and before Signature, verify the declared server_id against the peer's authenticated verifying key using verify_server_identity(). Refuse the connection if no valid registry entry exists. This binds the self-declared identity to the cryptographic identity.

Fix G1, G2: Sign PBFT votes and verify quorum
  1. Add signature: [u8; 64] to PbftVote, signed over (view, seq, voter_id, phase) with the voter's key
  2. On receiving GovernanceSealed, verify every embedded vote's signature and count distinct valid votes per phase; reject if below quorum_size
  3. Require KeyRegister transactions to include a signature from the old key
Fix M1: Pin endorser keys to the registry

Endorser verification keys must come from the TrustedServerRegistry, not from the CRDT merge result. This breaks the privilege-insertion loop because the attacker cannot forge registry signatures.

Phase 4: Hardening (ongoing)

Additional hardening

13. Safe Deployment Patterns (Until Gaps Are Fixed)

Until the gaps in Phase 1 and 2 are fixed, federation should be deployed with these constraints:

Only federate with servers you fully trust. The current model is full-trust: a peer can read all content and inject works/provenance. Treat federation peers as co-administrators of your server, not as untrusted network nodes.
PatternGuidance
Network isolationBind /federation to a private network interface or VPN. Do not expose it to the public internet.
Shared content onlyIf you want to keep private works private, don't enable federation yet. The SyncPull works filter is partial; direct-fetch paths have no filter.
Disable federation by defaultOnly enable --peer / --federation-mode when you intentionally want to federate. Ensure federation_is_peer_known returns false when disabled (Fix A2).
Minimize peer countFewer peers = smaller attack surface. Each peer is a potential exfiltration and injection point.
Audit known_peer_keysReview the key list regularly. Remove keys for decommissioned peers immediately.
Don't rely on governanceThe PBFT layer has gaps (G1-G3). Don't rely on it for security-critical decisions until Phase 3 fixes are in.
The positive framing: The cryptography is sound. The existing single-server access control model is well-designed with clubs, locks, KeyMaster, rate limiting, and audit trails. The federation layer just needs to reuse those mechanisms instead of bypassing them. The fixes are architectural-pattern reuse, not new cryptography.