Transport Directory
Look up the seven checks a transport directory bundle passes, the keys each entry binds, and how a running node reloads it.
On this page
A transport directory is the signed list of partner kernels that may connect to yours over the iroh lane, with the keys each one connects under. An issuer you pin signs it as one bundle, and your node verifies that bundle before its gate admits a single endpoint.
Partner traffic crosses over HTTPS, authenticated by mutual TLS or signed bodies, unless an operator adds the partner with --transport iroh. The directory governs that optional lane. Verification yields a VerifiedDirectory, and a bundle that fails any check yields nothing.
A bundle that passes all seven checks becomes the directory
The federation guide signs a two-peer bundle and verifies it under a trusted issuer: issuer-signed directory version 1 with 2 admitted peers: verified. The gate admits only the endpoints this directory binds to admitted kernels. Verify a transport directory bundle.
Try OutsideValidityWindow, version_floor, or transportEndpointId. Each entry has its own link.
The checks, in order
TransportDirectoryBundleDocument::verify_bundle runs seven checks in the order the source numbers them. The first failure returns an IdentityError and no directory.
- Schema. The bundle, its body, and its directory each carry
chio.federation.transport.iroh.peer-directory-bundle.v2.UnsupportedSchema. - Rollback.
version > version_floor, andprevious_version_sha256chains onto the expected predecessor.Rollback,PreviousVersionMismatch. - Validity window. The supplied time falls in
[issued_at_unix_ms, expires_at_unix_ms).OutsideValidityWindow. - Body hash. The recomputed canonical SHA-256 of the directory equals the signed
directory_sha256.BodyHashMismatch. - Issuer signature. A pinned
TrustedTransportDirectoryIssuer, matched on(issuer, key_id), signed the body.UnknownIssuer,SignatureInvalid,CanonicalJson. - Peer endorsements. Each live peer's passport endorses its transport
EndpointId, and any oracle signer key, under separate domain tags. Kernel ids and endpoints are unpadded, non-empty, and unique, and the directory binds at least one endpoint.EndorsementInvalid,OracleEndorsementInvalid,NonEd25519OracleKey,MalformedEntry,Duplicate,EmptyDirectory. - Treaty party sets. Each
TransportTreatyEntrycarries an unpadded treaty id and a non-empty, duplicate-free party list, and every party is a live operator bound in the same signed directory.MalformedEntry,Duplicate.
A tombstoned entry skips the endorsement check, since it resolves to nothing either way. A directory with no treaty entries yields an empty treaty index, and every treaty membership check against it denies.
Read the first returned IdentityError against this list before you replace the bundle. A later check never compensates for a refusal before it. Every variant is indexed on Federation Refusal Codes.
Trust inputs and the verified directory
The verifier's trust inputs arrive in TransportDirectoryBundleTrust: the pinned issuer set, the rollback version_floor, the expected predecessor hash, and now_unix_ms. The window check reads that supplied time at load and at every reload.
The resulting VerifiedDirectory answers authorize(endpoint), resolve_transport_endpoint(kernel_id), resolve_passport_key(kernel_id), and is_treaty_party(treaty_id, kernel_id). authorize checks key binding and removal state without a clock, so the reloader below enforces expiry.
Operator and transport keys
An iroh EndpointId is Ed25519 only, so a P-256, P-384, ML-DSA-65, or hybrid passport key cannot serve as one. Each entry carries two keys: the long-term operator passport_public_key, in any algorithm, and a rotatable Ed25519 transport_endpoint_id, cross-linked by a passport-signed endorsement.
The endorsement signs the bytes transport_endorsement_preimage returns: the domain tag chio.iroh.transport-endorsement.v1, the entry's kernel_id, and the transport EndpointId, each length-prefixed. It commits to which operator owns which endpoint. Oracle signer endorsements use the distinct tag chio.iroh.revocation-signer-endorsement.v1, so the two kinds never replay as each other.
{
"schema": "chio.federation.transport.iroh.peer-directory-bundle.v2",
"body": {
"schema": "chio.federation.transport.iroh.peer-directory-bundle.v2",
"issuer": "did:chio:z6Mkf3…Qp9",
"keyId": "dir-2026-q3",
"directorySha256": "5b41…06c9",
"version": 42,
"previousVersionSha256": "9a4f…3a4b",
"issuedAtUnixMs": 1776970000000,
"expiresAtUnixMs": 1776973600000
},
"directory": {
"schema": "chio.federation.transport.iroh.peer-directory-bundle.v2",
"localKernelId": "did:chio:z6Mkf3…Qp9",
"peers": [
{
"kernelId": "did:chio:z6MkpA7…c2T",
"passportPublicKey": "9b07…d31c",
"transportEndpointId": "3fa8…91be",
"passportEndorsement": "7e2b…c104",
"revocationSigners": [],
"removed": false
}
],
"treaties": [
{
"treatyId": "trt-01aa…4c19",
"partyKernelIds": ["did:chio:z6Mkf3…Qp9", "did:chio:z6MkpA7…c2T"]
}
]
},
"signature": "9f4d…b73e"
}The two signatures are shown truncated. passportEndorsement and the bundle signature are Signature values, and an Ed25519 one renders as bare 128-character lowercase hex. passportPublicKey is a PublicKey, and an Ed25519 one renders as bare 64-character lowercase hex.
Only p256:, p384:, and hybrid: material carries an algorithm prefix, and the parsers branch on exactly those three. An ed25519: prefix falls through to the hex decoder and fails to load.
A removed entry is a tombstone: it never resolves, and it drops its revocation_signers from the derived signer directory. Evicting an operator in one bundle revokes its oracle signer in the same bundle.
Live reload
DirectoryGate holds the verified directory behind an atomic swap, so a reload never takes a lock on the admission path. chio pheromone relay runs the reloader on the bundle named by --iroh-transport-directory. Every 60 seconds, and at the live bundle's expiry deadline when that comes first, it re-reads the bundle and runs the same seven checks.
- Chain from the live directory. The version floor and the previous-version hash advance from the directory the gate is serving, so a downgrade is never accepted again.
- Recheck this node. A successor must still bind this node's
localKernelIdto the endpoint it serves under. One that tombstones or rebinds it fails closed to deny-all. - Swap.
DirectoryGate::swappublishes the successor, and every lane's next accept reads it. - Expire closed. A bundle that expires with no valid successor leaves the gate admitting nothing until a valid bundle is published.
- Keep last-good. A transient read or verify error keeps the serving directory and is counted and logged.
Pass the rotation-state pin with --iroh-transport-directory-state to load a rotated bundle; without it only a genesis bundle loads. Here the node serves version 42, and a reload that finds version 41 refuses it and keeps serving 42:
chio pheromone relay serve \
--listen 0.0.0.0:8443 --store ./relay.db --profile production \
--transit-policy ./transit-policy.json --proof-package ./proof-package.json \
--trust-bundle ./trust-bundle.json --context ./context.json --report-dir ./reports \
--trusted-issuers ./directory-issuers.json \
--iroh-enable \
--iroh-transport-directory ./transport-directory-bundle.json \
--iroh-transport-directory-state ./transport-directory-state.json \
--iroh-transport-key ./transport-key.jsondirectory version 42 verified, 7 of 7 checks, issuer did:chio:z6Mkf3…Qp9 key dir-2026-q3 gate admits did:chio:z6MkpA7…c2T at endpoint 3fa8…91be reload every 60 s, and at the bundle's expiry reload version 41 refused: Rollback, version not above floor 42; serving version 42
Each reload outcome is counted in chio_federation_transport_directory_reload_total, and the identity seam of chio_federation_transport_verify_failures_total counts bundles refused at load and reload. The alerts to set on both are on Federation Operations.
Example
Verify a transport directory bundle and refuse a stale one
Sign a two-peer directory bundle, verify it under a trusted issuer, then refuse it below the version floor, after its window, and under an untrusted key.
Complete the project setup, then run the program from federation-guide/. The download includes every constructor and input.
cargo run --locked --bin directoryissuer-signed directory version 1 with 2 admitted peers: verified bundle below the version floor: refused (bundle version 1 is not above the trusted floor 2) bundle read after its validity window: refused (bundle is outside its validity window) bundle signed by a key the verifier does not trust: refused (issuer signature is invalid)
Keys, endpoint ids, and the clock are deterministic example values, and no endpoint is bound. The admission gate example in the public source checkout runs the same bundle through a live loopback connection.
Read the complete program 92 lines · complete file
//! Verify an issuer-signed transport directory bundle, then refuse the same
//! bundle below the version floor, after its validity window, and under a key
//! the verifier does not trust.
use chio_core_types::{canonical_json_bytes, sha256_hex, Keypair};
use chio_federation_transport_iroh::identity::{
transport_endorsement_preimage, IdentityError, TransportDirectoryBundleBody,
TransportDirectoryBundleDocument, TransportDirectoryBundleTrust, TransportDirectoryDocument,
TransportDirectoryEntry, TrustedTransportDirectoryIssuer, TRANSPORT_DIRECTORY_BUNDLE_SCHEMA,
};
use iroh::{EndpointId, SecretKey};
/// Fixed logical clock for the validity window; the verifier supplies `now`.
const NOW: u64 = 2_000_000;
fn endpoint_id(seed: u8) -> EndpointId {
SecretKey::from_bytes(&[seed; 32]).public()
}
fn bundle(issuer: &Keypair) -> Result<TransportDirectoryBundleDocument, Box<dyn std::error::Error>> {
let peers = [("did:chio:alice", 10u8), ("did:chio:bob", 11u8)]
.iter()
.map(|(kernel_id, seed)| {
let passport = Keypair::from_seed(&[*seed; 32]);
let transport = endpoint_id(*seed);
TransportDirectoryEntry {
kernel_id: (*kernel_id).to_string(),
passport_public_key: passport.public_key(),
transport_endpoint_id: transport,
passport_endorsement: passport
.sign(&transport_endorsement_preimage(kernel_id, &transport)),
revocation_signers: Vec::new(),
removed: false,
}
})
.collect::<Vec<_>>();
let directory = TransportDirectoryDocument {
schema: TRANSPORT_DIRECTORY_BUNDLE_SCHEMA.to_string(),
local_kernel_id: "did:chio:local".to_string(),
peers,
treaties: Vec::new(),
};
let directory_sha256 = sha256_hex(&canonical_json_bytes(&directory)?);
let body = TransportDirectoryBundleBody {
schema: TRANSPORT_DIRECTORY_BUNDLE_SCHEMA.to_string(),
issuer: "did:chio:issuer".to_string(),
key_id: "issuer-key-1".to_string(),
directory_sha256,
version: 1,
previous_version_sha256: None,
issued_at_unix_ms: NOW - 1,
expires_at_unix_ms: NOW + 1_000,
};
let (signature, _) = issuer.sign_canonical(&body)?;
Ok(TransportDirectoryBundleDocument {
schema: TRANSPORT_DIRECTORY_BUNDLE_SCHEMA.to_string(),
body,
directory,
signature,
})
}
fn trust(issuer: &Keypair, version_floor: u64, now_unix_ms: u64) -> TransportDirectoryBundleTrust {
TransportDirectoryBundleTrust {
issuers: vec![TrustedTransportDirectoryIssuer {
issuer: "did:chio:issuer".to_string(),
key_id: "issuer-key-1".to_string(),
public_key: issuer.public_key(),
}],
version_floor,
expected_previous_version_sha256: None,
now_unix_ms,
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
std::fs::create_dir("directory-run")?;
let issuer = Keypair::from_seed(&[240u8; 32]);
let bundle = bundle(&issuer)?;
std::fs::write("directory-run/bundle.json", serde_json::to_vec_pretty(&bundle)?)?;
bundle.verify_bundle(&trust(&issuer, 0, NOW))?;
println!("issuer-signed directory version 1 with 2 admitted peers: verified");
let err = bundle.verify_bundle(&trust(&issuer, 2, NOW)).unwrap_err();
assert!(matches!(err, IdentityError::Rollback { .. }));
println!("bundle below the version floor: refused ({err})");
let err = bundle.verify_bundle(&trust(&issuer, 0, NOW + 5_000)).unwrap_err();
assert!(matches!(err, IdentityError::OutsideValidityWindow));
println!("bundle read after its validity window: refused ({err})");
let stranger = Keypair::from_seed(&[241u8; 32]);
let err = bundle.verify_bundle(&trust(&stranger, 0, NOW)).unwrap_err();
println!("bundle signed by a key the verifier does not trust: refused ({err})");
Ok(())
}Open the source file. Each program asserts its expected refusals; keys, timestamps, and digests change between runs.
The admission gate example runs a verified bundle through a live loopback connection and closes an unbound endpoint at the handshake.
Source files
TransportDirectoryBundleDocument::verify_bundle and IdentityError are in crates/trust/chio-federation-transport-iroh/src/identity.rs, and DirectoryGate is in crates/trust/chio-federation-transport-iroh/src/admission.rs. The reloader is in crates/products/chio-cli/src/cli/chio/dispatch/pheromone/iroh_mount.rs.