BuildIdentity
Bootstrap Federated Trust
Establish trust between two Chio kernels: exchange anchors, pin peer keys, complete the handshake, and configure co-signing.
Two schemas and one transport you supply
chio-federation crate. The handshake envelope schema is chio.federation-kernel-handshake.v1, and the co-signed receipt schema is chio.federation-dual-signed-receipt.v1. Neither the transport that carries an envelope between operators nor the store that keeps pinned peers across restarts comes from the crate: the default peer store is in memory, and moving an envelope is your code's job.Why Federate
A single Chio deployment already produces signed receipts and enforces local policy. Federation answers a different question: when an agent in Org A invokes a tool hosted by Org B, can both sides independently verify what happened, and can either side deny a forged receipt later?
Before you federate passports, share evidence, or co-sign receipts, each kernel needs the other's signing-key hash pinned locally and refreshed within a rotation window. See Federation Overview for the related federation APIs.
Prerequisites
Before either operator runs a handshake, both sides need three things in place:
- A kernel signing keypair. Each operator already holds an Ed25519 keypair that signs receipts. The handshake pins the public half of that key on the remote side, so the keys you pin are the keys that sign cross-org receipts.
- A stable kernel identifier. A short string like
kernel.org-athat uniquely names your kernel to peers. The kernel exposesset_federation_local_kernel_idfor this; otherwise it falls back to the hex encoding of the signing public key. - An out-of-band trust anchor. Before first contact, each side must have received the other's expected public key through a channel it trusts (shared control plane, signed onboarding document, sneakernet). The handshake refuses first-contact envelopes from an unpinned, un-anchored peer fail-closed with
MissingTrustAnchor.
No discovery means no trust
Step 1: Exchange Trust Anchors
Both operators agree on two kernel identifiers and swap Ed25519 public keys through a channel they already trust. On each side you instantiate a KernelTrustExchange bound to your local keypair and pre-seed the expected remote public key with with_trusted_peer.
use chio_core_types::crypto::{Keypair, PublicKey};
use chio_federation::trust_establishment::KernelTrustExchange;
// Org A side.
let local_keypair = load_local_kernel_keypair()?;
let org_b_public_key: PublicKey = load_remote_anchor("org-b")?;
let exchange = KernelTrustExchange::new(
"kernel.org-a",
local_keypair,
)
.with_trusted_peer("kernel.org-b", org_b_public_key);The exchange owns an in-memory InMemoryPeerStore by default. Long-lived deployments should replace it via .with_store(Box::new(my_store)) with any type that implements the FederationPeerStore trait so pinned peers survive restarts.
You can also tune the freshness window and clock-skew tolerance at construction time:
use chio_federation::trust_establishment::{KernelTrustExchange, KernelTrustExchangeConfig};
let exchange = KernelTrustExchange::new("kernel.org-a", local_keypair)
.with_config(KernelTrustExchangeConfig {
// Default: 12 * 60 * 60 (twelve hours).
rotation_window_secs: 12 * 60 * 60,
// Default: 5 * 60 (five minutes).
max_handshake_skew_secs: 5 * 60,
})
.with_trusted_peer("kernel.org-b", org_b_public_key);The defaults (DEFAULT_ROTATION_WINDOW_SECS = 12 hours, DEFAULT_HANDSHAKE_MAX_SKEW_SECS = 5 minutes) allow a twelve-hour pin lifetime and reject envelopes whose timestamps differ by more than five minutes.
Step 2: Issue the Handshake Envelope
Each side builds a PeerHandshakeEnvelope addressed to its counterpart. The envelope wraps a signed HandshakeChallenge binding the two kernel ids, a fresh nonce, and the current timestamp. The exchange signs the challenge with the local kernel key.
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
// Build our signed envelope addressed to the remote kernel.
let local_envelope = exchange.local_envelope(
"kernel.org-b", // remote_kernel_id
"nonce-2026-04-21-01", // caller-supplied nonce
now,
)?;
// Serialize and ship it over whatever authenticated transport you use
// between trust control planes (mTLS RPC, signed message queue, etc.).
let wire = serde_json::to_vec(&local_envelope)?;
transport.send("kernel.org-b", &wire).await?;The nonce is caller-supplied and the crate does not remember the ones it has seen, so replay protection has to come from the transport or from an idempotency window upstream. Any value that is unique across retries inside the skew window is fine; UUID v7 or a counter plus a random suffix both work.
Serialized, the envelope is three fields, and the challenge inside it renders in camelCase because both structs carry rename_all = "camelCase":
challenge.schema:chio.federation-kernel-handshake.v1challenge.localKernelIdandchallenge.remoteKernelId: the directed pair for this envelopechallenge.nonceandchallenge.timestamp: freshness material, checked on the accepting sidechallenge.capabilities,challenge.conformanceTierandchallenge.ladderManifestRef: all three are omitted from the wire at their default, so a Bronze handshake carries noconformanceTierkey at alldeclaredPublicKey: the public half the signer claims to be, bare lowercase hexsignature: Ed25519 signature over the canonical JSON of the challenge
Both kernels perform the same step in parallel. The handshake is mutual: Org A signs and sends; Org B signs and sends; each side processes the envelope it received.
Nothing about the handshake needs two hosts. Build two KernelTrustExchange values in one process, anchor each to the other's public key, pin a single now, and hand each side's envelope to the other. The crate's own test does exactly that, and it is the cheapest place to find a mismatched anchor because there is no transport, no clock, and no kernel in the way:
fn handshake_succeeds_and_pins_both_sides() {
let kp_a = Keypair::generate();
let kp_b = Keypair::generate();
let now: u64 = 1_800_000_000;
let exchange_a = KernelTrustExchange::new("kernel.org-a", kp_a.clone())
.with_trusted_peer("kernel.org-b", kp_b.public_key());
let exchange_b = KernelTrustExchange::new("kernel.org-b", kp_b.clone())
.with_trusted_peer("kernel.org-a", kp_a.public_key());
// Each side builds its own signed envelope.
let envelope_a = exchange_a
.local_envelope("kernel.org-b", "nonce-a", now)
.unwrap();
let envelope_b = exchange_b
.local_envelope("kernel.org-a", "nonce-b", now)
.unwrap();
// Each side verifies and pins the remote.
let peer_b = exchange_a
.accept_envelope(&envelope_b, "kernel.org-b", now)
.unwrap();
let peer_a = exchange_b
.accept_envelope(&envelope_a, "kernel.org-a", now)
.unwrap();
assert_eq!(peer_b.kernel_id, "kernel.org-b");
assert_eq!(peer_a.kernel_id, "kernel.org-a");
assert_eq!(peer_b.conformance_tier, ConformanceTier::Bronze);
assert_eq!(peer_a.conformance_tier, ConformanceTier::Bronze);
assert!(peer_b.rotation_due > now);
assert!(peer_a.rotation_due > now);
// Resolve while fresh succeeds.
let resolved = exchange_a.resolve("kernel.org-b", now + 60).unwrap();
assert_eq!(resolved.public_key.to_hex(), kp_b.public_key().to_hex());
}Step 3: Verify and Pin the Peer
When an envelope arrives, call accept_envelope with the kernel id you expected the envelope to come from. The method performs six fail-closed checks before pinning:
- the Ed25519 signature verifies against the declared public key,
- the envelope is addressed to the local kernel id (no confused deputy),
- the declared remote kernel id matches the id you passed in (no silent identity swap),
- the timestamp is within
max_handshake_skew_secsof local clock time, - the declared public key equals either the pre-configured trust anchor or the already-pinned peer key (first-contact without an anchor is refused),
- and the tier the envelope carries meets the quorum policy floor. The default policy accepts Bronze, so this check is invisible until you raise the floor.
let remote_envelope: PeerHandshakeEnvelope = serde_json::from_slice(&incoming)?;
let pinned_peer = exchange.accept_envelope(
&remote_envelope,
"kernel.org-b", // expected_remote_kernel_id
now,
)?;
println!(
"pinned {} until {} (window = {}s)",
pinned_peer.kernel_id,
pinned_peer.rotation_due,
exchange.rotation_window_secs(),
);On success, accept_envelope writes a FederationPeer into the peer store with established_at = now and rotation_due = now + rotation_window_secs. Every failure mode raises a typed PeerHandshakeError; see Troubleshooting for the error table.
Refreshing a pin is another handshake
rotation_due elapses, the peer is treated as stale and resolve returns PeerStale fail-closed. The two kernels must re-run Step 2 plus Step 3 to re-pin with a later rotation_due. Schedule this at roughly half the window so the next handshake has slack.Once the peer is pinned, hand the snapshot to your running kernel so cross-org requests can be resolved against it. The kernel exposes a builder-style entry point:
use chio_kernel::ChioKernel;
let peers = exchange.peers()?; // Vec<FederationPeer>
let kernel = ChioKernel::new(config)
.with_federation_peers(peers);
kernel.set_federation_local_kernel_id("kernel.org-a");Conformance Tiers and Quorum Policy
Each handshake also advertises a ConformanceTier and the accepting side can refuse a peer whose tier is below a configured floor. The tier is ordered Gold > Silver > Bronze, so a policy check is ordinary comparison and fails closed when a peer sits under the floor.
A tier is asserted, not proved. What travels in the handshake is a bare ConformanceTier value the sending operator set, and the accepting side compares it against a floor and pins whatever it was told. There is a helper for computing the value honestly: ConformanceEvidence carries threat_coverage_bps, mutation_kill_bps, and kani_trust_boundary_crates, and derive_tier() maps them onto a stable Bronze/Silver/Gold band. Nothing in the handshake path calls it. Treat a peer's advertised tier the way you treat any self-reported claim, and pair the floor with evidence you obtained some other way.
use chio_federation::trust_establishment::{ConformanceEvidence, ConformanceTier};
let evidence = ConformanceEvidence {
threat_coverage_bps: 10_000, // 100%
mutation_kill_bps: 8_200, // 82%
kani_trust_boundary_crates: 9,
};
let tier: ConformanceTier = evidence.derive_tier()?; // ConformanceTier::GoldThe local side advertises its tier by constructing the exchange with with_conformance_tier; the default is ConformanceTier::Bronze. At the default the field is skipped on serialization, so a Bronze envelope carries no tier key and the accepting side reads the same default back.
let exchange = KernelTrustExchange::new("kernel.org-a", local_keypair)
.with_conformance_tier(ConformanceTier::Gold)
.with_trusted_peer("kernel.org-b", org_b_public_key);The accepting side gates on a QuorumPolicy. The plain accept_envelope used in Step 3 delegates to accept_envelope_with_policy with QuorumPolicy::default(), whose min_tier is Bronze, which is why the basic example admits any schema-valid peer. To require a higher tier, call the policy-aware variant directly.
use chio_federation::trust_establishment::QuorumPolicy;
// Refuse any peer below Silver.
let policy = QuorumPolicy { min_tier: ConformanceTier::Silver };
let pinned_peer = exchange.accept_envelope_with_policy(
&remote_envelope,
"kernel.org-b",
now,
&policy,
)?;A below-floor peer raises PeerHandshakeError::ConformanceTierBelowMinimum carrying the offending kernel_id, the peer's actual tier, and the policy's minimum. The pinned FederationPeer records the tier that was signed into its most recent accepted handshake, and nothing ratchets: a later handshake can record a lower tier than the one before it, as long as it still clears the floor in force at that moment.
A handshake may also carry an optional ladder_manifest_ref, a LadderManifestRef pinning a governance-ladder manifest by manifest_id, sha256, and a validity window (issued_at_unix_ms / expires_at_unix_ms). Attach one with with_ladder_manifest_ref when the two kernels co-govern under a shared ladder; it is validated on accept and travels with the pinned peer.
Step 4: Enable Bilateral Co-Signing
Pinning peer keys makes identity verifiable. Bilateral co-signing is the second half of the contract: when an agent in Org A calls a tool hosted by Org B, both kernels sign the same receipt, so either org can later verify the chain without the other being online.
Wire the tool-host kernel with a BilateralCoSigningProtocol implementation. For production, install the shipped networked co-signer IrohBilateralCoSigner from chio-federation-transport-iroh, which carries the co-signing exchange to the origin kernel over QUIC. Peer admission is fail-closed: a DirectoryGate resolves the authenticated EndpointId to a kernel_id through an issuer-signed transport directory before any co-signing handler runs, so an unadmitted endpoint never reaches the co-signer. Reserve InProcessCoSigner for single-host tests and integration environments, as below.
use std::sync::Arc;
use chio_federation::bilateral::InProcessCoSigner;
// set_federation_cosigner takes &mut self, so the kernel binding is mutable.
let mut kernel = kernel;
// On the tool-host (Org B) kernel, install a cosigner that knows how to
// reach the origin (Org A) kernel.
kernel.set_federation_cosigner(Arc::new(InProcessCoSigner::new(
"kernel.org-a", // origin_kernel_id
origin_keypair.clone(), // test/single-host only; prod uses IrohBilateralCoSigner
tool_host_public_key, // origin verifies Org B's sig before co-signing
)));Two more things the tool-host kernel needs
federated_origin_kernel_id is denied before dispatch unless the kernel also has a durable receipt store and a treaty-bound runtime admission hook. Without the store the refusal reads federated receipt persistence unavailable: no durable receipt store configured; without the admission context it reads chio treaty-bound runtime admission context missing and the co-signer refuses to mint an envelope with federation runtime treaty material missing; refusing treaty-bound DSSE. A cosigner alone is not enough to make the call below succeed.Cross-org requests are marked on the request itself. The kernel's ToolCallRequest carries an optional federated_origin_kernel_id. When set and the peer is pinned fresh, the post-sign hook dispatches the local receipt to the cosigner and assembles two records for each federated call: the DSSE (in-toto) envelope via bilateral_dsse::sign_chio_bilateral_dsse_envelope_with_cosigner and a compatibility DualSignedReceipt via co_sign_with_origin. Both are stashed on the kernel, keyed by the underlying receipt id.
let mut request = build_tool_call_request(/* ... */);
request.federated_origin_kernel_id = Some("kernel.org-a".to_string());
let response = kernel.evaluate_tool_call_blocking(&request)?;
assert_eq!(response.verdict, Verdict::Allow);
// Canonical path: the DSSE envelope is the authorization and audit artifact
// for the bilateral invocation. Retrieve and verify it with the strict Chio
// bilateral verifier against both pinned peer keys (org A = origin, org B =
// tool host).
let envelope = kernel
.federation_dsse_envelope(&response.receipt.id)
.expect("federated call must produce a DSSE envelope");
let statement = chio_federation::bilateral_dsse::verify_chio_bilateral_dsse_envelope(
&envelope,
&origin_public_key,
&tool_host_public_key,
)?;
// The same call also emits a compatibility DualSignedReceipt keyed by the
// same receipt id. It is a compatibility artifact only: its verify* is the
// older detached-signature adapter, not a DSSE verifier, and must not be
// used as the authorization or audit verifier for the signature-slice
// profile.
let dual = kernel
.dual_signed_receipt(&response.receipt.id)
.expect("federated call also produces a compatibility dual-signed receipt");The DSSE envelope carries an in-toto statement over the bilateral invocation predicate, signed by both the origin and tool-host kernels. The predicate covers request and outcome hashes, the ordered signer kernel ids, and (for treaty-bound hops) a treaty binding reference. verify_chio_bilateral_dsse_envelope returns the decoded DsseStatement only when both signatures validate against the declared kernel ids.
The compatibility DualSignedReceipt contains the original ChioReceipt untouched plus two detached signatures over the canonical CoSigningBody: one from the origin kernel (org_a_signature) and one from the tool-host kernel (org_b_signature). The base receipt still verifies in isolation. Keep this record for consumers that have not migrated to the DSSE profile; do not treat it as the authorization or audit verifier.
Both halves are required, and supply the ids yourself
verify_chio_bilateral_dsse_envelope (canonical) and DualSignedReceipt::verify (compatibility) each succeed only when both signatures validate, and each binds a signature to a key fingerprint rather than to a kernel id. Swapping either key for an attacker's raises OrgASignatureInvalid or OrgBSignatureInvalid. Note that DualSignedReceipt::verify reads the peer ids out of the artifact it is checking, so at a trust boundary prefer verify_pinned, where the verifier supplies the ids it expects.Verify the Federation Works
Start below the kernel. The trust-establishment suite runs the whole handshake in one process, and its eighteen cases are the checks Steps 2 and 3 describe, one test per refusal:
$ cargo test --release -p chio-federation --test trust_establishment \
-- --test-threads 1running 18 tests test accept_rejects_clock_skew ... ok test accept_rejects_tampered_signature ... ok test accept_rejects_untrusted_first_contact ... ok test accept_rejects_wrong_addressee ... ok test aggregate_budget_negotiation_requires_both_peers ... ok test conformance_evidence_derives_tiers_from_thresholds ... ok test conformance_evidence_rejects_impossible_percentages ... ok test conformance_tier_is_signed_and_pinned_at_handshake ... ok test cumulative_approval_negotiation_requires_both_peers ... ok test default_handshake_omits_v1_compatibility_fields ... ok test explicit_t1_capabilities_are_signed_when_requested ... ok test freshness_rotation_reissues_pin ... ok test handshake_succeeds_and_pins_both_sides ... ok test ladder_manifest_ref_is_signed_and_pinned_when_requested ... ok test quorum_policy_rejects_peer_below_min_tier ... ok test resolve_unknown_peer_fails_closed ... ok test stale_peer_is_rejected_fail_closed ... ok test untrusted_peer_cannot_probe_quorum_tier_floor ... ok test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
Read the names as the contract. handshake_succeeds_and_pins_both_sides is the happy path; the four accept_rejects_* cases are the signature, addressee, first-contact and skew gates; stale_peer_is_rejected_fail_closed and resolve_unknown_peer_fails_closed are the two resolve refusals; and default_handshake_omits_v1_compatibility_fields is the reason a Bronze envelope carries no tier key.
Then, with peers pinned, a receipt store attached, a treaty-bound admission hook installed, and a cosigner wired, run a smoke test end to end. Check the following:
- Peer snapshot.
kernel.federation_peers_snapshot()returns the peer you pinned with arotation_duein the future. - Fresh lookup. On the tool-host kernel, the peer that gates a federated call is the origin, so check the id you will put in
federated_origin_kernel_id:kernel.federation_peer("kernel.org-a", now)returnsSome(_)while fresh andNonepast the rotation deadline. - Federated tool call. Send a request with
federated_origin_kernel_idset. The verdict should beAllow(or whatever the local policy dictates) andfederation_dsse_envelope(&receipt.id)must return aDsseEnvelope(withdual_signed_receipt(&receipt.id)returning the compatibility record alongside it). - Mutual verification. Serialize the DSSE envelope, ship it to the other side, and confirm that Org A can verify it with
verify_chio_bilateral_dsse_envelopeagainst its own copy of both pinned public keys. - Fail-closed check. Build a kernel with no pin for the origin and repeat the federated tool call. The response is a signed, persisted
Verdict::Deny, not anErr, and its reason containsnamed federation peer kernel.org-a is not pinned fresh. If that call is allowed, the federation is misconfigured.
Failures and recovery
/// Errors raised by the trust-establishment primitives. Every variant is
/// fail-closed: callers MUST refuse to pin a peer when any step fails.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum PeerHandshakeError {
#[error("unsupported handshake schema: {0}")]
UnsupportedSchema(String),
#[error("canonical JSON encoding failed: {0}")]
CanonicalJson(String),
#[error("handshake signing failed: {0}")]
SigningFailed(String),
#[error("remote handshake signature is invalid")]
InvalidSignature,
#[error("capability negotiation failed closed: {0}")]
CapabilityNegotiation(String),
#[error("remote envelope is addressed to kernel_id {addressed_to} but we are {actual}")]
AddressMismatch {
addressed_to: String,
actual: String,
},
#[error("remote envelope declares self as kernel_id {declared} but we expected {expected}")]
KernelIdMismatch { declared: String, expected: String },
#[error("remote envelope timestamp {envelope} drifts from local clock {local} beyond {skew}s")]
ClockSkewExceeded {
envelope: u64,
local: u64,
skew: u64,
},
#[error("peer {0} is not pinned; run a handshake before resolving")]
PeerNotPinned(String),
#[error("peer {0} is stale and must be re-handshaked before use")]
PeerStale(String),
#[error("peer {0} is not trusted for first contact; configure a trust anchor before accepting handshakes")]
MissingTrustAnchor(String),
#[error("peer {kernel_id} declared unexpected public key; expected {expected}, got {actual}")]
UnexpectedPeerKey {
kernel_id: String,
expected: String,
actual: String,
},
#[error("peer {kernel_id} conformance tier {actual:?} is below required {minimum:?}")]
ConformanceTierBelowMinimum {
kernel_id: String,
actual: ConformanceTier,
minimum: ConformanceTier,
},
#[error("invalid conformance evidence: {0}")]
InvalidConformanceEvidence(String),
#[error("invalid ladder manifest reference: {0}")]
InvalidLadderManifestRef(String),
#[error("trust store is poisoned and cannot service requests")]
StorePoisoned,
}Every failure mode below is fail-closed by design. Handshake errors are typed as PeerHandshakeError; co-signing errors are typed as BilateralCoSigningError. Kernel-side federation failures reach the caller two ways. A named peer that is not pinned fresh becomes a signed Verdict::Deny that is written to the receipt log like any other refusal; downstream co-signing failures surface as KernelError::Internal so operators can investigate. Neither path returns an allow whose receipt the peer did not co-sign.
| Error | What it means | How to fix |
|---|---|---|
MissingTrustAnchor | First contact without a pre-configured anchor or prior pin. | Exchange public keys out of band and add with_trusted_peer before calling accept_envelope. |
UnexpectedPeerKey | The remote declared a public key that differs from the anchor or pin. | Confirm the remote did not rotate its key out of band. Either re-anchor to the new key or refuse. |
InvalidSignature | The envelope signature does not verify against the declared public key. | Tampered or truncated transport. Re-request the envelope; do not auto-retry on a partial. |
AddressMismatch | Envelope is addressed to a different kernel than you are. | Check local_kernel_id on both sides and make sure the peer wrote it correctly in its envelope. |
KernelIdMismatch | The envelope's declared sender id does not match the kernel id you expected. | Confirm both sides use the same peer ID. |
ClockSkewExceeded | Envelope timestamp drifts beyond max_handshake_skew_secs. | Sync NTP on both hosts. Do not widen the skew window to paper over drift. |
PeerStale | KernelTrustExchange::resolve found a pin past its rotation_due. | Re-run Steps 2-3 to re-pin with a later rotation deadline. |
PeerNotPinned | KernelTrustExchange::resolve was asked for a peer the store does not hold. The kernel does not call resolve; its own miss is a Deny reading is not pinned fresh, which collapses missing and stale into one case. | Either complete the handshake or refuse the inbound call at the edge. |
OrgASignatureInvalid | Origin kernel signature on a dual-signed receipt failed verification. | Confirm the pinned origin key still matches the key the origin kernel actually signs with. |
OrgBSignatureInvalid | Tool-host kernel signature failed, or an attacker tried to have the origin co-sign a forged body. | InProcessCoSigner already refuses this. For RPC cosigners, confirm the tool-host public key held by the origin matches the signer. |
UnsupportedSchema | Envelope schema string is not chio.federation-kernel-handshake.v1. | Upgrade the lagging side. The v1 schema is frozen and mismatched versions must fail closed. |
Reading a federation refusal
named federation peer <id> is not pinned fresh. A pinned peer with no cosigner installed produces federation cosigner missing for request <id> bound to origin kernel <id>. Match on not pinned fresh and federation cosigner missing; an alert that greps for stale will never fire.Next Steps
- Federation Overview · how pinned peers compose with
did:chio, Agent Passports, and bilateral federation policy - Delegate Between Agents · the cross-org handoff pattern that rides on a pinned federation
- Rotate Keys & Revoke · when you rotate the local kernel signing key, every federation peer must re-handshake against the new anchor