Skip to content

Revocation

A DelegationCert carries issued_at and expires_at. The natural way for it to stop being valid is to wait until now > expires_at. But sometimes you need it to stop being valid right now — the agent’s key was leaked, the principal changed their mind, the relationship ended. That’s what revocation is for.

┌─────────────────────────────────────────────┐
│ Principal (Alice) │
│ │
│ Decides to revoke a cert she issued │
│ (e.g. agent's key was leaked). │
└──────────────────┬──────────────────────────┘
│ 1. Signs a RevocationList:
│ issuer_id: <Alice's ID>
│ updated_at: now
│ revoked_certs: ["cert-abc", ...]
│ signature: hybrid (Ed25519+ML-DSA-65)
┌─────────────────────────────────────────────┐
│ Distribution │
│ │
│ - Self-hosted by Alice, OR │
│ - Hosted by a registry, OR │
│ - Pushed in real time via RevocationPush │
└──────────────────┬──────────────────────────┘
│ consulted by the verifier's
│ RevocationProvider on every Verify
┌─────────────────────────────────────────────┐
│ Verifier │
│ │
│ For each cert in bundle.delegations: │
│ revoked, err := provider.IsRevoked(id) │
│ if err → identity_status: invalid │
│ (revocation_error) │
│ if revoked → identity_status: revoked │
└─────────────────────────────────────────────┘

RevocationList is the canonical, hybrid-signed payload an issuer publishes (types.goRevocationList):

{
"issuer_id": "92cb0a15572d7a71",
"updated_at": 1800000000,
"revoked_certs": [
"cert-abc-001",
"cert-xyz-042"
],
"signature": { "ed25519": "...", "ml_dsa_65": "..." }
}

That’s it — four fields. The list is signed by the same root key that signed the certs it’s revoking, so consumers can authenticate it without a CA. There’s no version, expires_at, or per-entry metadata on the wire; freshness is managed at the distribution layer via TTL on the fetch, not by an embedded expiry.

There is also a v1.1 push variant (RevocationPush) — a signed delta carrying {issuer_id, seq_no, entries, pushed_at, signature} — for verifiers that maintain a long- lived subscription and need sub-second propagation. Edge / serverless verifiers stick with the pull model.

The verifier never fetches a RevocationList itself. Revocation is a provider hook you plug into VerifyOptions — that way the same SDK can talk to a self-hosted endpoint, a managed registry, a Redis cache, or all three in front of each other.

The interface is one method:

// Go
type RevocationProvider interface {
IsRevoked(certID string) (bool, error)
}
// TypeScript
interface RevocationProvider {
isRevoked(certID: string): Promise<boolean>; // throw on lookup failure
}
# Python
class RevocationProvider(Protocol):
def is_revoked(self, cert_id: str) -> bool: ... # raise on lookup failure
// Rust
pub trait RevocationProvider {
fn is_revoked(&self, cert_id: &str) -> Result<bool, String>;
}

Wire it up by setting VerifyOptions.Revocation (Go field name; equivalents in TS/Py/Rust):

opts := ratify.VerifyOptions{
RequiredScope: "meeting:attend",
Revocation: myProvider,
}
result := ratify.Verify(&bundle, opts)

The verifier is fail-closed: a provider that returns an error fails the cert as identity_status: invalid with error_reason: "revocation_error: ...". There is no fallback to “allow because we can’t tell” — a verifier that doesn’t know revocation state MUST NOT report a cert as valid.

The three outcomes:

Provider returnsidentity_statuserror_reason
(false, nil)continues to next check
(true, nil)revokedcert-xyz revoked
(_, err)invalidrevocation_error: <err>

Provider implementations decide their own freshness policy: a RevocationList cached in Redis with a 60-second TTL behaves differently from one served directly by Cloudflare KV with edge-replicated push. The RevocationProvider interface is intentionally narrow so that policy is yours, not the SDK’s.

For v1.0 backward compatibility, VerifyOptions still accepts a plain closure IsRevoked: func(certID string) bool. It is deprecated because there’s no way to surface lookup failures — the closure must collapse “I don’t know” into either false (allow) or true (deny). It will be removed in v1.0.0-beta.1. Use RevocationProvider in new code.

When both are set, RevocationProvider wins.

VerifyOptions.ForceRevocationCheck = true is the high-stakes path: it signals your provider implementation that it should bypass any local cache and re-fetch the freshest state. The verifier itself doesn’t perform the fetch — that’s your provider’s job — but it does guarantee one safety check: if ForceRevocationCheck is true and neither RevocationProvider nor IsRevoked is set, the bundle is failed with force_revocation_no_callback. The caller asked for fresh revocation state but provided no way to check it.

Sub-delegation: every cert in the chain is checked

Section titled “Sub-delegation: every cert in the chain is checked”

When the chain is Alice → Agent-A → Agent-B, every cert in bundle.delegations is passed through IsRevoked independently:

For each cert in [A→B, Alice→A]:
revoked, err := provider.IsRevoked(cert.cert_id)
...

Alice can revoke Alice→A — which transitively kills everything Agent-A sub-delegated, because the chain check fails on the first link. Agent-A can revoke A→B without affecting Alice’s relationship with Agent-A.

The protocol does not support a “revoke everything from Alice” call. Revocation is always per-cert. If you need a global revoke, your provider returns (true, nil) for every cert_id issued by Alice’s root.

Suggested TTLs by surface (the protocol doesn’t mandate these — they’re operational guidance):

SurfaceCache TTLMax staleness before fail-closed
Meeting verification60 s300 s
Voice gateway30 s120 s
API gateway60 s300 s
Physical AI / offline5 minmission-duration cached

Tighter TTL = revocations propagate faster, more network calls. Looser TTL = better availability during partial outages, slower propagation. Pick a max-staleness your provider enforces by returning an error past that threshold — the verifier will then fail closed.

RevocationShort expires_at
MechanismRevocationProvider.IsRevoked per VerifyPlain timestamp check, no I/O
Propagation speedSeconds (TTL-bounded)Bounded by cert lifetime
Verifier costNetwork call (cacheable)None
Operational burdenMaintain a list endpoint or registryNone
Best forCompromised keys, terminated relationshipsRoutine refresh, blast-radius shrinkage

Common pattern: short expiry + revocation together. Issue certs with 1-hour expiry by default, refresh on a heartbeat, only revoke when something goes wrong. Worst case is a 1-hour blast radius even if revocation hosting is completely down.

RevocationList and RevocationPush wire shapes: types.go. RevocationProvider interface and the verifier integration: verify.go. SPEC.md §11 (revocation) and §17.1 (provider hook) are normative.