Revocation Service¶
HTTP service for publishing and querying DCP agent revocations. Supports both V1 (agent-level) and V2 (kid-level, emergency revocation, short-lived cert tracking). Exposes a .well-known endpoint for standardized queries.
Every write is authenticated. The operator registers each new agent with its self-signed V2
passport (POST /v2/register with the operator token): the composite signature proves possession
of the signer keys, so the service records those kids as the agent's, with their expiry and the
passport's emergency revocation token. Re-registering is self-service, and revocations must be
signed by the registered keys. State is in memory unless REVOCATION_STATE_PATH names a file
(see Configuration).
The write examples below (POST /revoke, POST /v2/register, POST /v2/revoke, POST /v2/emergency-revoke) are
templates: their signatures, passports and secrets are placeholders (...), so they do not run as they are, and they
are not to be filled with invented values. index.test.js shows how real ones are built.
Quickstart¶
# Start the service, from the root of a checkout: it imports @dcp-ai/sdk, so build the
# workspace SDK and install the root package first
(cd sdks/typescript && npm install && npm run build) && npm install
node services/revocation/index.js
# Or with Docker, from the repo root
docker compose -f docker/docker-compose.yml up revocation
The service listens on http://localhost:3003 by default.
V1 Endpoints¶
GET /health¶
Service health check.
{
"ok": true,
"service": "dcp-revocation",
"supported_versions": ["1.0", "2.0"],
"registered_agents": 2,
"total_revocations": 3,
"total_kid_revocations": 1
}
POST /revoke¶
Publish an agent-level revocation (V1). signature is an Ed25519 signature (base64) over the
V1 canonical JSON of { dcp_version, agent_id, human_id, timestamp, reason } as sent
(signObject in tools/crypto.js), made with one of the agent's active (registered, not revoked,
not expired) ed25519 keys. The agent must have registered with POST /v2/register first, which
takes a hybrid passport signature: an Ed25519-only (V1) agent cannot publish revocations here.
curl -X POST http://localhost:3003/revoke \
-H "Content-Type: application/json" \
-d '{
"dcp_version": "1.0",
"agent_id": "agent:research-bot-001",
"human_id": "human:alice-001",
"timestamp": "2026-03-01T00:00:00Z",
"reason": "Compromised credentials",
"signature": "base64-signature..."
}'
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
agent_id |
string |
Yes | ID of the agent to revoke |
human_id |
string |
No | ID of the responsible human |
reason |
string |
Yes | Reason for revocation |
signature |
string |
Yes | Ed25519 signature over the other fields |
dcp_version |
string |
No | DCP version (default: "1.0") |
timestamp |
string |
No | ISO 8601 (default: now) |
Fields left out of the request are left out of the signed object; defaults are filled in afterwards.
Response (201):
Error responses: 400 (missing fields), 403 (signature does not verify against the agent's
registered ed25519 keys), 409 (agent already revoked: revocations are final, so an emergency
revocation cannot be overwritten).
GET /check/:agent_id¶
Check if an agent is revoked.
Revoked agent:
{
"revoked": true,
"record": {
"dcp_version": "1.0",
"agent_id": "agent-001",
"human_id": "human-001",
"timestamp": "2026-03-01T00:00:00.000Z",
"reason": "Compromised credentials",
"signature": "base64-signature..."
}
}
Non-revoked agent:
GET /list¶
List all agent-level revocations.
V2 Endpoints¶
POST /v2/register¶
Register an agent's keys and emergency revocation token. The body is the agent's self-signed
passport, the SignedPayload of an AgentPassportV2 signed under DCP-AI.v2.AgentPassport by
the keys it lists (the agent_passport of an SDK-built bundle). The composite signature must be
pq_over_classical.
Registering a new agent_id needs Authorization: Bearer <OPERATOR_TOKEN>. A passport is
only self-signed, and so is an RPR, so without a trust anchor anyone could claim a victim's
agent_id first, plant their own token and emergency-revoke it. The operator vouches for new
agents instead, and with no OPERATOR_TOKEN configured nobody can register one. Re-registering an
existing agent needs no token.
curl -X POST http://localhost:3003/v2/register \
-H "Authorization: Bearer $OPERATOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "signed_passport": { "payload": { "dcp_version": "2.0", "agent_id": "...", "keys": [...], "emergency_revocation_token": "sha256:...", ... }, "payload_hash": "sha256:...", "composite_sig": { ... } } }'
- Only the two signer keys (classical + PQ) are registered: listing someone else's public key
registers nothing. Their
expires_atdrives theexpiredanswer ofGET /v2/check/kid/:kid. - Registering an
agent_idagain (for example to replace the token) needs a passport whosecreated_atis newer than the registered one, since signed passports are public and an old one could be replayed, signed by exactly the agent's active classical and PQ keys (not revoked, not expired). The registered key entries are kept, so a new passport can neither extend a key'sexpires_atnor add keys, and a passport without a token keeps the registered one. - An agent that has lost its active classical or PQ key (expired, or revoked by kid) stays
locked: its remaining key alone could otherwise pair itself with a new partner key and take the
agent over. Recovery goes through the emergency revocation token (revoke, then register a new
agent_id) or the operator, who has no API for it yet.
Response (201):
Error responses: 400 (not a V2 signed passport, no valid created_at, or a malformed token
or key expiry), 401 (a new agent_id without the operator token), 403 (signature invalid, or
agent emergency-revoked), 409 (agent registered with
other keys, passport not newer than the registered one, agent without an active classical or PQ
key, or a kid that is revoked, expired or owned by another agent), 429 (more than
REGISTRATIONS_PER_HOUR calls from this client), 507 (MAX_AGENTS reached).
POST /v2/revoke¶
Kid-level revocation with composite signature. composite_sig signs the record without it
(dcp_version, agent_id, human_id, revoked_kid, timestamp, reason, as sent) under
DCP-AI.v2.Revocation, with the agent's active keys; revoked_kid must be one of its registered kids.
curl -X POST http://localhost:3003/v2/revoke \
-H "Content-Type: application/json" \
-d '{
"dcp_version": "2.0",
"agent_id": "agent-001",
"human_id": "human-001",
"revoked_kid": "a1b2c3d4e5f6...",
"reason": "key_compromise",
"composite_sig": {
"classical": { "alg": "ed25519", "kid": "abc123", "sig_b64": "..." },
"pq": { "alg": "ml-dsa-65", "kid": "def456", "sig_b64": "..." },
"binding": "pq_over_classical"
}
}'
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
dcp_version |
string |
Yes | Must be "2.0" |
agent_id |
string |
Yes | ID of the agent |
human_id |
string |
No | ID of the responsible human |
revoked_kid |
string |
Yes | Key identifier to revoke |
reason |
string |
Yes | Reason for revocation |
composite_sig |
object |
Yes | Composite signature: classical and PQ, both required (a classical-only signature is refused) |
Response (201):
{
"ok": true,
"agent_id": "agent-001",
"revoked_kid": "a1b2c3d4e5f6...",
"revoked_at": "2026-03-01T00:00:00.000Z"
}
Error responses: 400 (missing fields or dcp_version), 403 (revoked_kid is not a
registered key of agent_id, or composite_sig does not verify against the agent's keys).
POST /v2/emergency-revoke¶
Emergency revocation (panic button). Revokes ALL keys for an agent using the pre-image of the
emergency_revocation_token registered with its passport. Rate-limited to 5 attempts per minute
per client IP (socket address, or the last X-Forwarded-For hop with TRUST_PROXY=true).
curl -X POST http://localhost:3003/v2/emergency-revoke \
-H "Content-Type: application/json" \
-d '{
"agent_id": "agent-001",
"revocation_secret": "a1b2c3d4e5f6..."
}'
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
agent_id |
string |
Yes | ID of the agent to revoke |
human_id |
string |
No | ID of the responsible human |
revocation_secret |
string |
Yes | Pre-image of the registered token (64 hex chars) |
reason |
string |
No | Reason (default: "key_compromise_emergency") |
timestamp |
string |
No | ISO 8601 (default: now) |
Response (200):
{
"ok": true,
"agent_id": "agent-001",
"revoked_at": "2026-03-01T00:00:00.000Z",
"keys_revoked": 2
}
Error responses: 400 (bad format), 403 (invalid secret), 404 (no token registered), 409 (token already consumed), 429 (rate limit).
POST /v2/register-emergency-token and POST /v2/register-key-expiry were removed: they
accepted unsigned requests, so anyone could replace a token or mark any key expired. The token
and the key expiry now come from the signed passport (POST /v2/register).
GET /v2/check/kid/:kid¶
Check if a registered key identifier is revoked: an explicit kid revocation, an emergency
revocation of its owner, or expiry. The service only knows the kids that signed an agent's
registration (POST /v2/register). Any other kid gets 404 { "registered": false }. That
covers kids listed in a passport without signing it, rotated at the gateway, or belonging to an
agent that never registered here: their status here is unknown, not "not revoked". ?agent_id=
is ignored, so a caller cannot borrow another agent's verdict for a kid.
Revoked key:
{
"revoked": true,
"kid": "a1b2c3d4e5f6",
"agent_id": "agent-001",
"reason": "key_compromise",
"revoked_at": "2026-03-01T00:00:00.000Z"
}
Active key:
GET /v2/list¶
List all revocations including agent-level and kid-level.
{
"dcp_version": "2.0",
"agent_revocations": [...],
"kid_revocations": [...],
"total_agents": 2,
"total_kids": 5
}
Shared Endpoints¶
GET /.well-known/dcp-revocations.json¶
Standardized endpoint for querying all revocations (V1 + V2). Follows RFC 8615.
{
"dcp_version": "2.0",
"updated_at": "2026-03-01T00:00:00.000Z",
"revocations": [
{
"dcp_version": "2.0",
"agent_id": "agent-001",
"human_id": "human-001",
"timestamp": "2026-03-01T00:00:00.000Z",
"reason": "Compromised credentials",
"signature": "composite:a1b2c3d4"
}
],
"kid_revocations": [
{
"dcp_version": "2.0",
"agent_id": "agent-001",
"revoked_kid": "a1b2c3d4e5f6...",
"timestamp": "2026-03-01T00:00:00.000Z",
"reason": "key_compromise"
}
]
}
This endpoint can be:
- Queried by any verifier
- Cached by CDNs
- Served as a static file in production
The list carries no signature: a verifier trusts it because of the URL and HTTPS it comes from, so a CDN or static copy must keep that trust. A cached copy is only as current as its last refresh: a newly added revocation is not reflected until the copy is refreshed. Other admission checks and freshness requirements still apply. The admission revocation source reads this format as a complete deny list and fails when it cannot fetch it (docs/ADMISSION.md).
Configuration¶
Environment Variables¶
| Variable | Default | Description |
|---|---|---|
PORT |
3003 |
HTTP port |
OPERATOR_TOKEN |
— | Bearer token for registering a new agent_id. Unset: no new agents |
REVOCATION_STATE_PATH |
— | File that keeps registrations and revocations across restarts. Unset: memory only. Every write is appended as one fsynced JSON line (a re-registration only records what changed). At start the file is replayed, then compacted to one line per entry through a temp file and an atomic rename. The service refuses to start if the path is not writable or the file exceeds 512 MB |
TRUST_PROXY |
— | true only behind a proxy that appends the client IP to X-Forwarded-For |
REGISTRATIONS_PER_HOUR |
20 |
POST /v2/register calls per client per hour; more get 429 |
MAX_AGENTS |
10000 |
Registered agents; new agents then get 507 (nothing is evicted) |
Without REVOCATION_STATE_PATH, state is in memory and does not survive a restart, deploy
or crash: registrations and every revocation are lost, so revoked agents look valid again. On
Fly, put the file on a volume (see deploy/fly/revocation.toml); the Docker Compose stack keeps
it on the revocation-data volume. The limits above keep a flood of
registrations from running the service out of memory. Records are also bounded:
- human_id, timestamp and reason are at most 256 characters, and V2 reason is one of
key_compromise, key_rotation, administrative, key_compromise_emergency.
- emergency_revocation_token must be sha256:<64 hex>.
- Only the signature fields of composite_sig are stored.
Integration¶
Querying from middleware¶
The Express middleware's checkRevocation: true queries GET /check/:agent_id and GET /v2/check/kid/:kid on every request. It lets the request through when the service does not answer, unless revocationFailClosed: true. Admission (Express, FastAPI, OpenClaw and the reference gateway, with the strict-v1 profile) reads /.well-known/dcp-revocations.json as a deny list instead, and refuses the request when it cannot.
V2 flow¶
- The operator registers the agent's signed passport during setup (
POST /v2/registerwith the operator token): keys, their expiry and the emergency token - Revoke individual keys as needed, signed by the agent's keys (
POST /v2/revoke) - Use panic button in emergencies (
POST /v2/emergency-revoke) - Check key status before accepting bundles (
GET /v2/check/kid/:kid)
Development¶
PORT=3003 node services/revocation/index.js # from the repo root, after npm install there
curl http://localhost:3003/health
curl http://localhost:3003/check/test-001
curl http://localhost:3003/v2/list
curl http://localhost:3003/.well-known/dcp-revocations.json
# Writes need signatures; index.test.js builds signed passports and revocations
node --test services/revocation/index.test.js # from the repo root
License¶
Apache-2.0