Skip to content

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.

rust
use kanoniv_agent_auth::verify_invocation_with_revocation;

let result = verify_invocation_with_revocation(
    &invocation,
    &invoker_identity,
    &root_identity,
    |hash| revoked_hashes.contains(hash),
)?;
typescript
import { verifyInvocationWithRevocation } from "@kanoniv/agent-auth";

const result = verifyInvocationWithRevocation(
  invocation,
  invokerIdentity,
  rootIdentity,
  (hash) => revokedSet.has(hash),
);
python
# 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.

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

rust
// 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: DelegationRevoked

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

rust
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),
);
typescript
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.

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

sql
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);
rust
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:

bash
# 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.

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

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

The identity and delegation layer for AI agents.