Revocation
Delegations are long-lived by default. Once issued, a delegation remains valid until its expires_at caveat (if any) lapses. Revocation lets you invalidate a delegation immediately, before it would otherwise expire.
The is_revoked Callback
Revocation is implemented as a callback, not a built-in store. When you call verify_invocation_with_revocation, you provide a function that takes a delegation's content hash and returns whether that delegation has been revoked.
use kanoniv_agent_auth::verify_invocation_with_revocation;
let result = verify_invocation_with_revocation(
&invocation,
&invoker_identity,
&root_identity,
|hash| revoked_hashes.contains(hash),
)?;import { verifyInvocationWithRevocation } from "@kanoniv/agent-auth";
const result = verifyInvocationWithRevocation(
invocation,
invokerIdentity,
rootIdentity,
(hash) => revokedSet.has(hash),
);# The Python SDK currently exposes verify_invocation without
# a revocation callback. For revocation support, check the
# delegation's content_hash() against your revocation store
# before calling verify_invocation.
delegation_hash = delegation.content_hash()
if delegation_hash in revoked_set:
raise ValueError(f"Delegation revoked: {delegation_hash}")
invoker_did, root_did, chain, depth = verify_invocation(
invocation, invoker_identity, root_identity)The callback is invoked for every delegation in the chain, not just the leaf. Revoking any link invalidates the entire downstream chain.
Content Hash as Revocation Identifier
Each delegation has a deterministic content hash - a SHA-256 hash of its signed envelope's canonical fields (nonce, payload, signature, signer_did, timestamp). This hash uniquely identifies the delegation and serves as the revocation key.
let delegation = Delegation::create_root(&root, &agent.identity().did, vec![]).unwrap();
let hash = delegation.proof.content_hash();
// e.g. "a1b2c3d4e5f6789012345678abcdef0123456789abcdef0123456789abcdef01"The content hash is:
- Deterministic - The same delegation always produces the same hash, computed from canonical (alphabetically sorted) JSON.
- Unique - Each delegation includes a UUID nonce, so two delegations with identical fields still have different hashes.
- Cross-language consistent - Rust, TypeScript, and Python all compute the same hash for the same delegation, because they use the same canonical field ordering (BTreeMap in Rust, sorted keys in TypeScript/Python).
Revoking a Delegation
To revoke a delegation, store its content hash in your revocation store. On the next verification, the is_revoked callback will find the hash and reject the invocation.
// When creating the delegation, record its hash
let delegation = Delegation::create_root(
&root,
&agent.identity().did,
vec![Caveat::ActionScope(vec!["resolve".into()])],
).unwrap();
let delegation_hash = delegation.proof.content_hash();
// Later, when you want to revoke it
revocation_store.insert(delegation_hash.clone());
// Now verification fails
let invocation = Invocation::create(
&agent, "resolve", serde_json::json!({}), delegation,
).unwrap();
let result = verify_invocation_with_revocation(
&invocation,
&agent.identity(),
&root.identity(),
|hash| revocation_store.contains(hash),
);
// Error: DelegationRevoked("a1b2c3d4...")Chain Revocation
Revoking a delegation in the middle of a chain invalidates everything downstream. If Root delegated to A, who delegated to B, who delegated to C, and you revoke A's delegation, then B and C are also blocked - their chain cannot be verified because it passes through the revoked link.
Root -> A -> B -> C
Revoke Root->A delegation:
C tries to invoke
-> walks chain: C -> B -> A -> Root
-> at A, checks is_revoked(A's delegation hash) -> true
-> Error: DelegationRevokedThis is by design. If you want to revoke A's access without affecting B and C, issue new delegations directly from Root to B and C.
Implementation Patterns
In-Memory HashSet
The simplest approach. Good for single-process systems or short-lived services.
use std::collections::HashSet;
use std::sync::{Arc, RwLock};
let revoked: Arc<RwLock<HashSet<String>>> = Arc::new(RwLock::new(HashSet::new()));
// Revoke
revoked.write().unwrap().insert(delegation_hash);
// Verify with revocation
let revoked_ref = revoked.clone();
let result = verify_invocation_with_revocation(
&invocation,
&invoker_identity,
&root_identity,
|hash| revoked_ref.read().unwrap().contains(hash),
);const revokedSet = new Set<string>();
// Revoke
revokedSet.add(delegationHash);
// Verify with revocation
const result = verifyInvocationWithRevocation(
invocation, invokerIdentity, rootIdentity,
(hash) => revokedSet.has(hash),
);Trade-offs: Fast (O(1) lookup), no external dependencies. But revocations are lost on restart, and not shared across processes.
Redis Set
For distributed systems where multiple services need a shared revocation store.
// Revoke: add hash to a Redis set
redis_conn.sadd("delegation:revoked", &delegation_hash).await?;
// Optional: set a TTL matching the delegation's max possible lifetime
// (no need to track revocations for already-expired delegations)
redis_conn.expire("delegation:revoked", 86400 * 30).await?; // 30 days
// Verify with revocation
let result = verify_invocation_with_revocation(
&invocation,
&invoker_identity,
&root_identity,
|hash| {
// Note: this is synchronous. For async Redis, pre-fetch
// all hashes in the chain before verification.
redis_conn_sync.sismember("delegation:revoked", hash).unwrap_or(true)
},
);Trade-offs: Shared across processes, survives restarts, fast (Redis SISMEMBER is O(1)). Requires Redis infrastructure.
Database Table
For systems that need audit trails and queryable revocation history.
CREATE TABLE revoked_delegations (
content_hash TEXT PRIMARY KEY,
revoked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_by TEXT NOT NULL, -- DID of the agent that revoked
reason TEXT
);
-- Revoke
INSERT INTO revoked_delegations (content_hash, revoked_by, reason)
VALUES ('a1b2c3d4...', 'did:agent:root...', 'Agent compromised');
-- Check
SELECT EXISTS(SELECT 1 FROM revoked_delegations WHERE content_hash = $1);let result = verify_invocation_with_revocation(
&invocation,
&invoker_identity,
&root_identity,
|hash| {
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM revoked_delegations WHERE content_hash = $1)")
.bind(hash)
.fetch_one(&pool)
.await
.unwrap_or(true) // fail-closed: if DB is down, treat as revoked
},
);Trade-offs: Full audit trail, queryable, durable. Higher latency per check (one DB round-trip per delegation in the chain). For chains of depth N, that is N queries. Consider caching or batch-checking.
OSS vs Cloud Revocation
OSS (kanoniv-agent-auth library)
The open-source library provides the is_revoked callback hook but does not include a built-in revocation store. You implement the store that fits your infrastructure - in-memory, Redis, database, or any other backend.
This is intentional. The library is a verification primitive, not a platform. It does not assume you have Redis, a database, or any specific infrastructure.
Kanoniv Cloud
Kanoniv Cloud provides a managed revocation service as part of the delegation API:
# Revoke a delegation
POST /v1/delegations/revoke
Content-Type: application/json
X-API-Key: kn_live_...
{
"content_hash": "a1b2c3d4e5f6..."
}
# List revocations
GET /v1/delegations/revoked
X-API-Key: kn_live_...The Cloud API's verify_invocation endpoint automatically checks the managed revocation store. No callback needed - revocations are enforced server-side.
Best Practices
Always Fail Closed
If your is_revoked callback cannot reach the revocation store (network error, timeout, DB down), return true or propagate the error. Never silently return false - that would allow a revoked delegation to pass verification during an outage.
// CORRECT: fail closed
|hash| redis.sismember("revoked", hash).unwrap_or(true)
// WRONG: fail open (allows revoked delegations during outage)
|hash| redis.sismember("revoked", hash).unwrap_or(false)Use expires_at as a Safety Net
Every delegation should have an expires_at caveat as a backstop. Even if revocation fails (store is down, hash is lost), the delegation will eventually expire. Short-lived delegations (minutes to hours) reduce the blast radius of a missed revocation.
Record Hashes at Creation Time
When you create a delegation, immediately store its content hash alongside the delegation metadata. You will need it later if you need to revoke.
let delegation = Delegation::create_root(&root, &agent.identity().did, vec![...]).unwrap();
let hash = delegation.proof.content_hash();
// Store for later revocation
db.insert_delegation_record(DelegationRecord {
content_hash: hash,
issuer_did: root.identity().did,
delegate_did: agent.identity().did,
created_at: Utc::now(),
}).await?;Revoke at the Highest Level Possible
If a manager agent is compromised, revoke the manager's delegation - not each of its sub-delegations individually. Revoking one link in the chain invalidates everything downstream. This is simpler and more reliable than tracking and revoking every leaf delegation.
