Trust roots
Choose which keys may issue authority and which scope their delegations descend from, then see what a rotated key still verifies.
On this page
The operator decides which keys may grant authority in a deployment, and the kernel refuses a grant signed by any other key, however well its scope matches. Two roots, both set in operator configuration, settle every presented grant: the accepted issuer set, and the scope each issuer's delegations must descend from.
A signature binds a grant's contents to a key, and a verifier computes it as readily for an attacker's key as for an organization's issuer. Whether that key may grant authority is decided outside the artifact. Delegation adds a second decision, because a child's claimed scope must descend from its issuer's upstream authority. Chio checks accepted issuer identity and committed lineage separately, and both matter when you verify a grant, change a trusted service, or rotate an issuing key.
σaccepted issuersStrust root per issuerκpresented grantδlineage
A valid signature from an unaccepted key is refused
The same scope signed by another key fails the issuer check with UntrustedIssuer, and a tool call receives capability verification failed: capability issuer is not a trusted CA. A matching scope makes no signer an authority. Trust roots.
Operator configuration sets both roots
A token cannot bring its own trust policy. An issuer key inside the presented object may help locate a configured key, and the host takes its expectation from its own provisioning or administrative process, since accepting a key because it accompanies a valid signature would let anyone establish authority.
Issuer trust establishes who may grant authority. The chain-binding root establishes which upstream scope an issuer's delegated authority must descend from.
| Root | Answers | Set by |
|---|---|---|
| Issuer keys | Which signatures may mint a capability | KernelConfig.ca_public_keys, merged with the authority's and the kernel's own keys |
| Chain-binding root | Which issuer's scope a delegation chain must descend from | set_capability_trust_root or with_capability_trust_roots, one scope hash per issuer |
The kernel accepts a signature only from a key in KernelConfig.ca_public_keys, a key the capability authority trusts, or its own public key; trusted_issuer_keys() merges the three. The chio.yaml loader leaves the configured list empty, and the chio api protect, chio start, and chio mcp serve-http sidecars add the keys pinned for the issuer role in their trust file (--trust, default ~/.chio/trust.toml). An accepted grant still has to satisfy its validity window and the serving path's current admission checks.
For lineage, the deployment registers one expected scope hash per issuer, through with_capability_trust_roots at construction or set_capability_trust_root on a running kernel, and verification resolves it through TrustRootResolver. The Chain-Binding Rule requires a direct-issue token's parent_scope_hash to equal its issuer's root and a delegated token's to equal the last delegation link's signed scope_hash. An issuer whose true authority is a narrow scope therefore cannot mint a token that claims a broader parent, even with an internally consistent witness.
Trust-root resolver interface
pub trait TrustRootResolver {
/// Resolve the trust-root scope hash for `issuer`, returning `None`
/// when the issuer has no registered authority hash. The verifier
/// treats `None` as a fail-closed deny for attenuated tokens that require
/// chain binding.
fn trust_root_scope_hash(&self, issuer: &PublicKey) -> Option<ScopeHash>;
}The prepared verifier takes its issuer from a separate file
The Authority application retains its issuer's public key during initialization. Its read-only verify-grant command takes the direct root grant and that separately selected public-key file, so the grant never supplies its own expected issuer:
// The expected key has its own operator-selected file. The token cannot
// choose this file or substitute its issuer field for this expectation.
let expected = bounded(Path::new(&args[1]), 4096)?;
let expected = PublicKey::from_hex(std::str::from_utf8(&expected)?.trim())?;
let grant: CapabilityToken = serde_json::from_slice(&bounded(Path::new(&args[0]), 1_048_576)?)?;
ensure!(
grant.delegation_chain.is_empty() && grant.attenuation_proof.is_none(),
"this command checks direct grants; use the serving kernel for delegated lineage admission"
);The helper bounds the public-key file to 4 KiB and the token to 1 MiB. It accepts direct grants only: delegated lineage needs the serving kernel's registered root and retained ancestors. The command opens no authority store and grants no permission to dispatch a tool.
This application's direct grant carries a signed three-call family binding, so the consumer explicitly enables that supported feature before verification; the baseline verifier refuses an unnegotiated aggregate budget. Feature support and the classical cryptographic floor both come from the verifier's own configuration, and the command authenticates the family binding without reading its remaining balance.
The verifier's feature contract
// This local verifier explicitly implements signed aggregate-root checks.
// Feature support is fixed by the consumer, never copied from token metadata.
let mut supported = CapabilityNegotiation::v1_default();
supported
.features
.insert(AGGREGATE_INVOCATION_BUDGET.into(), true);
let verified = match verify_capability_with_negotiated_floor(
&grant,
&[expected],
&FixedClock::new(now),
CapabilityCryptoFloor::AllowClassical,
&supported,
) {
Ok(value) => value,
Err(reason) => {
println!(
"{}",
json!({"accepted":false,"reason":format!("{reason:?}"),"evaluated_at":now})
);
bail!("direct grant did not verify");
}
};The same grant verifies under its own issuer and fails under another
Use the prepared Execution example commands from the example directory; each command reopens the retained state its --state names. Create fresh private state, then verify the root grant while its one-hour validity window is open:
authority init --repository repository --state .trust-review
verify-grant .trust-review/root-capability.json .trust-review/kernel.pub{
"accepted": true,
"capability_id": "cap-01a0fd2a-aea0-7688-a0bf-b14fa68d6930",
"checked": [
"issuer_pin",
"signature",
"validity_window",
"supported_feature_contract"
],
"clock": "system",
"evaluated_at": 1790953829,
"expires_at": 1790957428,
"issued_at": 1790953828,
"issuer": "8e3492b9a47ebc213bc26dea5c21f149ec90468a288d5be9e888e1534536f2d6",
"signed_family_binding_checked": true,
"subject": "f69109fffe99c3e52a7c887e8676107483a6f742832ed3b52057adadf33a3fcf",
"unchecked": [
"current_revocation",
"subject_possession",
"request_scope",
"durable_use_accounting",
"tool_execution"
]
}You should see accepted: true, with the issuer pin, signature, validity window, and supported feature contract under checked, and current revocation, subject possession, request scope, durable use accounting, and tool execution under unchecked. The JSON summary is unsigned, so retain the original token and pin for anyone who repeats the check. The default clock is the system clock; --at UNIX_SECONDS reports an explicitly selected inspection time, and current authority is still judged at the present.
Now select another independently provisioned issuer and keep the grant unchanged:
authority init --repository repository --state .other-issuer
verify-grant .trust-review/root-capability.json .other-issuer/kernel.pubThe captured command exited 1 with UntrustedIssuer, before the matching scope could confer trust. Checking the original issuer at the token's exact expiry returned Expired, and changing the scope from six to seven per-grant invocations broke the signed family-root binding before the modified grant could verify.
Portable verifier counterexamples
The baseline comparison verifies direct grants without the aggregate-budget feature: direct_grant signs one repository scope under two disposable keys, and the public Rust verifier accepts only the trusted issuer, and only while the grant is live. No tool is dispatched; the serving kernel keeps current revocation, delegation admission, and durable use accounting.
Add these functions to the host example modules beside review_scope.rs, request_proof.rs, delegate_review.rs, and verify_review_grant.rs, then call compare_issuers(), compare_proofs(), and compare_delegations() from that embedding's entrypoint. They generate disposable keys and provision no issuance authority.
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use chio_core::capability::token::{CapabilityToken, CapabilityTokenBody};
use chio_core::crypto::{sha256_hex, Keypair};
use chio_kernel::{verify_dpop_proof, DpopConfig, DpopNonceStore};
use chio_kernel_core::CapabilityError;
use super::request_proof::sign_read_proof;
use super::delegate_review::sign_review_delegation;
use super::review_scope::review_scopes;
use super::verify_review_grant::verify_review_grant;
fn direct_grant(issuer: &Keypair, caller: &Keypair, now: u64, id: &str) -> CapabilityToken {
let (_, scope) = review_scopes();
CapabilityToken::sign(
CapabilityTokenBody {
id: id.into(),
issuer: issuer.public_key(),
subject: caller.public_key(),
scope,
issued_at: now,
expires_at: now + 300,
delegation_chain: vec![],
aggregate_invocation_budget: None,
},
issuer,
).expect("sign disposable example grant")
}
pub fn compare_issuers() {
let issuer = Keypair::generate();
let stranger = Keypair::generate();
let reviewer = Keypair::generate();
let now = 1_800_000_000;
let trusted = direct_grant(&issuer, &reviewer, now, "review-trusted");
let untrusted = direct_grant(&stranger, &reviewer, now, "review-untrusted");
assert!(verify_review_grant(&trusted, issuer.public_key(), now).is_ok());
assert!(matches!(
verify_review_grant(&untrusted, issuer.public_key(), now),
Err(CapabilityError::UntrustedIssuer)
));
assert!(matches!(
verify_review_grant(&trusted, issuer.public_key(), now + 300),
Err(CapabilityError::Expired)
));
println!("VERIFY trusted issuer: review grant accepted");
println!("REFUSE other issuer: same scope is not trusted");
println!("REFUSE expiry: the trusted grant is no longer live");
}
pub fn compare_proofs() {
let issuer = Keypair::generate();
let reviewer = Keypair::generate();
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
let grant = direct_grant(&issuer, &reviewer, now, "review-proof");
let arguments = br#"{"path":"/srv/larkfield/pricing/src/pricing.py"}"#;
let changed = br#"{"path":"/srv/larkfield/pricing/.env"}"#;
let hash = sha256_hex(arguments);
let proof = sign_read_proof(&grant, &reviewer, arguments, "first-call".into(), now).unwrap();
let config = DpopConfig::default();
let store = DpopNonceStore::new(config.nonce_store_capacity, Duration::from_secs(config.proof_ttl_secs));
// A changed request fails before the original nonce is consumed.
assert!(verify_dpop_proof(&proof, &grant, "workspace", "read_file", &sha256_hex(changed), &store, &config).is_err());
verify_dpop_proof(&proof, &grant, "workspace", "read_file", &hash, &store, &config).unwrap();
assert!(verify_dpop_proof(&proof, &grant, "workspace", "read_file", &hash, &store, &config).is_err());
let fresh = sign_read_proof(&grant, &reviewer, arguments, "second-call".into(), now).unwrap();
verify_dpop_proof(&fresh, &grant, "workspace", "read_file", &hash, &store, &config).unwrap();
println!("REFUSE changed arguments: proof stays bound to src/pricing.py");
println!("VERIFY first proof: nonce consumed once");
println!("REFUSE repeated proof: nonce already consumed");
println!("VERIFY fresh proof: cryptographic check passes; dispatch still needs admission");
}
pub fn compare_delegations() {
let issuer = Keypair::generate();
let owner = Keypair::generate();
let reviewer = Keypair::generate();
let now = 1_800_000_000;
let (parent_scope, child_scope) = review_scopes();
let parent = CapabilityToken::sign(CapabilityTokenBody {
id: "pricing-review-parent".into(),
issuer: issuer.public_key(),
subject: owner.public_key(),
scope: parent_scope,
issued_at: now,
expires_at: now + 300,
delegation_chain: vec![],
aggregate_invocation_budget: None,
}, &issuer).unwrap();
let receipt = sign_review_delegation(&parent, &owner, &reviewer.public_key(), now, [1; 16]).unwrap();
assert!(receipt.link.verify_signature().unwrap());
assert_eq!(receipt.link.attenuations.len(), 4);
assert_eq!(receipt.link.delegator, owner.public_key());
assert_eq!(receipt.link.delegatee, reviewer.public_key());
assert_eq!(receipt.attenuation.child_expires_at, Some(now + 120));
assert_eq!(receipt.attenuation.budget_share_bps, Some(1600));
let mut widened = child_scope;
widened.grants[0].tool_name = "write_file".into();
assert!(chio_core::capability::attenuation::delegate(
&parent, &widened, &owner, &reviewer.public_key(),
chio_core_types::ScopeAttenuation::empty(), now, [2; 16],
).is_err());
assert!(sign_review_delegation(&parent, &reviewer, &reviewer.public_key(), now, [3; 16]).is_err());
println!("SIGN delegation: reviewer read subset and 120-second expiry");
println!("CHECK share: 1600 bps requested; child token and admission must bind it");
println!("REFUSE widening: parent did not authorize write_file");
println!("REFUSE wrong delegator: reviewer does not own the parent");
}VERIFY trusted issuer: review grant accepted
REFUSE other issuer: same scope is not trusted
REFUSE expiry: the trusted grant is no longer liveA revoked grant still verifies, and admission refuses its use
Perform one useful read, then withdraw the parent in the original authority store. Keep the public issuer pin and the signed token unchanged:
authority read src/discount/rules.py \
--as reviewer --operation before-revocation \
--state .trust-review
authority revoke parent --state .trust-review
verify-grant .trust-review/root-capability.json .trust-review/kernel.pub
authority read src/discount/rules.py \
--as reviewer --operation after-revocation \
--state .trust-review
cat .trust-review/resource-reads.jsonlBefore withdrawal the reviewer's read is allowed and the journal holds one read. After it, the direct grant's signature, issuer, and validity window are still accepted within the original window, the new read is denied because an ancestor capability is revoked, and the journal still holds one read.
A pure verifier result is a statement about signed bytes, and current admission is a statement about retained authority state. Withdrawal changes that state without rewriting a past signature, and the kernel checks the state, the subject, the requested route, the lineage, and use accounting before new work.
Rotation and remote pins keep issuance on the current key
For a capability authority reached over the control plane, chio mcp serve-http takes the operator's pinned current key from --control-authority-public-key and the trusted set from --control-authority-trusted-public-keys. build_pinned_remote_capability_authority refuses to construct the authority unless validate_authority_pins finds the live service reporting exactly those keys, and the check runs again on every status refresh. The dedicated --remote-authority-workload-token authenticates both the status lookup and the issuance calls and must differ from the --control-token, --auth-token, and --admin-token values, so a leaked admin or auth token alone cannot requisition capability issuance.
Removing a key from issuance trust is a separate decision from keeping it to verify past grants, and the authority backend decides which one rotation makes. A SQLite-backed authority (--authority-db) rotates by generating a new keypair and inserting it into the trusted-key table beside the row for the key it replaces. A capability signed under a rotated-out key still verifies, because the set trusted_issuer_keys() reads keeps every inserted key. A seed-file authority (--authority-seed-file) trusts only its current key: rotation overwrites the seed file, and once a kernel loads the rotated seed, the old key leaves the set it takes from the authority.
Issuance is narrower than verification. After rotation the authority signs only with its new current key, and validate_issued_capability_response refuses any returned capability whose issuer is another key, so a rotated-out key mints nothing new even where it still verifies what it minted before. Witnessed rotation covers a legitimate issuer change end to end.
A trust refusal names the root that failed
The first three rows are tool-call deny reasons, each prefixed capability verification failed: ; a resource, prompt, or completion request runs the same check and surfaces the same reason directly as Err(KernelError::GuardDenied), without the prefix. Rows 4 and 5 reach a client starting a chio mcp serve-http session, and row 6 reaches a caller of POST /v1/capabilities/issue.
| Refusal | Condition | Root |
|---|---|---|
capability issuer is not a trusted CA | The signer is not an accepted issuer | Issuer keys |
capability rejected by chain binding: chain-binding: no trust-root scope hash registered for issuer | A direct-issue attenuated token or single-hop chain; issuer has no registered root | Chain-binding root |
capability rejected by chain binding: signed lineage has no pinned issuer scope | An attenuated chain of two or more hops; issuer has no registered root | Chain-binding root |
500 on initialize: remote capability authority current key does not match the operator pin | The remote authority reports a current key the operator did not pin | Remote authority pin |
500 on initialize: remote capability authority trusted key set does not match the operator pins | The remote authority reports another trusted key set | Remote authority pin |
500, {"error": "capability issuer is not a trusted CA"} from Kernel | The authority returned a capability signed by a key other than its current key | Issuing authority |
After an unexpected issuer refusal, compare the token's signer with the operator-owned issuer set, the scope registration, the current remote-authority pin, and the authority's key history. A legitimate rotation goes through the operator's reviewed rotation procedure. Retrying with the token's own issuer as the expected key would let the rejected artifact choose its own authority.
Authenticated identity establishes who is calling, and workload identity connects authority to the workload presenting it; trust roots decide which signer may vouch for that authority in the first place.