Chio/Docs

BuildIdentity

Bootstrap Federated Trust

Establish trust between two Chio kernels: exchange anchors, pin peer keys, complete the handshake, and configure co-signing.

Alpha APIs and fixed wire formats

The kernel-to-kernel handshake and bilateral co-signing APIs are defined by the chio-federation crate. The handshake envelope schema is pinned at chio.federation-kernel-handshake.v1, and the co-signed receipt schema at chio.federation-dual-signed-receipt.v1. Production transports around those APIs (long-lived mTLS peers, persistent peer stores) are expected to mature further; the wire formats are frozen.

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-a that uniquely names your kernel to peers. The kernel exposes set_federation_local_kernel_id for 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

The handshake is not a discovery protocol. It is key confirmation. If you skip the out-of-band anchor step and try to pin whatever public key the peer declares, you have delegated your trust boundary to the network. Pin anchors deliberately.

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.

rust
use chio_core_types::crypto::{Keypair, PublicKey};
use chio_federation::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:

rust
use chio_federation::{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.

rust
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. Any value that is unique across retries inside the skew window is fine; UUID v7 or a counter plus random suffix both work. The signed envelope body carries:

  • schema: chio.federation-kernel-handshake.v1
  • local_kernel_id and remote_kernel_id: the directed pair for this envelope
  • nonce and timestamp: freshness material, checked on the accepting side
  • declared_public_key: the public half the signer claims to be
  • signature: 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.


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 five 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_secs of 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).
rust
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

There is no silent renewal. When 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:

rust
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 derived from measured evidence, never asserted directly. ConformanceEvidence carries threat_coverage_bps, mutation_kill_bps, and kani_trust_boundary_crates; derive_tier() maps those metrics onto a stable Bronze/Silver/Gold band.

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

The local side advertises its tier by constructing the exchange with with_conformance_tier; the default is ConformanceTier::Bronze. Every envelope the exchange emits carries that tier.

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

rust
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, so a later handshake records a lower tier.

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.

rust
use std::sync::Arc;
use chio_federation::InProcessCoSigner;

// 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
)));

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.

rust
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 — request and outcome hashes, the ordered signer kernel ids, and (for treaty-bound hops) a treaty binding reference — signed by both the origin and tool-host kernels. 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

Both verifiers demand both signatures. verify_chio_bilateral_dsse_envelope (canonical) and DualSignedReceipt::verify (compatibility) each succeed only when both signatures validate against the declared kernel ids. A verifier that can check only one side must still reject the record. Swapping either key for an attacker's raises OrgASignatureInvalid or OrgBSignatureInvalid.

Verify the Federation Works

With peers pinned and a cosigner installed, run a smoke test end to end. Check the following:

  • Peer snapshot. kernel.federation_peers_snapshot() returns the peer you pinned with a rotation_due in the future.
  • Fresh lookup. kernel.federation_peer("kernel.org-b", now) returns Some(_) while fresh and None past the rotation deadline.
  • Federated tool call. Send a request with federated_origin_kernel_id set. The verdict should be Allow (or whatever the local policy dictates) and federation_dsse_envelope(&receipt.id) must return a DsseEnvelope (with dual_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_envelope against its own copy of both pinned public keys.
  • Fail-closed check. Tear the peer pin out (exchange.forget("kernel.org-b")) and repeat the federated tool call. The kernel must refuse with KernelError::Internal whose message contains "not pinned" or "stale". If that call succeeds, the federation is misconfigured.

Troubleshooting

Every failure mode below is fail-closed by design. Handshake errors are typed as PeerHandshakeError; co-signing errors are typed as BilateralCoSigningError. The kernel returns downstream failures as KernelError::Internal so operators can investigate the failed co-signing request; the kernel does not return an unsigned-by-peer receipt.

ErrorWhat it meansHow to fix
MissingTrustAnchorFirst contact without a pre-configured anchor or prior pin.Exchange public keys out of band and add with_trusted_peer before calling accept_envelope.
UnexpectedPeerKeyThe 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.
InvalidSignatureThe 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.
AddressMismatchEnvelope 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.
KernelIdMismatchThe envelope's declared sender id does not match the kernel id you expected.Confirm both sides use the same peer ID.
ClockSkewExceededEnvelope timestamp drifts beyond max_handshake_skew_secs.Sync NTP on both hosts. Do not widen the skew window to paper over drift.
PeerStaleThe peer pin is past its rotation_due.Re-run Steps 2-3 to re-pin with a later rotation deadline.
PeerNotPinnedA federated request referenced a peer that has never been pinned locally.Either complete the handshake or refuse the inbound call at the edge.
OrgASignatureInvalidOrigin kernel signature on a dual-signed receipt failed verification.Confirm the pinned origin key still matches the key the origin kernel actually signs with.
OrgBSignatureInvalidTool-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.
UnsupportedSchemaEnvelope schema string is not chio.federation-kernel-handshake.v1.Upgrade the lagging side. The v1 schema is frozen and mismatched versions must fail closed.

Internal kernel errors from co-signing

When the kernel's post-sign hook cannot co-sign a federated receipt (cosigner not installed, peer missing or stale), it raises KernelError::Internal with a message that includes "federation cosigner missing", "not pinned", or "stale". The tool call is refused; no unsigned-by-peer receipt ships.

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