Skip to content

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.

curl http://localhost:3003/health
{
  "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):

{
  "ok": true,
  "agent_id": "agent-001",
  "revoked_at": "2026-03-01T00:00:00.000Z"
}

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.

curl http://localhost:3003/check/agent-001

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:

{
  "revoked": false,
  "agent_id": "agent-001"
}

GET /list

List all agent-level revocations.

curl http://localhost:3003/list
{
  "revocations": [...],
  "total": 3
}

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_at drives the expired answer of GET /v2/check/kid/:kid.
  • Registering an agent_id again (for example to replace the token) needs a passport whose created_at is 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's expires_at nor 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):

{ "ok": true, "agent_id": "agent-001", "registered_kids": ["a1b2...", "c3d4..."] }

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.

curl "http://localhost:3003/v2/check/kid/a1b2c3d4e5f6"

Revoked key:

{
  "revoked": true,
  "kid": "a1b2c3d4e5f6",
  "agent_id": "agent-001",
  "reason": "key_compromise",
  "revoked_at": "2026-03-01T00:00:00.000Z"
}

Active key:

{ "revoked": false, "kid": "a1b2c3d4e5f6" }

GET /v2/list

List all revocations including agent-level and kid-level.

curl http://localhost:3003/v2/list
{
  "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.

curl http://localhost:3003/.well-known/dcp-revocations.json
{
  "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

  1. The operator registers the agent's signed passport during setup (POST /v2/register with the operator token): keys, their expiry and the emergency token
  2. Revoke individual keys as needed, signed by the agent's keys (POST /v2/revoke)
  3. Use panic button in emergencies (POST /v2/emergency-revoke)
  4. 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