Rotate Keys & Revoke
Withdraw a compromised agent's authority at its next admission, rotate the operator key, and keep past receipts verifiable.
On this page
A revoked capability stops authorizing at its next admission on every kernel that reads your revocation store, and its delegated subtree stops with it. A rotated operator key signs everything new, and every receipt signed before the change still verifies.
The trust control plane, chio trust serve, holds the revocation set and the operator signing key. MCP edges, sidecars, and runners read both over --control-url, so one revoke or one rotation reaches every node pointed at the service.
Revocation is looked up on every admission
The kernel checks the presented token and every delegation link before admission and again before finalizing an allow. It makes one lookup per id at GET /v1/revocations, with no cache, and a failed lookup denies the call. The operator revokes with POST /v1/revocations, through chio trust revoke. The state a verdict depends on.
In Rust · lib.rs:50730 lines
pub fn configure_revocation_store(
kernel: &mut ChioKernel,
revocation_db_path: Option<&Path>,
control_url: Option<&str>,
control_token: Option<&str>,
) -> Result<(), CliError> {
match (revocation_db_path, control_url) {
(Some(_), Some(_)) => {
return Err(CliError::cli_other_error(
"use either --revocation-db or --control-url for revocation state, not both"
.to_string(),
));
}
(Some(path), None) => {
kernel.set_revocation_store(Box::new(chio_store_sqlite::SqliteRevocationStore::open(
path,
)?));
}
(None, Some(url)) => {
let token = require_control_token(control_token)?;
kernel.set_revocation_store(
trust_control::service_runtime::remote_stores::build_remote_revocation_store(
url, token,
)?,
);
}
(None, None) => {}
}
Ok(())
}
--control-url. Durable admission and child receipts have remote adapters too and are left out; a node without the URL keeps the same state in local SQLite files.Architecture1. Name the lifecycle you are changing
Chio keeps three independent lifecycles. Rotation changes the key used for new signatures; revocation withdraws authority from a credential or capability that already exists.
| Object | What it authorizes | Where its state lives |
|---|---|---|
| Operator signing key | Capability tokens, reputation credentials, trust-control writes | An authority seed file or the authority database |
| Agent Passport | Portable reputation credentials for a did:chio subject | PassportLifecycleRecord in the passport statuses registry |
| Capability token | One tool invocation under scope, budget, and TTL | RevocationRecord in the revocation store |
Each lifecycle moves alone. Rotating an operator key leaves outstanding capabilities it signed valid, and revoking a passport leaves the subject's capability tokens valid, so revoke each affected type separately.
Plan rotations from credential lifetimes; revoke at once for a compromised subject, a bad deployment, or a failed policy evaluation. Revocation is visible history: the signed record still exists, and the lifecycle record and revocation store change.
2. Point every kernel at one revocation store
Start trust control with an authority database and a joint session database, which holds revocations and budget state. Then start each edge.
chio trust serve \
--listen 127.0.0.1:8787 \
--service-token operator-admin-token \
--receipt-db ./receipts.sqlite3 \
--authority-db ./authority.sqlite3 \
--session-db ./trust-session.sqlite3chio --session-db ./session.sqlite3 \
--control-url http://127.0.0.1:8787 \
--control-token operator-admin-token \
mcp serve --policy mcp-policy.yaml --server-id pricing \
-- python3 mcp_pricing.pyThe edge wraps Larkfield's pricing MCP server from the pricing desk and its quote tool. Each kernel checks the presented token and every delegation link against the store at admission, one uncached lookup per id, and a lookup that fails refuses the call. Send one tools/call quoting sku-204 at no discount and the kernel admits it.
{
"id": 3,
"jsonrpc": "2.0",
"result": {
"content": [
{
"text": "{\"id\": \"7c2e4a90-3b1d-4f6e-9a85-d04c1e7b2f63\", \"sku\": \"sku-204\", \"percent\": 0, \"price\": \"58.00\", \"currency\": \"USD\"}",
"type": "text"
}
],
"isError": false
}
}Take the capability id from that call's receipt, the newest in the store.
chio --control-url http://127.0.0.1:8787 \
--control-token operator-admin-token \
receipt list --order newest --limit 1 \
| jq -r '.capability_id'cap-019fbe3d-bcc9-7263-9752-68702b83f369
3. Revoke the capability and its subtree
Revoke through the service so every kernel reading it sees the same row.
chio --control-url http://127.0.0.1:8787 \
--control-token operator-admin-token \
trust revoke \
--capability-id cap-019fbe3d-bcc9-7263-9752-68702b83f369capability_id: cap-019fbe3d-bcc9-7263-9752-68702b83f369 revoked: true newly_revoked: true backend: http://127.0.0.1:8787
You should see backend report the service URL: the write landed in shared state. newly_revoked separates the first write from a repeat, so the step is safe to re-run. Revocation is permanent; restore a subtree by reissuing from a live ancestor.
Grant a (analysis) is revoked at t_rev. Its subtree, a, r, is closed: a's later calls are refused with CapabilityRevoked and its descendants' with DelegationChainRevoked(a). c and s keep their grants. Every call before t_rev stands.
In Rust · validation.rs:71813 lines
pub(crate) fn check_revocation(&self, cap: &CapabilityToken) -> Result<(), KernelError> {
if self.with_revocation_store(|store| Ok(store.is_revoked(&cap.id)?))? {
return Err(KernelError::CapabilityRevoked(cap.id.clone()));
}
for link in &cap.delegation_chain {
if self.with_revocation_store(|store| Ok(store.is_revoked(&link.capability_id)?))? {
return Err(KernelError::DelegationChainRevoked(
link.capability_id.clone(),
));
}
}
Ok(())
}
A capability whose delegation_chain contains the revoked id is refused on presentation. A revoked leaf fails with capability has been revoked; a revoked ancestor fails with delegation chain revoked at ancestor <id>. Revoke a misbehaving child alone; revoke the parent when the agent that delegated is itself compromised.
4. Confirm the next call is refused
Repeat the identical tools/call on the live edge, without a restart or new session.
{
"id": 4,
"jsonrpc": "2.0",
"result": {
"content": [
{
"text": "capability has been revoked: cap-019fbe3d-bcc9-7263-9752-68702b83f369",
"type": "text"
}
],
"isError": true
}
}You should see an MCP tool error with isError: true. The kernel raised KernelError::CapabilityRevoked during admission, and the wrapped tool server was never reached.
5. Revoke the passport
Transition the lifecycle record where the passport was published: a registry file or trust control. The registry refuses to transition a record it never published.
chio \
--control-url https://trust.larkfield.example \
--control-token operator-admin-token \
passport status revoke \
--passport-id <passport-artifact-id> \
--reason key-leak$ chio passport status revoke \
--passport-id "$(cat ./passport-id.txt)" \
--passport-statuses-file ./passport-statuses.json \
--reason compromisedpassport revoked passport_id: 57191b1ef425678d13e2080b8447bddba4ae8363585ff0d3a69efdac414cf4cc state: revoked revoked_at: 1788609711 revoked_reason:compromised
You should see the record move from Active to Revoked with revoked_at stamped and your reason. Trust control owns its registry and returns the same fields under camelCase keys. Later resolutions return state = Revoked, and a verifier policy with requireActiveLifecycle: true stops accepting the passport as soon as that resolution is available.
$ chio passport status resolve \
--passport-id "$(cat ./passport-id.txt)" \
--passport-statuses-file ./passport-statuses.jsonpassport_id: 57191b1ef425678d13e2080b8447bddba4ae8363585ff0d3a69efdac414cf4cc state: revoked subject: did:chio:d02f9b5f38ce7746e2ccbd8fc93f6d1d9dd4ddbe02a3e206830a6950b3752590 updated_at: 1788609711 source: registry:./passport-statuses.json revoked_at: 1788609711 revoked_reason:compromised
6. Rotate the operator signing key
Read the authority status to see the key you are replacing, then rotate with POST /v1/authority.
$ curl -sS "$CHIO_CONTROL/v1/authority" \
-H "Authorization: Bearer $CHIO_TOKEN" | jq .{
"configured": true,
"backend": "sqlite",
"publicKey": "d991d21af0635a2eacdca9e85966fd8f08bdb3580d28c72dc0fee4f334febf23",
"generation": 1,
"rotatedAt": 1788609712,
"appliesToFutureSessionsOnly": true,
"trustedPublicKeys": [
"d991d21af0635a2eacdca9e85966fd8f08bdb3580d28c72dc0fee4f334febf23"
]
}$ curl -sS -X POST "$CHIO_CONTROL/v1/authority" \
-H "Authorization: Bearer $CHIO_TOKEN" | jq .{
"appliesToFutureSessionsOnly": true,
"backend": "sqlite",
"configured": true,
"generation": 2,
"publicKey": "4a3756f1650d76f57a8d7cc09a83aa95c4b14cfe339857e9db2fde282783a807",
"rotatedAt": 1788609712,
"trustedPublicKeys": [
"d991d21af0635a2eacdca9e85966fd8f08bdb3580d28c72dc0fee4f334febf23",
"4a3756f1650d76f57a8d7cc09a83aa95c4b14cfe339857e9db2fde282783a807"
]
}You should see generation go from 1 to 2 and publicKey change, the pair a replica compares. trustedPublicKeys holds both keys, so a capability the old key signed verifies until it expires or is revoked, and appliesToFutureSessionsOnly says the same of open sessions. After a compromise, remove the old key from relying verifiers' admission trust while keeping it to verify what it already signed.
Rotate through the route, which every replica reads. With keyring custody configured it runs the witnessed rotation, and a hand edit of the seed file would bypass the key log. Then reissue signed verifier policies and reputation credentials under the new key so issuer_allowlist entries keep matching.
7. Reconcile what the revoked authority did
Pull the receipts for the compromised window. A refusal writes a signed receipt on the same schema as an allow.
chio --control-url http://127.0.0.1:8787 \
--control-token operator-admin-token \
receipt list --outcome deny --limit 5{
"action": {
"parameter_hash": "88bab6d8f6dc68a877064d584cbb5b6c50e74f617ea50d81d3a53c2ee6ffbc4f",
"parameters": {
"percent": 0,
"sku": "sku-204"
}
},
"boundary_class": "prevent",
"capability_id": "cap-019fbe3d-bcc9-7263-9752-68702b83f369",
"content_hash": "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b",
"decision": {
"guard": "kernel",
"reason": "capability has been revoked: cap-019fbe3d-bcc9-7263-9752-68702b83f369",
"verdict": "deny"
},
"id": "110b30ea4829d0cda2b23fa65c117906532bd0ae45a60297c4fd76c00f14382b",
"kernel_key": "c56228c41776e96d3b266053c0cfd3e2b8439cad5b83730b37a8d2d8ec4fa226",
"metadata": {
"attribution": {
"delegation_depth": 0,
"issuer_key": "617e2042e610c87a33ba9876e6cb6d750389db1dd74010b62372aa4e6e7eb73d",
"subject_key": "ead3b427b2954a3373dce7976618a5af04d7d4e60eccfdeb11ccc32bfd163172"
},
"chio_receipt_signing_nonce": "rcpt-019fbe3d-bf7e-7ab2-89b0-f5a89725b5dc",
"receipt_context": {
"request_id": "mcp-edge-req-a26edfd6b5b14d6aa4d8b81697e975dd"
}
},
"policy_hash": "69e943b96e9ce64d0264bb56bf8930e77bd4e68adf68fcf1395790dae03e6b55",
"receipt_kind": "mediated_decision",
"redaction_mode": "none",
"signature": "a1ea2190da78070fea48d91464b3756f2110b7bba5e3e3f282df3bc2df7abcbeaac386bfb8c9b534e8a365e5f147132884fc4bceb39fc3e082b687cba7687a0d",
"timestamp": 1785603145,
"tool_name": "quote",
"tool_origin": "caller_executed",
"tool_server": "pricing",
"trust_level": "mediated"
}Four fields carry the incident. decision.guard is kernel on every kernel-built deny, naming the layer that refused; decision.reason, or evidence[].guard_name for a guard denial, names the check. capability_id ties the denial to the allow before it, and the byte-identical action.parameter_hash shows both receipts describe one request.
Revocation leaves existing receipts unchanged: the allow stays beside the deny, both signed under kernel_key c56228c4..., and an incident review reads the pair.
Verifiers act on the revocation state published to them
Every node pointed at one trust-control service sees the same revocation set; a single-node kernel can use a local SQLite store. Passport resolutions carry an updatedAt and a cacheTtlSecs, so a consumer can tell current active state from fail-closed stale state.
# Capability status
chio --json \
--revocation-db revocations.sqlite3 \
trust status \
--capability-id cap-019fbe3d-bcc9-7263-9752-68702b83f369
# Passport lifecycle
chio passport status resolve \
--passport-id <passport-artifact-id> \
--passport-statuses-file passport-statuses.json
# Passport lifecycle via the public holder route (read-only, no admin token)
chio passport status resolve \
--passport-id <passport-artifact-id> \
--control-url https://trust.larkfield.example$ chio --json --revocation-db ./revocations.sqlite3 \
trust status --capability-id cap-test-123{
"capability_id": "cap-test-123",
"revocation_backend": "./revocations.sqlite3",
"revoked": true
}An id the store has never seen returns revoked: false, so a status poll treats "never issued" and "issued and live" alike.
$ chio --json --revocation-db ./revocations.sqlite3 \
trust status --capability-id cap-never-issued{
"capability_id": "cap-never-issued",
"revocation_backend": "./revocations.sqlite3",
"revoked": false
}One script runs the incident in order
Revoke capabilities, then the passport, then rotate the key. Revocation is safe to repeat; rotation and publication create new state, so reconcile the current key, generation, or publication before retrying an uncertain response.
# Emergency: one agent compromised, one operator key leaked
export CHIO_CONTROL=https://trust.larkfield.example
export CHIO_TOKEN=operator-admin-token
# 1. Revoke the agent's capability subtree
chio --control-url $CHIO_CONTROL --control-token $CHIO_TOKEN \
trust revoke --capability-id cap-019fbe3d-bcc9-7263-9752-68702b83f369
# 2. Revoke the passport
chio --control-url $CHIO_CONTROL --control-token $CHIO_TOKEN \
passport status revoke \
--passport-id passport-fixer-v3 \
--reason compromised
# 3. Rotate the authority key through trust control, which every replica reads
curl -X POST "$CHIO_CONTROL/v1/authority" \
-H "Authorization: Bearer $CHIO_TOKEN"
# 4. Confirm the new authority public key
curl "$CHIO_CONTROL/v1/authority" \
-H "Authorization: Bearer $CHIO_TOKEN"In an incident, revoke the token rather than waiting for its TTL; expiry supplements revocation.
An application host can revoke and rotate its own agent
The approval desk in pricing-desk runs its host on port 4310 with state in state/approvals. After Mara approves one price change, revoke the agent's capability:
curl --fail -X POST http://127.0.0.1:4310/agent/revoke \
-H "Authorization: Bearer $(cat state/approvals/mara-token.txt)"Mara can still approve, but the kernel refuses execution under the revoked grant and no file appears in price-changes: approval and execution authority are separate. Rotating issues new authority:
curl --fail -X POST http://127.0.0.1:4310/agent/rotate \
-H "Authorization: Bearer $(cat state/approvals/mara-token.txt)"The response names the old capability, the new capability, and the new agent subject key, which the host generates and retains. The host issues a grant with the same scope, revokes the previous one, and publishes the next approved price change under it. Pending proposals keep their original capability, existing receipts verify under the unchanged kernel key, and the kernel issuer and Mara's login stay as they were. If the response is lost, inspect the retained capability before retrying; another rotation creates another identity. Read the host implementation.
Each failure names the token, flag, or record it needs
missing or invalid control bearer token, HTTP 401
The control token differs from the service's --service-token. Check --control-token; reads of the public passport route need no token.
$ curl -sS -w '\nHTTP %{http_code}\n' "$CHIO_CONTROL/v1/authority" \
-H "Authorization: Bearer wrong-token"{"error":"missing or invalid control bearer token"}
HTTP 401trust control service requires --authority-seed-file or --authority-db, HTTP 409
The service has no authority backend to rotate. Restart it with --authority-db and rotate again.
trust control service requires --revocation-db, HTTP 409
The service holds no revocation store. Restart with a joint --session-db or a standalone --revocation-db.
a joint authority database replaces separate budget and revocation databases
Both --session-db and --revocation-db were passed. Drop --revocation-db; the joint database owns the revocation store.
was not found in the lifecycle registry
The passport was never published. Publish it with chio passport status publish, then revoke.
cannot include an empty revoked_reason
A lifecycle record with an empty revoked_reason fails PassportLifecycleRecord validation. Pass a non-empty --reason, or omit the flag.
delegation chain revoked at ancestor
A capability was presented under a revoked parent, as expected after a cascade. Reissue the subtree from a live ancestor.
delegation admission failed: missing capability snapshot for delegation ancestor
An ancestor snapshot is absent, or its depth or parent linkage disagrees with the presented chain. This lineage failure is independent of revocation; reissue the chain from an ancestor the kernel holds.
private directory ancestry must not be group or world writable unless sticky
The directory holding the SQLite files has permissive parents. Run chmod 700 on the working directory before starting the service.
Commands, flags, and records
Try trust revoke, --session-db, rotatedAt, cacheTtlSecs, or RevocationStore.
Commands and the state they write
| Action | Command | State it writes |
|---|---|---|
Rotate authority key | POST /v1/authority on trust control | Trust |
Revoke a passport | chio passport status revoke | Passport |
Supersede a passport | chio passport status publish | superseded_by = <new-id> |
Revoke a capability | chio trust revoke --capability-id ... | Revocation in the revocation store |
Check capability status | chio trust status --capability-id ... | Read-only query |
Check passport lifecycle | chio passport status resolve | Returns Passport |
Revocation and authority backends
--revocation-dbnames a local SQLite revocation store;--control-urlnames a trust-control service instead. Every revoke and status command targets one or the other.--control-tokenis sent as anAuthorization: Bearerheader. Revoking and rotating need it; reading a passport lifecycle record does not.chio trust servetakes a joint authority database (--session-db, which supplies the revocation store with budget state) or a standalone--revocation-db. The two are mutually exclusive, and with neither a revoke answers 409 naming the flag it wants.- On
--authority-dbthe authority status carries a monotonicgenerationand arotatedAtstamp. On--authority-seed-filethe rotation happens and both come back null, so a replica diffs the key itself. With neither, the rotate call answers 409. --passport-statuses-filenames a JSON passport statuses registry; trust control can host the same registry. A record must be published before it can be revoked.- With
--advertise-url, published passport records inherithttps://.../v1/public/passport/statuses/resolveas the default holder resolution endpoint, with acacheTtlSecsof 300.
Authority key and routes
An operator signing key is an Ed25519 keypair held as a seed file. chio-control-plane exports load_or_create_authority_keypair to read it and rotate_authority_keypair to roll it atomically: a new keypair is generated, written to the seed path, and its public key returned.
pub fn rotate_authority_keypair(path: &Path) -> Result<chio_core::PublicKey, CliError> {
let keypair = Keypair::generate();
write_authority_seed_file(path, &keypair)?;
Ok(keypair.public_key())
}POST /v1/authority performs the rotation on the service and returns a TrustAuthorityStatus carrying the new public key; GET /v1/authority reads it back. The status fields are configured, backend, publicKey, generation, rotatedAt, appliesToFutureSessionsOnly, and trustedPublicKeys. Verifiers query the route and pin the new key before the old key stops signing new capabilities. Policies signed before the rotation still verify against their signer but no longer name the current authority.
Revocation store
pub trait RevocationStore: Send + Sync {
/// Check if a capability ID has been revoked.
fn is_revoked(&self, capability_id: &str) -> Result<bool, RevocationStoreError>;
/// Revoke a capability. Returns `true` if it was newly revoked.
fn revoke(&self, capability_id: &str) -> Result<bool, RevocationStoreError>;
fn observe_revocation(
&self,
capability_id: &str,
) -> Result<RevocationObservation, RevocationStoreError> {
Ok(RevocationObservation {
revoked: self.is_revoked(capability_id)?,
commit: None,
})
}
/// Whether this store loses its revocation set on process restart. The
/// default is the safe (loud) assumption so an unknown store is treated as
/// ephemeral; durable and remote stores override to `false`.
fn is_ephemeral(&self) -> bool {
true
}
}Every method takes &self, so a store is shared behind an Arc and mutates through interior mutability. observe_revocation returns the boolean plus the commit metadata a distributed store attaches; its default answers from is_revoked with no commit. The SQLite store persists a two-field row:
pub struct RevocationRecord {
pub capability_id: String,
pub revoked_at: i64,
}SqliteRevocationStore exposes list_revocations_after for cursor-based replication and suits single-node kernels and offline proofs. Trust control exposes a revocation query API with cursor pagination and a revoke admin endpoint.
Revocation inside the kernel
Each admission through ChioKernel consults the RevocationStore. The write is one row, committed inside a runtime trace transition; with a trace observer installed, the commit becomes a RevocationCommitted event whose newly_revoked field separates a new revocation from a repeat.
pub fn revoke_capability(&self, capability_id: &CapabilityId) -> Result<(), KernelError> {
info!(capability_id = %capability_id, "revoking capability");
let trace_transition = self.lock_runtime_trace_transition()?;
let newly_revoked = self.with_revocation_store(|store| Ok(store.revoke(capability_id)?))?;
let trace_event = if self.runtime_trace_observer.is_some() {
Some(RuntimeTraceEvent::RevocationCommitted {
source_sequence: self.allocate_runtime_trace_source_sequence()?,
capability_id: capability_id.clone(),
newly_revoked,
delegation_depth_limit: self.config.max_delegation_depth,
})
} else {
None
};
drop(trace_transition);
if let Some(event) = trace_event {
self.observe_runtime_trace(event);
}
Ok(())
}Revoke and check against a local store
Both backends call the same revocation operation. Against a local store the command prints the capability, whether it was newly revoked, and the backend that committed the write.
# Local single-node kernel, persistent sqlite revocation store
chio \
--revocation-db revocations.sqlite3 \
trust revoke \
--capability-id cap-019fbe3d-bcc9-7263-9752-68702b83f369
# Trust-control service, cluster-wide visibility
chio \
--control-url https://trust.larkfield.example \
--control-token operator-admin-token \
trust revoke \
--capability-id cap-019fbe3d-bcc9-7263-9752-68702b83f369$ chio --revocation-db ./revocations.sqlite3 \
trust revoke --capability-id cap-test-123capability_id: cap-test-123 revoked: true newly_revoked: true backend: ./revocations.sqlite3
In JSON mode the result suits automated runbooks. The capture is a second run against the same store: revoked is still true and newly_revoked is false. The alphabetical key order is the canonical JSON the command-result envelope serializes in.
$ chio --json --revocation-db ./revocations.sqlite3 \
trust revoke --capability-id cap-test-123{
"capability_id": "cap-test-123",
"newly_revoked": false,
"revocation_backend": "./revocations.sqlite3",
"revoked": true
}Passport lifecycle record
An Agent Passport is a bundle of signed credentials. Revocation operates on its published PassportLifecycleRecord, never the signed passport: the record moves from Active to Revoked, stamps revoked_at, and may carry a revoked_reason that later resolutions return. A new passport for the same subject replaces the old one through a record whose superseded_by names the new passport: a lifecycle transition relying parties honor, distinct from a security revocation.
pub struct PassportLifecycleRecord {
pub passport_id: String,
pub subject: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub issuers: Vec<String>,
pub issuer_count: usize,
pub published_at: u64,
#[serde(default)]
pub updated_at: u64,
pub status: PassportLifecycleState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub superseded_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revoked_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revoked_reason: Option<String>,
#[serde(default, skip_serializing_if = "PassportStatusDistribution::is_empty")]
pub distribution: PassportStatusDistribution,
pub valid_until: String,
}Passport revocations resolve through /v1/public/passport/statuses/resolve/{passport_id}. PassportStatusDistribution validation rejects a distribution that publishes resolve URLs without a cacheTtlSecs, and rejects a TTL of zero. chio did resolve --passport-status-url ... advertises the resolve URL in the subject's DID document as a ChioPassportStatusService service entry.