Chio/Docs

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

Iroh is implemented and tested, but is disabled by default. Enable it with the --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_id sender value with a cryptographically authenticated EndpointId.

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

The accept-time gate rejects unknown peers early and supplies an authenticated 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.

rust
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:

CheckRuleIdentityError
Schema pinBundle, body, and directory schema all equal chio.federation.transport.iroh.peer-directory-bundle.v2.UnsupportedSchema
Rollback gateversion > version_floor and previous_version_sha256 chains onto the expected predecessor.Rollback, PreviousVersionMismatch
Validity windownow ∈ [issued_at_unix_ms, expires_at_unix_ms).OutsideValidityWindow
Body-hash pinRecomputed canonical SHA-256 of the directory equals the signed directory_sha256.BodyHashMismatch
Issuer signatureBody is signed by a pinned TrustedTransportDirectoryIssuer matched on (issuer, key_id).UnknownIssuer, SignatureInvalid
Per-entry endorsementEach 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.

transport-directory-bundle.json
{
  "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:

LaneTransportALPN / topic
(a) Pheromone directed batchesDirect per-peer QUIC streamchio/federation/pheromone-batch/1
(b) Revocation epoch rootsDirect per-peer QUIC streamchio/federation/revocation-root/1
(c) Cross-operator fan-outiroh-gossip, per-treaty topiciroh_gossip::ALPN
(d) Bilateral DSSE co-signBidirectional QUIC RPCchio/federation/bilateral-dsse-cosign/1
(e) Catch-up historyiroh-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

A gossip topic exposes traffic to its members, so its membership set is a confidentiality boundary. Binding 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:

DefaultValueBounds
DEFAULT_ACCEPT_STREAM_TIMEOUT30 saccept_bi()
DEFAULT_READ_TIMEOUT30 sReading the one request frame
DEFAULT_WRITE_TIMEOUT30 sWriting the response frame
DEFAULT_LINGER_TIMEOUT60 sPost-exchange conn.closed() linger
DEFAULT_MAX_IN_FLIGHT1024Concurrent accept handlers per lane
DEFAULT_MAX_IN_FLIGHT_PER_PEER16Concurrent handlers per EndpointId
DEFAULT_SHED_WAIT250 msBounded 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_*:

text
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                      # gauge

The 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:

Cargo.toml
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-up

Keep 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

bash
$ 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_gossip

Related federation surfaces

The transport carries the same wire protocols as the HTTP federation path. See Federation Overview for the trust model this transport plugs under, and Bilateral Federation for the operator-to-operator contract it dials between.