PlatformFederation
Iroh Federation Transport
Use QUIC to authenticate a peer EndpointId and check it against an issuer-signed directory before processing a federation protocol.
Feature-gated transport
--features iroh cargo feature when a deployment requires it. A CI guard ensures the feature stays out of default builds. Iroh authenticates a transport key; authorization remains in the layers above it, including signature, treaty-scope, quorum, and revocation checks. See Federation & Swarms for the concept-level framing this page implements.Component boundary
The crate chio-federation-transport-iroh sits beneath chio-federation and does not replace its trust logic. Responsibilities are divided as follows:
- iroh authenticates the peer key. A completed QUIC/TLS handshake proves the remote holds the private key for a given
EndpointId(a 32-byte Ed25519 public key). That is all it proves. - Chio authorizes above it. Deposit and root signature verification, agent-passport resolution, treaty scope, quorum, scarcity caps, and freshness remain above the transport. The adapter replaces an opaque
kernel_idsender value with a cryptographically authenticatedEndpointId.
The adapter populates the field read by the per-frame verifier: authenticated_sender_kernel_id on PheromoneGossipBatchVerificationContext, checked against every frame's gossiping_peer_kernel_id and, for direct frames, origin_kernel_id. The adapter's job is to populate that one field from an authenticated EndpointId, resolved through a verified directory. The per-frame check stays untouched above it.
Keep the admission checks
EndpointId). The issuer-signed directory supplies the other half (the EndpointId → kernel_id resolution. The per-frame batch verifier in chio-federation keeps running runs above both checks.The accept-time admission gate
admission::DirectoryGate is the connection gate shared by the protocol handlers. It implements iroh's EndpointHooks: inside after_handshake it resolves conn.remote_id() against the load-time-verified directory and rejects any EndpointId not bound to an admitted, non-removed kernel_id. The reject fires before any ProtocolHandler::accept runs.
use chio_federation_transport_iroh::admission::{
DirectoryGate, NOT_ADMITTED_ERROR_CODE, NOT_ADMITTED_REASON,
};
use std::sync::Arc;
// verify_bundle ran at load and produced an Arc<VerifiedDirectory>.
let gate = DirectoryGate::new(directory);
// Registered once; applies to both the connect and accept side.
let endpoint = iroh::Endpoint::builder()
.hooks(gate.clone())
.bind()
.await?;
// NOT_ADMITTED_ERROR_CODE == 403; NOT_ADMITTED_REASON == b"not admitted".
// An unbound, unknown, or tombstoned EndpointId is Reject { error_code, reason }.The gate is built only from an Arc<VerifiedDirectory>, so it never resolves against an unverified bundle. DirectoryGate::swap replaces the directory behind a single-writer atomic swap, backing live reload without a lock on the hot admission path; DirectoryGate::resolve returns the bound kernel_id a lane handler uses to populate authenticated_sender_kernel_id.
The issuer-signed directory
The identity module verifies an issuer-signed transport-directory bundle at load time and builds the VerifiedDirectory used by the other components. TransportDirectoryBundleDocument::verify_bundle runs five fail-closed checks in order, and yields no directory on any failure:
| Check | Rule | IdentityError |
|---|---|---|
| Schema pin | Bundle, body, and directory schema all equal chio.federation.transport.iroh.peer-directory-bundle.v2. | UnsupportedSchema |
| Rollback gate | version > version_floor and previous_version_sha256 chains onto the expected predecessor. | Rollback, PreviousVersionMismatch |
| Validity window | now ∈ [issued_at_unix_ms, expires_at_unix_ms). | OutsideValidityWindow |
| Body-hash pin | Recomputed canonical SHA-256 of the directory equals the signed directory_sha256. | BodyHashMismatch |
| Issuer signature | Body is signed by a pinned TrustedTransportDirectoryIssuer matched on (issuer, key_id). | UnknownIssuer, SignatureInvalid |
| Per-entry endorsement | Each peer's passport endorses its transport EndpointId (and any oracle signer key). | EndorsementInvalid, OracleEndorsementInvalid |
Trust inputs arrive in TransportDirectoryBundleTrust: the pinned issuer set, the rollback version_floor, the expected predecessor hash, and now_unix_ms. The resulting VerifiedDirectory answers authorize(endpoint), resolve_transport_endpoint(kernel_id), resolve_passport_key(kernel_id), and is_treaty_party(treaty_id, kernel_id).
Option B key binding
An iroh EndpointId is Ed25519 only, so a P-256, P-384, ML-DSA-65, or hybrid passport key cannot itself be an EndpointId. Each directory entry therefore carries two keys: the long-term operator passport_public_key (any algorithm) and a rotatable Ed25519 transport_endpoint_id, cross-linked by a passport-signed endorsement. The endorsement signs the exact bytes returned by transport_endorsement_preimage (the domain tag chio.iroh.transport-endorsement.v1, the entry kernel_id, and the transport EndpointId, each length-prefixed) so the endorsement commits to which operator owns which endpoint. A distinct domain tag guards the oracle-signer endorsement, so the two endorsement kinds can never cross-replay.
{
"schema": "chio.federation.transport.iroh.peer-directory-bundle.v2",
"body": {
"schema": "chio.federation.transport.iroh.peer-directory-bundle.v2",
"issuer": "did:chio:org-a-directory",
"keyId": "dir-2026-q3",
"directorySha256": "5b41362bc82b7f3d56edc5a306db22105707d01ff4819e26faef9724a2d406c9",
"version": 42,
"previousVersionSha256": "9a4f1c3b2e7d8a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b",
"issuedAtUnixMs": 1776970000000,
"expiresAtUnixMs": 1776973600000
},
"directory": {
"schema": "chio.federation.transport.iroh.peer-directory-bundle.v2",
"localKernelId": "did:chio:org-a-soc",
"peers": [
{
"kernelId": "did:chio:org-b-soc",
"passportPublicKey": "ed25519:80f2b577472e6662f46ac2e029f4b2d1300f889bc767b3de1f7b63a4c562fd8f",
"transportEndpointId": "3c7e1a9d5b0f2e8c4a6d1f3b9e7c5a2d0f8b6e4c1a9d7f5b3e2c0a8d6f4b2e0c",
"passportEndorsement": "ed25519:7e2b1f3a...c104",
"revocationSigners": [],
"removed": false
}
],
"treaties": [
{
"treatyId": "treaty:cybersec.regional-soc.v1",
"partyKernelIds": ["did:chio:org-a-soc", "did:chio:org-b-soc"]
}
]
},
"signature": "ed25519:MEUCIQDxK8b3...A=="
}A removed entry never resolves (fail-closed tombstone) and suppresses its revocation_signers from the derived signer directory, so evicting an operator revokes its oracle signer in the same bundle.
Protocol handlers
Each of chio-federation's wire protocols use separate handlers behind the shared gate, with one ALPN per handler. The transport follows the underlying contract:
| Lane | Transport | ALPN / topic |
|---|---|---|
| (a) Pheromone directed batches | Direct per-peer QUIC stream | chio/federation/pheromone-batch/1 |
| (b) Revocation epoch roots | Direct per-peer QUIC stream | chio/federation/revocation-root/1 |
| (c) Cross-operator fan-out | iroh-gossip, per-treaty topic | iroh_gossip::ALPN |
| (d) Bilateral DSSE co-sign | Bidirectional QUIC RPC | chio/federation/bilateral-dsse-cosign/1 |
| (e) Catch-up history | iroh-blobs (content-addressed) | iroh_blobs::ALPN |
Pheromone (lane a)
The directed handler drains existing per-recipient push-queue batches over direct per-peer QUIC streams, reusing the chio-pheromone-relay SQLite outbox verbatim; only the wire hop is substituted. mount_pheromone_lane(endpoint, handler) returns an iroh Router keyed on the pheromone ALPN; enqueue_batch_for_delivery and drain_outbox_over_iroh feed it. The PheromoneBatchHandler reuses chio-pheromone-relay's RelayBatchReceiver trait so the per-frame verifier that this page's Pheromone protocol describes runs unchanged above the transport.
Revocation (lane b)
Revocation carries no directory, endpoint, or public key in its wire types, so the lane introduces a new signer_id → EndpointId binding. A RevocationSignerEntry pins an Ed25519 oracle_public_key to the operator that owns the enclosing directory entry, endorsed under the distinct chio.iroh.revocation-signer-endorsement.v1 tag. A non-Ed25519 oracle key is rejected at bundle-verify time (NonEd25519OracleKey) rather than accepted and silently failing every root verify, since lane-b roots are checked by an Ed25519RootVerifier from chio-revocation-oracle.
Fan-out (lane c)
The many-to-many handler uses iroh-gossip with one topic per treaty. The topic ID is derived locally: topic_for_treaty(label, treaty_id) computes TopicId = blake3(label ‖ 0x00 ‖ treaty_id_bytes), with the pheromone surface using PHEROMONE_FANOUT_TOPIC_LABEL (chio-pheromone-gossip/v1).
Use one topic per treaty
TopicId to a treaty limits a topic to that treaty's subscription set. TheTopicId is deterministic and does not grant access: membership is still checked by the accept-time EndpointId gate plus the issuer-signed TransportTreatyEntry party set.Fan-out payloads MUST be self-signed and origin-verified from the payload alone. iroh-gossip's Message::delivered_from is the forwarding neighbor, not the author, so verify_fanout_frame re-derives the exact preimage the deposit signs and verifies it against the origin's pinned key rather than trusting the sender. Anything over the MAX_GOSSIP_MESSAGE_SIZE of 4096 bytes goes out of band via the blob lane.
Bilateral co-sign (lane d)
Interactive DSSE co-signing runs over one bidirectional QUIC stream on a dedicated ALPN. IrohBilateralCoSigner implements chio_federation::bilateral::BilateralCoSigningProtocol, so a networked co-signer substitutes directly for chio-federation's in-process InProcessCoSigner. The requester opens the bidi stream, writes one length-delimited canonical DsseCoSigningRequest (with its signature over the PAE bytes), and half-closes the send half. The responder asserts its directory-resolved EndpointId matches the claimed counterparty, verifies the requester signature against the pinned passport key, re-checks trust and the rotation window, and only then signs the same PAE bytes and writes the DsseCoSigningResponse. On any failure it writes a typed BilateralCoSigningError and resets without signing. See Bilateral Co-Sign for the receipt the two signatures commit to.
Catch-up (lane e)
Bulk signed epoch roots are served content-addressed over iroh-blobs so catch-up does not have to inline a large history on the bounded lane-b stream. A responder advertises a RevocationCatchupManifest (schema chio.federation.transport.iroh.revocation-catchup-manifest.v1) of (signer_id, epoch, blob_hash) entries; BlobCatchupClient::fetch_from_manifest downloads each root by its BLAKE3 address into an FsStore so history survives a restart.
A content address gives integrity, not authenticity, so the follower still validates the full contract: validate() rejects a mixed-signer manifest and enforces strict monotone contiguous epochs, raising CatchupGap on a hole exactly as RevocationCatchupResponse::validate_response does; every fetched SignedEpochRoot is BLAKE3-re-checked against its address and signature-verified against the pinned signer before merging into BlobBackedHistory. A rejected range leaves history empty (all-or-nothing). The epoch count is capped at REVOCATION_CATCHUP_MAX_EPOCHS = 4096.
Accept-side hardening
The three direct lanes share lanes::limits::AcceptLimiter to bound accept paths against slowloris and resource exhaustion. AcceptLimitConfig carries a per-phase timeout for each AcceptPhase (AcceptStream, ReadFrame, WriteResponse) plus a lane-wide and a per-peer concurrency cap:
| Default | Value | Bounds |
|---|---|---|
DEFAULT_ACCEPT_STREAM_TIMEOUT | 30 s | accept_bi() |
DEFAULT_READ_TIMEOUT | 30 s | Reading the one request frame |
DEFAULT_WRITE_TIMEOUT | 30 s | Writing the response frame |
DEFAULT_LINGER_TIMEOUT | 60 s | Post-exchange conn.closed() linger |
DEFAULT_MAX_IN_FLIGHT | 1024 | Concurrent accept handlers per lane |
DEFAULT_MAX_IN_FLIGHT_PER_PEER | 16 | Concurrent handlers per EndpointId |
DEFAULT_SHED_WAIT | 250 ms | Bounded wait for a permit before shedding |
A shed or timeout closes with a bounded application code: LANE_RESET_CLOSE_CODE (1), ACCEPT_TIMEOUT_CLOSE_CODE (2), or ACCEPT_BUSY_CLOSE_CODE (3). Every handler is attached with with_accept_limits.
Observability
The slim iroh build drops iroh's own metrics feature, so the crate emits its own counters through the workspace metrics registry. render_iroh_transport_metrics_prometheus returns a text-format block the caller's /metrics exporter concatenates, and observability::lane_accept_span opens a tracing span per accept. Every family is chio_federation_transport_*:
chio_federation_transport_admission_total{outcome} # accept | reject (probe signal)
chio_federation_transport_verify_failures_total{seam,reason}
chio_federation_transport_lane_total{lane,outcome} # accept|reject|busy|timeout|lagged
chio_federation_transport_catchup_epoch_gap_total{source}
chio_federation_transport_outbox_total{outcome} # delivered|retried|dead_lettered
chio_federation_transport_directory_reload_total{outcome}
chio_federation_transport_accept_open{lane} # gauge: in-flight accept handlers
chio_federation_transport_accept_duration_seconds{lane} # histogram: accept-handler tail
chio_federation_transport_router_alive # gaugeThe counters record events without changing a trust decision. Handler labels are the bounded vocabulary pheromone, revocation, bilateral, fanout. An admission_total{outcome="reject"} spike is someone probing the mesh; a climbing accept_open with a ballooning accept_duration_seconds tail is a slow-trickle campaign.
Build and dependency surface
Iroh 1.0 defaults pull a large dependency graph. This crate uses one TLS backend and excludes Apple- and UPnP-specific features:
iroh = { version = "1.0", default-features = false, features = ["tls-ring"] }
iroh-gossip = "0.101" # lane c fan-out
iroh-blobs = "0.103" # lane e content-addressed catch-upKeep one of tls-ring or tls-aws-lc-rs; drop metrics, portmapper, and fast-apple-datapath. Do not ship test-utils. It pulls a relay server and the dev-only insecure_skip_verify path. A durable mesh self-hosts its relays (RelayMode::Custom(RelayMap) with a real or own-CA cert) and pins discovery, so a dial-by-EndpointId path touches no third-party infrastructure. An iroh relay is content-blind: it sees only EndpointId pairs and byte counts, so relay self-hosting is about availability and metadata-minimization, not trust.
Testing
$ cargo test -p chio-federation-transport-iroh
# Each lane ships a runnable, deterministic example that doubles as a smoke
# test and exits non-zero if its fail-closed invariant is violated.
$ cargo run -p chio-federation-transport-iroh --example admission_gate
$ cargo run -p chio-federation-transport-iroh --example pheromone_exchange
$ cargo run -p chio-federation-transport-iroh --example revocation_catchup
$ cargo run -p chio-federation-transport-iroh --example bilateral_cosign
$ cargo run -p chio-federation-transport-iroh --example fanout_gossipRelated federation surfaces