Federation & Discovery¶
Federation lets independent switchyardd gateways interconnect directly, switchyardd-to-switchyardd, over an authenticated encrypted link — without routing through a shared native network. A locally-originated envelope is signed by its origin gateway and gains a verifiable attestation at every hop it federates through; RFDP (RelayFabric Discovery Protocol) rides the same links to let peers learn what services and protocols a gateway actually offers, without leaking the infrastructure behind it. Both are opt-in: a switchyardd with no federation: block never dials out, never listens, and never advertises anything.
Implementation status
This page documents what v0.3 actually ships: Noise_XX links, signed envelopes with attestation chains, a persistent trust store, fed: route egress, RFDP advert exchange (disabled/federation/public scopes), and GET /v1/discovery. RFDP's local scope (SPEC §111.5) is specced but deferred — no LAN transport exists yet, and config validation rejects it explicitly rather than silently accepting a scope that can't work. Federated attachments are metadata-only this cycle — see What federation does not carry.
How a federation link comes up¶
Two gateways federate over plain TCP, secured end-to-end by Noise_XX_25519_ChaChaPoly_BLAKE2s. XX means both sides authenticate — there's no pre-shared identity requirement to start a handshake, but the handshake result is only useful once the identity binding is checked.
Each node persists a stable X25519 static keypair (<data_dir>/fed_static.key, 0600), independent of its long-term Ed25519 node identity (<data_dir>/identity/node.key, presented as rf:<64 hex chars>). The identity binding happens in the handshake's final message: each side signs its own freshly-transmitted X25519 static public key with its Ed25519 node identity, domain-separated (relayfabric-noise-static-v1: prefix) so this signature can never be confused with any other signature this codebase produces (an envelope's origin signature, an attestation link, an advert signature — each has its own domain). The receiver checks the claimed node_id against that signature before trusting the peer at all; a mismatch tears the connection down as IdentityMismatch.
Once the handshake completes:
- The connection is recorded in the trust store at level
seenif this is the first time thisnode_idhas ever connected (record_seen— anINSERT OR IGNORE, so it never lowers or overwrites an existing higher level). - A 30-second keepalive (
Ping/Pong) and a 90-second dead timer keep the link's liveness authoritative — a stalled peer is torn down within one dead-timer window, not left as a zombie task. - The Noise transport keys rekey every 8 hours.
- Each side sends
AdvertReq(if RFDP discovery scope allows it for this peer) to ask for the other's current advert.
sequenceDiagram
participant A as switchyardd — Pasadena
participant B as switchyardd — Desert
A->>B: TCP connect
A-->>B: Noise_XX handshake (mutual, identity-bound)
Note over A,B: node_ids verified against signed static keys<br/>trust store: record_seen (floor only, never escalates)
par RFDP discovery (scope-gated)
A->>B: AdvertReq {}
B->>A: AdvertReq {}
B-->>A: Advert { signed Node Advertisement }
A-->>B: Advert { signed Node Advertisement }
Note over A,B: verified, re-sanitized, stored newer-wins<br/>in peer_adverts
end
A->>B: Envelope { env, target_route } (origin-signed + attested)
B-->>A: Ack { id }
loop every 30s
A->>B: Ping {}
B-->>A: Pong {}
end
loop every advert_ttl_secs / 2
B-->>A: Advert { refreshed }
end
Trust ladder¶
Every known federation node has exactly one row in a persistent node_trust store ((node_id, level, first_seen, updated_at)). Trust is a total order (SPEC §112.7):
| Level | Rank | Meaning | Set by |
|---|---|---|---|
blocked |
0 | Handshake refused outright. Sticky — a node removed from federation.blocked[] but not re-added anywhere else stays blocked; nothing in the wire protocol can clear it. |
federation.blocked[] (config only) |
| (unknown) | 1 | No row in the store at all — never actually persisted as a value. | — |
seen |
2 | Completed a Noise handshake. A floor: discovery and handshake activity can create this row, but can never raise an existing row past it. | record_seen, on first successful handshake |
verified |
3 | Config-asserted default trust for a listed peer. | federation.peers[].trust (default when a peer entry has no explicit trust) |
trusted |
4 | Highest tier — explicit operator elevation, independent of any peers[] entry. |
federation.peers[].trust: trusted, federation.trusted[] |
Three invariants hold everywhere this ladder is consulted:
- A handshake never escalates trust.
record_seenonly ever inserts a freshseenrow (INSERT OR IGNORE) — it can never touch an existingverified/trusted/blockedrow. - Config reseed is authoritative, every boot.
federation.peers[]/trusted/blockedare re-applied on every daemon start, in that order, withblockedapplied last so a node listed as a trusted peer and blocked ends up blocked — the most restrictive outcome wins. A node previouslyverified/trustedby a config that no longer lists it is downgraded toseen(its row is kept, not deleted);first_seenis preserved across reseeds — onlyupdated_atadvances. - Blocked is sticky. Nothing at runtime — not a handshake, not an advert, not a config edit that simply omits the node — clears a
blockedrow. Only an explicit config change that seeds a different level for thatnode_idcan move it offblocked.
federation.accept_from (default verified) is the gate fed_ingress applies to an inbound envelope: the signer's current trust rank must be >= trust_rank(accept_from), or the envelope is dead-lettered BAD_SIGNATURE/rejected before any routing decision is made.
Signed envelopes and attestation chains¶
A federation-eligible envelope carries an origin signature and zero or more attestations, each a verifiable link in a provenance chain (SPEC §32–33):
- Origin signature (
sign_origin): the gateway that first federates a locally-originated envelope signs its canonical bytes —[id, source.protocol, source.endpoint, sender.native_ref, kind, body, created_at, sorted per-attachment (sha256, filename, mime, size)]— with its own Ed25519 node identity, domain-separated withrelayfabric-envelope-v1:.priorityis deliberately excluded: it's not signed, and a remote peer's claimed priority is stripped/ignored entirely on ingress. - Attestation chain (
append_attestation): every gateway that forwards the envelope — including the origin, on its first hop — appends a link signingdigest(canonical) || prev_sig || timestamp(domainrelayfabric-attest-v1:), chained to the previous link's signature. A receiver walks the whole chain (verify_chain) before accepting the envelope; any broken link dead-letters itBAD_SIGNATURE.
v0.3 signs at the origin gateway only — this is gateway provenance (SPEC §30), not end-user authorship. User-key origin signatures are a v0.4 concern.
Attachment metadata is signed; attachment content is not carried here
The attachment fields covered by the origin signature are sha256/filename/mime/size — no attachment bytes cross the signature or the wire frame this way. See What federation does not carry yet below.
fed: route egress¶
A route whose destination is fed:<peer_name>/<remote_route> is delivered by looking up a live connection for peer_name (a disconnected peer just means "retry in 5 seconds," same as a down plugin), then:
- Egress budget.
federation.peers[].messages_per_minute(0 = unlimited) is checked first, keyedfed/<peer_name>in the same limiter transport budgets use — a peer fed from many distinct local senders is capped as a whole link, since no individual per-sender limit catches that pattern. Unlike transport-budget egress, there is no priority bypass: an emergency-priority envelope queues behind this budget exactly like everything else. - Pseudonymize, then sign — local-origin only. If the envelope has no
originyet (env.origin.is_none(), i.e. this daemon originated it), andfederation.identity_exposureispseudonymous(the default),sender.native_refis replaced with a route-scoped alias before signing — so the origin signature covers what the peer will actually see, never the raw local ref.identity_exposure: fullskips the alias and signs the raw ref instead. - Relay traffic is left untouched. An envelope that already has an
origin(this daemon ingressed it from another peer and is now forwarding it onward) is never re-pseudonymized or re-signed — mutatingsender.native_refon an already-signed envelope would invalidate its origin signature and get it dead-lettered downstream. Only the pass-through gateway's own attestation is appended. - Attest and send. Every hop — origin or relay — appends its own attestation and increments
hops, unconditionally, before the frame goes out asFed::Envelope { env, target_route }.
Replay bounds¶
Federation ingress cannot trust wall-clock arrival time as a freshness signal — only the envelope's signed created_at is trustworthy, because it's covered by the origin signature. Ingress applies two independent checks against it:
- Stale:
created_at + federation.max_ttl_secs < now— an old-enough envelope is rejected even if it was never seen before. - Far-future:
created_at > now + 300s— a clock-skewed or maliciously future-dated envelope is rejected too.
This bounds the replay window to max_ttl_secs regardless of in-memory dedup state: dedup alone would let a captured, genuinely-signed envelope be replayed after the dedup window closes or after a daemon restart clears it. The created_at bound closes that gap independent of dedup.
DoS hardening¶
Federation is reachable by anyone who can open a TCP connection, so the connection layer treats every unauthenticated socket as hostile until proven otherwise:
| Control | Value | What it stops |
|---|---|---|
| Inbound connection cap | 64 concurrent (Semaphore, held for the connection's full lifetime, not just its handshake) |
A connection-flood attacker exhausting file descriptors/memory before any policy is ever evaluated. A socket over the cap is dropped immediately, before spending any CPU on a Noise handshake. |
| Send timeout | 30s per send_frame call |
A peer that completes the handshake and then stops reading (zero-window TCP) stalling write_all — and with it the same task's own ping/dead-timer/rekey checks — forever. |
| Dead timer | 90s since last received frame | A peer that goes silent without closing the socket. |
| Ping interval | 30s | Keeps the dead timer from tripping on an otherwise-healthy idle link. |
A config knob for the connection cap is deferred; today it's a fixed safety ceiling, not operator-tunable.
RFDP discovery¶
RFDP lets a gateway describe what it can do — services, protocols, security posture — without describing how to reach specific people through it. Discovery reuses live federation links; it never opens a separate channel.
The Node Advertisement¶
A Node Advertisement is a signed, expiring capability document, built only from Config at load time — never from a live plugin handle, a route table, or anything else that could leak operational detail:
{
"rf_version": 1,
"node_id": "rf:75bc...",
"name": "DX.PE Pasadena",
"services": { "federation": true, "chat": true, "store_forward": true },
"protocols": {
"lxmf": { "rx": true, "tx": true, "text": true, "files": false, "max_payload": null }
},
"security": {
"translate": true,
"signed": true,
"sealed": true,
"sealed_key": "33333333...64 hex chars"
},
"expires": 1786838400,
"sig": "..."
}
servicesis the union of everypublic_services[].type— the service class (chat,store_forward,telemetry, ...), never a route name — plus"federation": trueunconditionally.protocolscovers every protocol named in anypublic_services[].ingress/.egresslist;text: true,files: false,max_payload: nullfor all of them this cycle — live per-plugin capability enrichment is future work.security.sealed_keyis this node's stable X25519 sealed-routing public key (Security & Sealed Routing).expiresisnow + discovery.advert_ttl_secs.
The whole document is Ed25519-signed by the node identity named in node_id, over a domain-separated (relayfabric-advert-v1:) canonical CBOR tuple — never the struct's raw serialization, so a future field addition can't silently join the signed bytes. A peer can't publish a sealed_key, a service list, or anything else under another node's identity without forging that node's signature.
What must never appear in an advert
SPEC §111.4: no Signal usernames/phone numbers, Meshtastic node IDs, LXMF user identities, device paths, IP addresses/VPN topology, GPS coordinates, identity mappings, or private route names. build_from_config's sourcing rule — config-facts only, nothing live — is what makes this enforceable at the type level rather than by convention.
Wire frames¶
All federation traffic — envelopes and discovery alike — moves as CBOR-tagged frames (t field) over the same Noise-secured connection:
Frame (t) |
Fields | Purpose |
|---|---|---|
envelope |
env, target_route |
A routed message, addressed to a local route on the receiver named target_route. |
ack |
id |
Acknowledges ingress of envelope id. |
ping / pong |
— | Keepalive, 30s interval / 90s dead timer. |
advert |
advert |
This node's current signed Node Advertisement — sent in reply to advert_req, or proactively on the refresh timer. |
advert_req |
— | "Send me your current advert, if you have one." Sent once per side at connection-up, gated by discovery scope. |
sealed |
sealed, target_route |
Sealed-routing egress — an AEAD-sealed payload with a cleartext routing/dedup/expiry header only. See Security & Sealed Routing. |
(unrecognized t) |
— | Decodes to an internal placeholder and is ignored outright — additive versioning, so an older daemon keeps working when a newer peer sends a frame type it doesn't know about yet, instead of tearing the link down. |
Exchange timing¶
- On-connect: each side sends
advert_reqonce, immediately after the handshake completes, gated byadvert_scope_allowsfor that specific peer. - Periodic refresh: a live connection proactively re-sends its own
adverteveryadvert_ttl_secs / 2, so a peer's stored copy never sits stale for more than half its TTL. - A received advert is rejected (and the row left untouched) if it fails signature verification, exceeds a 16 KiB size cap, has a name over 64 characters, or claims an expiry more than 24 hours out — an over-generous TTL claim is clamped down, not treated as fraud on its own.
The peer-adverts store¶
Verified adverts are kept in a peer_adverts table, keyed by node_id:
- Newer-wins:
upsert_peer_advertonly replaces an existing row if the incoming advert'sexpiresis strictly greater than what's stored — a stale or equal-TTL resend never clobbers a fresher record. - Purged hourly: expired rows are swept on the same hourly cadence as retention/challenge purges. This is disk hygiene, not a correctness gate —
list_peer_advertsalready filters to unexpired rows on every read.
Scope gating¶
| Mode | Behavior | Status |
|---|---|---|
disabled (default) |
Advert exchange never happens with any peer — neither sent nor requested. | Implemented |
federation |
Exchanged only with peers whose current trust rank meets federation.accept_from. Recommended default once discovery is turned on. |
Implemented |
public |
Exchanged with any peer that completed the Noise handshake, regardless of trust level. | Implemented |
local |
LAN / local RNS-neighborhood discovery. | Deferred — no LAN transport exists yet; discovery.mode: local is rejected at config load rather than silently accepted. |
advert_ttl_secs has a 300-second floor — validate() rejects anything shorter as a churn/flood footgun.
Serving GET /v1/discovery¶
{
"mode": "federation",
"our_advert": { "...": "this node's own advert, or null if discovery is off" },
"peers": [
{ "node_id": "rf:...", "name": "Desert", "services": {"...": true}, "protocols": {}, "security": {}, "expires": 1786838400, "received_at": "2026-08-17T12:00:00Z" }
]
}
our_advert is built and signed fresh from the live config on every request — the exact function the real fed link calls, so this endpoint can never show a different advert than what peers actually receive. peers[] is every stored, unexpired advert, but nothing is served on trust alone:
- Each row is re-verified against its own embedded signature before being served, independent of the receive-path verification it already passed once when it was stored — a defense against direct database tampering.
- Each row is checked that its embedded
node_idmatches the row key it's stored under — the node-id spoof guard. Without this, a database-write-capable attacker with no victim private key could insert a row keyed to a victim'snode_idcarrying an advert that's validly self-signed under the attacker's own keypair;sigverification alone would pass that straight through as the victim. - The served
nameis re-sanitized (control characters and newlines stripped) from the freshly-decoded advert on every request, never trusted from a cached/pre-sanitized value.
A row that fails any of these checks is silently dropped from the response — never served half-trusted — but left in storage; only the hourly purge sweep deletes rows, and only for expiry, not for a failed re-verification.
Per-peer egress budgets¶
Beyond the connection cap and send timeouts above, each configured peer has its own aggregate egress budget (federation.peers[].messages_per_minute, 0 = unlimited) — see fed: route egress. This is the peer-link-level complement to limits.per_sender and transport_budgets: it catches a flood spread across many distinct local senders that no individual per-sender limit would trip.
What federation does not carry yet: attachment bytes¶
Federated envelopes carry attachment metadata only — sha256, filename, mime, size — the same fields the origin signature covers. The actual attachment bytes live in this daemon's local content-addressed store (CAS) and are not replicated to a federated peer's CAS. A route that spans a federation hop and expects a downstream attachment to be locally deliverable needs that content already reachable on the receiving side (e.g. via a plugin protocol that carries the bytes itself); RelayFabric v0.3 has no CAS blob sync between federated gateways. Treat federated attachment references as advisory metadata until a future cycle adds blob replication.
Sealed routing composes with federation¶
Federation links are also the transport sealed routing rides on: a Fed::Sealed frame carries an AEAD-encrypted payload (x25519-xchacha20poly1305-v1) addressed to a peer's stable sealed_key — published in that peer's own advert, or pinned explicitly via federation.peers[].sealed_key — with only routing metadata (id, expires_at, target_route) left in the clear. Federation's Noise link, trust store, and attestation chain all still apply underneath; sealing is an additional payload-confidentiality layer on top, not a replacement for any of them. v0.3 sealed routing is phase 1: direct-peer only, no relay-through yet. See Security & Sealed Routing for the full model.
Configuration reference¶
node:
data_dir: /var/lib/relayfabric
federation:
listen: "0.0.0.0:7890" # omit for outbound-only (no listener bound)
accept_from: verified # minimum trust rank required to accept inbound envelopes
max_hops: 4
max_ttl_secs: 86400
identity_exposure: pseudonymous # or "full"
ingress_routes: [regional-chat] # local routes a federated peer may inject into
peers:
- name: desert
node_id: "rf:aaaaaaaa...64 hex chars"
addr: "desert.example.net:7890"
trust: verified # or "trusted"
messages_per_minute: 120 # 0 = unlimited
sealed_key: null # optional pin; otherwise learned from the peer's advert
trusted: [] # extra node_ids seeded "trusted" at boot
blocked: [] # node_ids refused at handshake — sticky
discovery:
mode: federation # disabled | federation | public
advert_ttl_secs: 3600 # minimum 300
See Configuration for the full config schema and validation rules, and Operations for switchyardctl federation/discovery inspection commands and the relayfabric_federation_*/relayfabric_advert_* Prometheus counters.
See also¶
- Security & Sealed Routing — content-security modes, sealed routing's threat model and guarantees.
- Operations —
GET /v1/federation,GET /v1/discovery, metrics, and message tracing across a federated hop. - Configuration — the full
federation/discovery/privacyconfig reference.