diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e42189..812fa45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Notable changes to the AgenID reference implementation. Format follows [Keep a C ### Added - **`/issue` hands the operator their `keys.json`.** The operator half of two-path key discovery (spec §9.3) is what keeps the registry non-authoritative, and the product told operators to publish it without giving them the file. The result screen now shows and downloads a strict `KeysDocument` built from the key document the registry just accepted, with the exact URL to serve it at. `lib/keys-document.ts` (dependency-free, client-safe); `test/keys-document.test.ts` (8) checks the output against `@agenid/core`'s `KeysDocument` and against `probeKeyDiscovery` — the function `/api/domain/status` and the Verification Card use — so the file handed out is the file accepted. The operator guide documents the format and the multi-agent case. +- **Operator-signed agent revocation — the first lifecycle transition this deployment writes.** `POST /api/v1/agents/{agenid}/revocation` accepts a `Revocation` (the v1.2-draft object, `revokes_type: "agent"`) signed by the agent's own operator key, and nothing else: another agent's operator key, an authority key, a tampered signature, a `revoked_at` more than five minutes from the registry clock, and a path/target mismatch are all refused before anything is written. Spec §12 makes `REVOKED` permanent: a second revocation or a replay is `409 already_revoked`, re-registration is `409 agent_exists`, and a revoked agent cannot be listed in the directory. The status change, the stored revocation and an `agent.revoked` ledger event (references and a sha-256 digest, never the object) are written in one Postgres transaction by `revoke_agent` (migration `0006`, RLS on, `EXECUTE` revoked from `PUBLIC`). The resolution envelope gains a `revocation` member — the full signed object when `REVOKED`, `null` otherwise — so a verifier can check that the operator made the change; the Verification Card shows who signed it. `/revoke` signs in the browser from the key file `/issue` offers, and asks for the reason, the typed identifier and an explicit permanence confirmation before it enables. The rules live once, in `packages/api/src/revoke.ts`, shared by the Fastify registry and the deployed route; a test fails if any other file calls `revokeAgent` or writes `REVOKED`. `packages/web/test/revocation.test.ts` (21), `packages/api/tests/revocation.test.ts` (4) and e2e 3s–3v; the standing, window and permanence guards were each proven to fail with the check removed. Every public page that said "no revocation flow" — homepage, `/trust`, `/badge`, and eleven passages in the content library — now says that agent revocation is operator-only and that key rotation, key revocation and every other status transition are still not deployed. `docs/revocation-propagation.md` proposes the missing half: how long a relying party may rely on an `ACTIVE` answer and what it must do when the registry is unreachable (proposal, not normative). - **This repository is public (2026-09-23, MIT).** Before the flip: a full-history gitleaks 8.30.1 scan (64 commits, 37 findings, every one a public test vector, key id, published pilot key or deliberate test string) and a working-tree scan (findings only in gitignored files). After: private vulnerability reporting enabled here (`{"enabled":true}` by API), secret scanning and push protection on, `main` protected against force-push and deletion. Every surface that said the monorepo was private now says it is public and links the real path: `SECURITY.md` routes reports to this repository first; Nav and Footer gain "Source (GitHub)"; the homepage gains a "Read the source" link; `/ecosystem` links `packages/web/data/ecosystem/` and accepts entries as pull requests; the eight partner briefs and the onboarding guide point at `packages/core` and `packages/mcp-server`. npm status is unchanged — nothing is published, and every sentence that says so still does. `public-links.test.ts` T-1 now allows `agenid` in the public set and fails on any linked monorepo path that does not exist in the checkout. - **v1.2-draft schemas served at `/schemas/v1.2/`** (`authorization-grant.json`, `revocation.json`; JSON Schema 2020-12; titled and commented *DRAFT — NOT NORMATIVE*). `@agenid/core` exports `GRANT_SCHEMA_ID` and `REVOCATION_SCHEMA_ID`, and the live Grok Bot pilot grant is signed with that `$schema` value, so both ids must resolve — this is what `check:schema-ids` required before a publish. The pilot grant validates against the grant schema under both ajv and core's zod validator; wildcard scopes, unknown members and a mismatched target are rejected. The schemas do not make the v1.2 layer normative or issuable. `test/schemas-v12.test.ts` pins the `$id` values to core's constants and the draft labels. - **Opt-in public agent directory (SEO strategic investment S9).** `/agents` lists registered agents whose operators signed a listing consent — and nobody else; registration alone lists nothing. The consent (`agenid.directory.consent.v1`) is a small object signed with the agent's own operator key over RFC 8785 canonical bytes, verified against the key the registry already holds, accepted only within five minutes of the registry clock and only if newer than the last consent recorded, so it can be neither forged for someone else's agent nor replayed after a delisting. `POST`/`GET /api/v1/directory` (rate limited, CORS, in the OpenAPI document), a "List this agent" action on the `/issue` confirmation screen while the key is still in the tab, and a key-file form on `/agents` to list or delist later — the private key is read in the browser and never sent. Listed agents' Verification Cards are added to the sitemap. Storage is `directory_listings` (migration `0005`, applied to production), RLS enabled, public read limited to listed rows, no write policies. A listing is not an endorsement and carries no level; each row's trust state is read from its resolution envelope through `lib/trust-presentation.ts`. `/trust` and the guides that said no directory exists were updated in the same commit. `test/directory.test.ts` (18 tests). diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 2c1d0f5..803e5e9 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -40,6 +40,7 @@ Capabilities that exist in code. Implemented is not the same as deployed, and ne | Site, `/issue`, resolver, badges, and the deployed API routes | `@agenid/web` | | Browser Ed25519 signer with an independent JCS implementation | `packages/web/lib/client-crypto.ts` | | Assertion write path (`POST /v1/agents/:id/assertions`) | `@agenid/api` — **not deployed** | +| Operator-signed agent revocation — `POST /api/v1/agents/{agenid}/revocation`, `/revoke`, envelope `revocation` member, atomic `revoke_agent` | Rules in `packages/api/src/revoke.ts` (one implementation, shared by the Fastify registry and `packages/web`); uses the v1.2-draft `Revocation` object | | Domain-control evidence — `POST /api/verify-dns` (real `_agenid.` TXT lookup) and `POST /api/domain/status` (provider detection, Domain Connect, and the operator's `.well-known` key document — reported `published` only when it strictly parses as a `KeysDocument` for that domain, never merely because the server answered 200) | `packages/web`. **No DNS write path exists:** `/api/dns/auto-add` is deleted, not unconfigured, and AgenID holds no provider credential | | Ecosystem registry and validator — 25 platforms, one reviewable JSON file each | `packages/web/data/ecosystem/`, `lib/ecosystem.ts` | | Long-form content library — 34-term glossary, 4 pillar guides, 6 sourced comparisons, voice-agent use-case hub, `/why-agent-identity` hub, research report, release-notes blog + RSS, `/badge` embed docs, and 17 `/how-it-works` scenarios (up from 9). One renderer that states the L1 issuance ceiling on every page; guarded by `test/content.test.ts` | `packages/web/lib/content/`, `components/content/`, `app/{glossary,learn,compare,use-cases,blog,state-of-agent-identity,badge}` | @@ -93,7 +94,8 @@ Exists in code, unavailable in production. | `/.well-known/agenid/authorities.json` | Correctly absent — publishing a pin for a nonexistent key would be the worst possible false claim | | `@agenid/api` as a running service | Production serves the equivalent routes from `packages/web`; the Fastify server is not deployed anywhere | | `0002_onboarding_tables.sql` | Written, deliberately unapplied. Wizard-specific state only; the wizard persists to the canonical tables today. | -| Agent statuses `CHANGED`, `STALE`, `SUSPENDED`, `REVOKED` | The column and enum exist so the envelope shape is stable, but no transition writes them. Every registered agent is `ACTIVE`. | +| Agent statuses `CHANGED`, `STALE`, `SUSPENDED` | The column and enum exist so the envelope shape is stable, but no transition writes them. `REVOKED` is written, and only by an operator-signed revocation. | +| Key rotation and key revocation | `KeyDocument` models `retired`/`revoked` correctly; no write path sets them, so every key is `active`. | ## Deprecated @@ -126,7 +128,7 @@ Full reference, verified live: [docs/api.md](docs/api.md). ## Current data model -Supabase Postgres. Applied migrations (confirmed 2026-10-19 against production): `0001_registry_store`, `0003_rate_limit`, `0004_rate_limit_grants`, `0005_directory_listings`. `0002_onboarding_tables` is deliberately unapplied. RLS is enabled on every table; the four registry tables have public-read policies and all writes go through the service role. The rate-limit and directory tables are described in their migrations. +Supabase Postgres. Applied migrations (confirmed 2026-10-19 against production): `0001_registry_store`, `0003_rate_limit`, `0004_rate_limit_grants`, `0005_directory_listings`, `0006_revocations`. `0002_onboarding_tables` is deliberately unapplied. RLS is enabled on every table; the four registry tables have public-read policies and all writes go through the service role. The rate-limit and directory tables are described in their migrations. | Table | Key | Contents | |---|---|---| @@ -134,12 +136,13 @@ Supabase Postgres. Applied migrations (confirmed 2026-10-19 against production): | `keys` | `key_id` (ULID) | `document` jsonb; `role` and `controller` are **generated columns** extracted from the jsonb (immutable, so they are legal generated expressions), indexed on `role` | | `assertions` | `assertion_id` | `subject`, `level`, `key_id`, `document` jsonb, `manifest_digest` generated from the jsonb, `verified_at` as a **plain** timestamptz column, indexed on `subject` | | `events` | `event_id` | Append-only ledger: `agent_id`, `type`, `occurred_at`, `detail_ref` jsonb — **pointers and hashes only, never raw evidence** | +| `revocations` | `revocation_id` | Operator-signed `Revocation` objects; at most one per agent; written only by the transactional `revoke_agent` function (migration `0006`) | Relationships are by identifier rather than by foreign key: an assertion's `subject` names an agent, and both a proof and an assertion name a `key_id`. Verification never relies on referential integrity — it re-derives everything from signatures. `verified_at` is deliberately **not** stored as `text`: `Rfc3339Utc` permits optional fractional seconds, and lexicographic order disagrees with chronological order across mixed precision. It is equally deliberately not a generated column cast from jsonb, which Postgres rejects as non-immutable (`42P17`). -Ledger event types: `agent.registered`, `key.published`, `assertion.issued`, `manifest.changed`, `agent.status_changed`, `agent.revoked`. Only the first two are written today. +Ledger event types: `agent.registered`, `key.published`, `assertion.issued`, `manifest.changed`, `agent.status_changed`, `agent.revoked`. `agent.registered`, `key.published` and `agent.revoked` are written today. ## Current integrations @@ -160,7 +163,7 @@ Full treatment: [docs/trust-model.md](docs/trust-model.md) and [docs/threat-mode **Controls in place.** Strict schema validation rejecting unknown members · Ed25519 signature and digest-binding verification on every write · key role and controller enforcement · bounded forward clock skew at registration with none at verification · operator keys generated and held client-side only, never transmitted or stored · RLS enabled on every table with public-read policies and service-role writes · append-only event ledger storing pointers, never evidence · a public-surface test suite that makes each honesty rule a build-breaking assertion. -**Known gaps.** No request-level authentication on public write endpoints (they are rate limited as of 2026-09-18, and every write is signature-verified, but nothing ties a live request to a registered agent) · no authorization model at all: verification levels say how carefully an identity was checked, never what an agent may do · no revocation path for an agent or a key · `/api/retell/agents` forwards a caller-supplied key to a third-party API from AgenID's domain — rate limited and refused unless same-origin, but a non-browser client can forge the origin headers · no trust root, so nothing above L1 can be signed · an operator who publishes no `.well-known` key copy leaves two-path discovery with a single source · no `security@` mailbox, because the `agenid.com` zone publishes no MX records. +**Known gaps.** No request-level authentication on public write endpoints (they are rate limited as of 2026-09-18, and every write is signature-verified, but nothing ties a live request to a registered agent) · no authorization model at all: verification levels say how carefully an identity was checked, never what an agent may do · agent revocation is operator-only, and its propagation to relying parties is unspecified · no key rotation or key revocation · `/api/retell/agents` forwards a caller-supplied key to a third-party API from AgenID's domain — rate limited and refused unless same-origin, but a non-browser client can forge the origin headers · no trust root, so nothing above L1 can be signed · an operator who publishes no `.well-known` key copy leaves two-path discovery with a single source · no `security@` mailbox, because the `agenid.com` zone publishes no MX records. ## Known limitations @@ -171,7 +174,7 @@ Full treatment: [docs/trust-model.md](docs/trust-model.md) and [docs/threat-mode 5. **Public write endpoints are unauthenticated.** Every write is signature-verified and self-attributed and every POST route is rate limited (since 2026-09-18), but the bound is per client-address range, so a widely distributed source is limited only in proportion to its ranges. `/api/retell/bind` caps its array at 25. 6. **`packages/web` carries a second registry implementation** mirroring `@agenid/api`'s validation, because `zod` is not resolvable inside `packages/web`. Equivalence is held by test, not by shared code. 7. **`/onboarding/retell` cannot complete a one-click DNS setup.** Its `apply_url` is null until AgenID's Domain Connect service template is registered with providers, so the operator adds the TXT record by hand. This is the correct failure mode — the alternative was a link that 404s on someone else's dashboard — but it is a real gap in that flow. -8. **Agent lifecycle statuses are stored but never written.** Every agent is `ACTIVE`; there is no revocation or status-change path in production. +8. **Only one lifecycle transition is written: operator-signed revocation.** `CHANGED`, `STALE` and `SUSPENDED` are never set, nothing but the operator can revoke an agent, and how fast a revocation reaches a relying party that cached `ACTIVE` is not specified ([docs/revocation-propagation.md](docs/revocation-propagation.md) is a proposal). 9. **Some content is mirrored rather than shared** — the v1.1.1 schemas and §8 vectors are copies of `AgenID-protocol/spec` (the `spec-sync` CI job fails on any difference), and `content/partners/` and `content/onboarding.md` are copies of `docs/` (`check:docs` fails on any difference, since 2026-10-19). The copies are enforced, not hand-checked, but they are still copies. 10. **`/onboarding/retell` has never been rendered in a browser.** It has been exercised over HTTP and by grepping the shipped JS bundle only. 11. **A malformed percent-escape is a `500` on a self-hosted `next start`.** `/v1/keys/%ZZ` and `/v1/keys/%` make Next's own parameter decoding throw before the route handler runs, so the raw-target rule never gets to answer them. It cannot be fixed inside a route handler. Production is unaffected: Vercel's edge refuses those targets with its own plain `400 Bad Request` first — verified by sending the raw target directly, since curl will not transmit it. @@ -192,6 +195,7 @@ Full treatment: [docs/trust-model.md](docs/trust-model.md) and [docs/threat-mode | Date | Change | |---|---| +| 2026-10-19 | **Operator-signed agent revocation** — the first lifecycle transition this deployment writes. Only the agent's own operator key has standing; status, stored revocation and ledger event are written in one transaction; the envelope carries the signed object; every public surface that said "no revocation flow" now says what is and is not deployed. Propagation semantics proposed in `docs/revocation-propagation.md` | | 2026-10-19 | **Retell relay is same-origin only** — `/api/retell/agents` refuses cross-site and non-JSON requests before reading the body; a test asserts a refused request never reaches Retell. **Mirrored docs enforced** — `check:docs` fails if the site's onboarding guide or any partner brief drifts from `docs/`, or a package LICENSE from the root | | 2026-09-28 | Editorial homepage redesign (`b2dff44`, PR #2); BugLoop feedback widget (`acd99ad`, PR #1) | | 2026-09-23 | **Opt-in agent directory (S9)** — `/agents` and `/api/v1/directory`; operator-signed, replay-bounded listing consent; migration `0005` applied; registration alone lists nothing | diff --git a/README.md b/README.md index 0a5de8e..1038c94 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Read [PROJECT_STATE.md](PROJECT_STATE.md) for the authoritative, dated breakdown | Identifier, canonicalization, schemas, Ed25519 proof engine (`@agenid/core`) | **Production** — conformance vectors passing | | Browser issuance → `L1_REGISTERED`, durable registry, resolution, badges | **Production** — verified live on `www.agenid.com` against Supabase | | Two-path key discovery — `GET /v1/keys/` plus the operator's `.well-known` copy | **Production** — both paths resolvable | +| Agent revocation — operator-signed, permanent, carried in the envelope | **Production** — `POST /api/v1/agents/{agenid}/revocation` and `/revoke`; key rotation and key revocation are **not deployed** | | Operator CLI (`@agenid/cli`), MCP server (`@agenid/mcp-server`) | **Implemented**, not published to npm | | Assertion issuance (`L2`–`L4`) | **Not deployed** — blocked on the root authority key ceremony | | `L5` | **Reserved by the spec. Not issuable.** | @@ -159,6 +160,7 @@ Stated plainly, because a trust product that hides these is not a trust product: | [docs/trust-model.md](docs/trust-model.md) | What a verifier must trust, what they can check themselves, and the verification levels | | [docs/threat-model.md](docs/threat-model.md) | Adversaries, assumptions, what the protocol defends against, and what it does not | | [docs/api.md](docs/api.md) | Endpoint reference, request and response shapes, error codes, deployment status | +| [docs/revocation-propagation.md](docs/revocation-propagation.md) | **Proposal, not normative** — how long a relying party may rely on an `ACTIVE` answer, and what to do when the registry is unreachable | | [docs/OPERATOR_ONBOARDING.md](docs/OPERATOR_ONBOARDING.md) | End-to-end operator walkthrough | | [docs/ECOSYSTEM-LOGO-REGISTRY.md](docs/ECOSYSTEM-LOGO-REGISTRY.md) | The registry behind every third-party name on the site: statuses, evidence rules, asset policy, and the tests that keep compatibility from reading as partnership | | [docs/brand-compliance-audit.md](docs/brand-compliance-audit.md) | Sixteen providers' trademark terms reviewed against displaying their marks here, and the machine-readable record that enforces the answer | diff --git a/docs/SECURITY-GAP-ANALYSIS.md b/docs/SECURITY-GAP-ANALYSIS.md index 2faeaf1..bc40989 100644 --- a/docs/SECURITY-GAP-ANALYSIS.md +++ b/docs/SECURITY-GAP-ANALYSIS.md @@ -56,18 +56,20 @@ computed. ## A. Agent Identity -**Verdict: implemented and sound. One real gap — no revocation path.** +**Verdict: implemented and sound. The revocation gap is partly closed (2026-10-19): operator-signed agent revocation is deployed; propagation semantics and key revocation are not.** | Property | State | Evidence | |---|---|---| | Agent ID format | `agenid:`, regex-enforced at every boundary | `packages/core/src/identifier.ts`, `AGENT_ID_REGEX` | | Registration | `POST /api/v1/agents` — 201 | signature, digest binding, key role, controller and expiry all checked before any write; single implementation in `packages/web/lib/register.ts` | | Ownership | operator key, `role=operator`, `controller === agent_id` | enforced in `verifyManifestProof` step 3 and re-checked at signing time | -| Lifecycle status | **stored, never written** | `agents.status` has a five-value CHECK (`ACTIVE`/`CHANGED`/`STALE`/`SUSPENDED`/`REVOKED`); only `ACTIVE` is ever set | -| Revocation | **does not exist** | see GAP-A1 | +| Lifecycle status | **`ACTIVE` and `REVOKED` written** | `agents.status` has a five-value CHECK; `REVOKED` is set only by `revoke_agent` after an operator-signed revocation verifies. `CHANGED`/`STALE`/`SUSPENDED` are never set | +| Revocation | **agent: deployed, operator-signed, permanent · key: does not exist** | see GAP-A1 | | Rotation | modelled correctly, no write path | see GAP-A2 | -### GAP-A1 — There is no revocation path for an agent. *(Severity: high)* +### GAP-A1 — There is no revocation path for an agent. *(Severity: high)* — **PARTLY CLOSED 2026-10-19** + +> **Update.** `POST /api/v1/agents/{agenid}/revocation` now accepts a `Revocation` signed by the agent's own operator key and records it atomically (status `REVOKED`, stored object, `agent.revoked` event); the envelope carries the signed object. See [api.md](api.md#post-apiv1agentsagenidrevocation--revoke-an-agent). **Still open:** propagation semantics ([revocation-propagation.md](revocation-propagation.md), a proposal), key revocation (GAP-A2), and suspension. The analysis below is the record of the gap as it was found. `LedgerEvent` declares the event types `manifest.changed`, `agent.status_changed`, `assertion.issued` and `agent.revoked`. Only `agent.registered` and `key.published` diff --git a/docs/api.md b/docs/api.md index ddd6f81..55d99a9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -29,7 +29,7 @@ Protocol v1.1.1 + errata E1/E2. | Surface | Limit | Window | |---|---|---| -| `POST /api/v1/agents`, `POST /api/retell/declare`, `POST /api/v1/directory` | 30 | 60s | +| `POST /api/v1/agents`, `POST /api/v1/agents/{agenid}/revocation`, `POST /api/retell/declare`, `POST /api/v1/directory` | 30 | 60s | | `POST /api/retell/bind` | 10 | 60s | | `POST /api/retell/agents` | 12 | 60s | | `POST /api/domain/status`, `POST /api/verify-dns` | 40 | 60s | @@ -51,9 +51,9 @@ Three properties worth stating because they are the ones that usually go wrong: ## CORS -**Verified by live request.** These eleven send `access-control-allow-origin: *` on their responses and are intended to be callable cross-origin from a browser, a CI job, or an agent runtime: +**Verified by live request.** These twelve send `access-control-allow-origin: *` on their responses and are intended to be callable cross-origin from a browser, a CI job, or an agent runtime: -`POST /api/v1/agents` · `POST /api/v1/verify` · `POST /api/retell/declare` · `POST /api/retell/bind` · `GET /api/v1/openapi.json` · `GET /api/resolve/{agenid}` · `GET /badge/{agenid}/shield.svg` · `GET /badge.js` · `GET /v1/keys/{key_ulid}` · `GET /api/v1/directory` · `POST /api/v1/directory` +`POST /api/v1/agents` · `POST /api/v1/verify` · `POST /api/retell/declare` · `POST /api/retell/bind` · `GET /api/v1/openapi.json` · `GET /api/resolve/{agenid}` · `GET /badge/{agenid}/shield.svg` · `GET /badge.js` · `GET /v1/keys/{key_ulid}` · `GET /api/v1/directory` · `POST /api/v1/directory` · `POST /api/v1/agents/{agenid}/revocation` The DNS routes and `/api/retell/agents` do **not** send it, and are same-origin only. @@ -79,11 +79,12 @@ The DNS routes and `/api/retell/agents` do **not** send it, and are same-origin | GET | `/v1/keys?key_id={percent-encoded logical id}` | **Production** — returns the identical document | | GET | `/api/v1/directory` | **Production** — opt-in listed agents only | | POST | `/api/v1/directory` | **Production** — operator-signed listing consent | +| POST | `/api/v1/agents/{agenid}/revocation` | **Production** — operator-signed, permanent agent revocation | | GET | `/blog/feed.xml` | **Production** — RSS for `/blog` | | POST | `/v1/agents/{id}/assertions` | **Not deployed** — 404. No root key exists to sign with. | | GET | `/.well-known/agenid/authorities.json` | **Not published** — 404, correctly | -Only the first four, plus the SVG badge, appear in the OpenAPI document. The Retell and DNS routes are deployed but undocumented there — see *Known documentation gaps* at the end. +Every deployed path appears in the OpenAPI document; see *OpenAPI* at the end. --- @@ -158,10 +159,13 @@ Returns the canonical **resolution envelope** — everything a verifier needs to }, "verification": { "level": "L1_REGISTERED", "valid_assertions": 0, "total_assertions": 0 }, "assertions": [], + "revocation": null, "verify_instructions": "how to re-verify this envelope without the registry" } ``` +`status` is `ACTIVE` or, after an operator-signed revocation, `REVOKED` — the only two values this deployment writes. `revocation` is the signed revocation object when `status` is `REVOKED` and `null` otherwise; see [revoke an agent](#post-apiv1agentsagenidrevocation--revoke-an-agent). + Two properties matter more than they look: - **Assertions are re-checked at resolution time against the *current* manifest.** They are not trusted because they were valid when issued — editing a manifest invalidates every assertion bound to the old one, automatically. @@ -356,6 +360,49 @@ A **registry feature of this deployment, not a protocol object**: spec v1.1.1 de `GET` returns at most 200 listed, `ACTIVE` agents (`agent_id`, `name`, `operator`, `operator_domain`, `listed_at`, `card`) and the disclosures. **A listing carries no verification level** and is not an endorsement; read each agent's level from `/a/{agenid}`. Storage: `directory_listings` (migration `0005`), RLS enabled, public read limited to listed rows, no write policies. +## POST /api/v1/agents/{agenid}/revocation — revoke an agent + +**Permanent.** Spec v1.1.1 §12 defines `REVOKED` as permanent and publicly resolvable: the identifier is never reissued, the record never disappears, and every badge and card renders `REVOKED` instead of a level. + +**Only the agent's own operator key has standing** — the key its ManifestProof names, with `role: operator` and `controller` equal to the agent. AgenID holds no key and no credential that can revoke an agent; the signature is the authorization, so this route is cross-origin callable like registration. + +The body is a signed **`Revocation`**, the v1.2-draft object (schema [`/schemas/v1.2/revocation.json`](https://www.agenid.com/schemas/v1.2/revocation.json), **draft, not normative**) with `revokes_type: "agent"`. v1.1.1 defines the status but no signed object for reaching it; this deployment uses the draft object rather than inventing a second shape for the same statement. + +```json +{ + "$schema": "https://agenid.com/schemas/v1.2/revocation.json", + "revocation_id": "revocation:", + "revokes": "agenid:", + "revokes_type": "agent", + "reason": "agent_retired", + "revoked_at": "2026-10-19T12:00:00.000Z", + "key_id": "agenid:key:", + "signature": "" +} +``` + +`reason` is one of `key_compromise`, `superseded`, `no_longer_authorized`, `operator_request`, `agent_retired`, `issued_in_error`, `unspecified`. It is the operator's statement; the registry checks who signed, not why. `revoked_at` must be within **five minutes** of the registry clock, so a revocation cannot be pre-signed and held or back-dated. + +The status change, the stored revocation and an `agent.revoked` ledger event (carrying `revocation_id`, a sha-256 `revocation_digest` and `reason` — references, not the object) are written **atomically** by one Postgres function (`revoke_agent`, migration `0006`). + +**Success — `200`:** `agent_id`, `status: "REVOKED"`, `revocation_id`, `reason`, `revoked_at`, `recorded_at` (the registry's clock), `revocation_digest`, `links` and `disclosures`. + +| Status | Error | Meaning | +|---|---|---| +| 400 | `invalid_json` / `invalid_revocation` | Not a strict `Revocation` (unknown members are refused) | +| 400 | `unsupported_revocation_target` | `revokes_type` is not `agent`; this registry records agent revocations only | +| 400 | `agent_id_mismatch` | `revokes` differs from the identifier in the path | +| 400 | `revocation_expired` | `revoked_at` is more than five minutes from the registry clock | +| 403 | `key_not_operator` | `key_id` is not this agent's operator key | +| 403 | `key_not_active` / `signature_invalid` | The key was not active at `revoked_at`, or the signature does not verify | +| 404 | `agent_not_found` | No such agent | +| 409 | `already_revoked` | Revocation is permanent; the first one stands and nothing is appended | +| 503 | `registry_unavailable` | Nothing was changed | + +After revocation the resolution envelope's **`revocation`** member carries the full signed object (it is `null` for every other status). A verifier should check it against `operator_key.document` and require `revocation.revokes === agent_id`, rather than take `status` on the registry's word. A browser flow that signs locally from the key file `/issue` offers is at [`/revoke`](https://www.agenid.com/revoke). + +**What is still not deployed:** key rotation and key revocation, and every other status transition (`CHANGED`, `STALE`, `SUSPENDED`). Propagation semantics — how long a relying party may cache an `ACTIVE` result — are not yet specified; see [revocation-propagation.md](revocation-propagation.md). + ## Not deployed **`POST /v1/agents/{id}/assertions`** — exists in `@agenid/api`, gated by `AGENID_AUTHORITY_TOKEN`, no deployed route, and no root authority key to sign an assertion with. @@ -398,7 +445,7 @@ The envelope carries its own `agenid_envelope_version: "1.0"`. ## OpenAPI -[`/api/v1/openapi.json`](https://www.agenid.com/api/v1/openapi.json) — **OpenAPI 3.0.3** (not 3.1). Verified live: it documents **all 16 deployed paths** — `/api/v1/agents`, `/api/v1/directory`, `/api/resolve/{agenid}`, `/a/{agenid}`, `/api/v1/verify`, `/api/v1/openapi.json`, `/badge/{agenid}/shield.svg`, `/badge.js`, `/blog/feed.xml`, `/v1/keys/{key_ulid}`, `/v1/keys`, `/api/verify-dns`, `/api/domain/status`, `/api/retell/declare`, `/api/retell/bind`, `/api/retell/agents` — eight component schemas (`RegisterRequest`, `RegisterResponse`, `VerifyRequest`, `VerifyResponse`, `ResolutionEnvelope`, `DomainControlResult`, `DomainStatus`, `Error`), and one server (`https://www.agenid.com`). Its request and response shapes match the implementation and this document. +[`/api/v1/openapi.json`](https://www.agenid.com/api/v1/openapi.json) — **OpenAPI 3.0.3** (not 3.1). Verified live: it documents **all 17 deployed paths** — `/api/v1/agents`, `/api/v1/agents/{agenid}/revocation`, `/api/v1/directory`, `/api/resolve/{agenid}`, `/a/{agenid}`, `/api/v1/verify`, `/api/v1/openapi.json`, `/badge/{agenid}/shield.svg`, `/badge.js`, `/blog/feed.xml`, `/v1/keys/{key_ulid}`, `/v1/keys`, `/api/verify-dns`, `/api/domain/status`, `/api/retell/declare`, `/api/retell/bind`, `/api/retell/agents` — eight component schemas (`RegisterRequest`, `RegisterResponse`, `VerifyRequest`, `VerifyResponse`, `ResolutionEnvelope`, `DomainControlResult`, `DomainStatus`, `Error`), and one server (`https://www.agenid.com`). Its request and response shapes match the implementation and this document. Coverage is enforced in both directions by `packages/web/test/dns-surface.test.ts` — a route handler with no documented path fails the suite, and so does a documented path with no handler — and `scripts/check-docs.mjs` re-asserts it against the live document. diff --git a/docs/architecture.md b/docs/architecture.md index 2e8a151..06dfe95 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -177,11 +177,14 @@ Supabase Postgres, reached through `RegistryStore`. `MemoryStore` is the dev/tes | `keys` | `key_id` | `document` jsonb; `role` and `controller` are generated columns extracted from the jsonb, indexed on `role` | | `assertions` | `assertion_id` | `document` jsonb; `manifest_digest` generated from jsonb; `verified_at` a **plain** timestamptz; indexed on `subject` | | `events` | `event_id` | Append-only ledger: `type`, `occurred_at`, `detail_ref` jsonb — **pointers and hashes only, never raw evidence** | +| `revocations` | `revocation_id` | Operator-signed `Revocation` objects (`document` jsonb), `revokes`, `revokes_type`, `revoked_at` (signed), `recorded_at` (registry clock); at most one per agent (partial unique index). Public read, no write policies (migration `0006`) | Relationships are by identifier, not by foreign key. Verification never relies on referential integrity; it re-derives everything from signatures. **Consistency.** `createAgent` is an atomic insert that reports `false` on a unique violation rather than overwriting — a replayed registration is a `409`. There are no multi-statement transactions: a registration writes the agent, the key and two events as separate statements, so a mid-sequence failure can leave an agent without its ledger events. That is an accepted trade today; it degrades the audit trail, not the verifiability of the identity, because the envelope is reconstructed from the agent and key rows alone. +**Revocation is the exception, deliberately.** `revoke_agent` (migration `0006`) stores the signed revocation, sets `status = 'REVOKED'` and appends `agent.revoked` inside one Postgres transaction, locking the agent row first so two concurrent revocations serialize and the second reports `already_revoked`. A revocation recorded without its status change — or the reverse — would make the public record disagree with the signed object it depends on, which is worse than a missing audit event. `SECURITY INVOKER`, with `EXECUTE` revoked from `PUBLIC`. + Two constraints learned the hard way, both from defects that reached the repository: - **A `timestamptz` generated column cast from jsonb is not immutable** and Postgres rejects the migration (`42P17`). jsonb extraction is immutable; a text→timestamptz cast depends on the `DateStyle`/`TimeZone` GUCs. `keys.role` and `assertions.manifest_digest` are legal generated columns precisely because they stay in the text domain. diff --git a/docs/revocation-propagation.md b/docs/revocation-propagation.md new file mode 100644 index 0000000..a992063 --- /dev/null +++ b/docs/revocation-propagation.md @@ -0,0 +1,50 @@ +# Revocation propagation — proposal + +**Status: PROPOSAL, not normative.** This document proposes text for a future specification erratum or minor version. Nothing here is part of protocol v1.1.1, and the reference deployment does not enforce any obligation on relying parties. It exists because revocation is only as fast as the slowest party still holding an old answer, and nothing in v1.1.1 says how slow that may be. + +## What exists today + +Verified against production on 2026-10-19. + +- An operator can permanently revoke their own agent: `POST /api/v1/agents/{agenid}/revocation` with a `Revocation` signed by the agent's operator key ([api.md](api.md#post-apiv1agentsagenidrevocation--revoke-an-agent)). The status becomes `REVOKED`, the signed object is stored, and an `agent.revoked` ledger event is appended, atomically. +- The resolution envelope (`/api/resolve/{agenid}`, `/a/{agenid}` as JSON) carries the signed revocation in `revocation`, so a verifier can check that the operator made the change. +- The registry answers resolution with `cache-control: no-store`. The SVG badge and `badge.js` send `cache-control: public, max-age=0`. + +What does **not** exist: any statement of how long a relying party may rely on an `ACTIVE` answer, what it must do when the registry is unreachable, or how revocation reaches a party that never re-resolves. The registry cannot fix that by itself — it controls only its own response headers — which is why it belongs in the specification. + +## The problem in one example + +A voice platform resolves `agenid:A` at 09:00, sees `ACTIVE`, and caches the result for a day. At 09:05 the operator discovers the operator key was stolen and revokes the agent. Until the next day the platform keeps accepting calls from an identity whose own operator has disowned it. The revocation worked; the propagation did not. + +## Proposed normative text + +The keywords are used as in RFC 2119. + +**P-1 · Maximum reliance window.** A relying party MUST NOT rely on an `ACTIVE` resolution result for longer than **300 seconds** after it was obtained. After that, it MUST re-resolve before relying on the identity again. A registry MAY advertise a shorter window; it MUST NOT be read as permission for a longer one. + +**P-2 · Revocation is final on sight.** A relying party that observes `REVOKED` for an identifier, from any registry response it has verified, MUST treat the identity as revoked from that moment on, MAY cache that result indefinitely, and MUST NOT return to `ACTIVE` on the strength of a later response. Revocation is permanent (§12), so a later `ACTIVE` is evidence of a fault or an attack, not a reprieve. + +**P-3 · Verify the revocation, not the status.** A relying party SHOULD verify the envelope's `revocation` against `operator_key.document` and require `revokes` to equal the agent identifier. A `REVOKED` status without a verifiable revocation object SHOULD still be honored as revoked — refusing service is the safe failure — but SHOULD be reported as a registry fault. + +**P-4 · Registry unreachable.** When re-resolution fails because the registry is unreachable, the relying party MUST treat the identity's status as **unknown**, not as `ACTIVE`. Whether it then proceeds is its own risk decision, and it MUST NOT represent the identity to its users as verified or active while the status is unknown. For actions with financial or safety consequences it SHOULD fail closed. + +**P-5 · Grace is explicit.** A relying party that chooses to continue during an outage (P-4) SHOULD bound that grace — this proposal suggests no more than **one hour** past the last successful resolution — and SHOULD log that it did so. + +**P-6 · Registry obligations.** A registry MUST serve resolution responses with caching directives no more permissive than P-1, and MUST serve `REVOKED` with the revocation object in the same response, never in a separate fetch the relying party might skip. + +## Why these numbers + +- **300 seconds** matches the window the reference registry already uses for operator-signed consents and revocations (`revoked_at` must be within five minutes of the registry clock), so the protocol uses one notion of "recent". It is short enough that a stolen key buys an attacker minutes, not days, and long enough that a busy relying party is not resolving on every request. +- **Final on sight** costs nothing and removes a whole class of replay: an attacker who can serve one stale `ACTIVE` response cannot undo a revocation the relying party has already seen. +- **Unknown is not active** is the same rule every surface on agenid.com already follows: a registry outage renders neutral "unavailable", never green. + +## Known gaps this proposal does not close + +1. **Badges in third-party caches.** A README badge is fetched through image proxies (GitHub's among them) that may cache regardless of `max-age=0`. A revoked agent's badge can show the old state there for longer than P-1 allows. Badges are a human-facing hint; P-1 applies to relying parties making decisions, which must resolve the envelope. +2. **Parties that never re-resolve.** No specification can reach a relying party that checked once and never again. P-1 makes that party non-conforming; it does not make it safe. +3. **Push notification.** A webhook or feed of revocations would shorten propagation below the polling interval. It is deliberately out of scope here: it needs subscriber authentication and delivery guarantees, and polling under P-1 is already bounded. +4. **Grant and key revocation.** The v1.2-draft `Revocation` also targets grants, keys and assertions. The same rules should apply to them, but this registry records agent revocations only, so they are not exercised yet. + +## Path to adoption + +This is queued as a specification change (project queue item #40b). It needs an erratum or a minor version of `AgenID-protocol/spec`, conformance vectors for P-2 and P-4, and — before any financial capability is designed — adoption, since payments-gate G-2 makes revocation propagation a prerequisite. diff --git a/docs/threat-model.md b/docs/threat-model.md index efcfb6e..82fbed3 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -162,7 +162,7 @@ Ranked. Nothing material is omitted. **Not accepted, tracked, not yet fixed:** ceremony hardening (4), OpenAPI coverage (8), vulnerability reporting (9). Rate limiting (1) is fixed. -**Newly documented, not yet fixed:** the system has no authorization layer, no principal (human/organization) key role, and no revocation path — see [SECURITY-GAP-ANALYSIS.md](SECURITY-GAP-ANALYSIS.md), gaps B1, D1 and A1. These are absences in the object model rather than threats against the current one, which is why they are recorded there rather than as T-numbers here. +**Newly documented, not yet fixed:** the system has no authorization layer and no principal (human/organization) key role — see [SECURITY-GAP-ANALYSIS.md](SECURITY-GAP-ANALYSIS.md), gaps B1 and D1. **Partly closed 2026-10-19:** gap A1 — an operator can now permanently revoke their own agent with its operator key; propagation semantics and key revocation remain open. These are absences in the object model rather than threats against the current one, which is why they are recorded there rather than as T-numbers here. **Structurally unresolved until v1.2:** root-compromise blast radius (3). A delegation object is the fix; it is designed and undecided. diff --git a/docs/trust-model.md b/docs/trust-model.md index 7ed1903..f41881e 100644 --- a/docs/trust-model.md +++ b/docs/trust-model.md @@ -181,7 +181,7 @@ Separately: **"not verified" and "merely compatible" always render neutral — n 3. Two-path key discovery is deployed on the registry side, but a verifier only gets its benefit where the operator publishes their own key document. 4. The trust root's ceiling is domain and org control, not key storage. 5. Operator attestations are unverifiable by anyone, including AgenID. The protocol can only guarantee they were stated explicitly under a real key. -6. There is no revocation of an *agent* in production — the status enum exists but no transition writes it. -7. Registration is unauthenticated and unrated. +6. Agent revocation is operator-only and permanent, and how quickly it reaches a relying party is not yet specified — a party holding a cached `ACTIVE` answer keeps it until it re-resolves ([revocation-propagation.md](revocation-propagation.md) proposes the rules). No other status transition is written, and keys cannot be rotated or revoked. +7. Registration is unauthenticated; it is rate limited per address range, not per actor. Adversaries, attack vectors and residual risk: [threat-model.md](threat-model.md). diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 46cebab..c547bf9 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -21,6 +21,7 @@ import { } from "@agenid/core"; import { MemoryStore, type RegistryStore, type AgentRecord } from "./store.js"; import { buildEnvelope } from "./envelope.js"; +import { revokeAgentRequest, revocationResponseBody } from "./revoke.js"; import { registrationTime } from "./registration-time.js"; import { resolveKeyFromRawPath, @@ -233,6 +234,14 @@ export function buildApp(opts: AppOptions = {}): FastifyInstance { return reply.code(201).send({ assertion_id: assertion.assertion_id, level: assertion.level, links: { self: `/v1/assertions/${assertion.assertion_id.slice("assertion:".length)}` } }); }); + // Operator-signed, not authority-gated: only the agent's own operator key can retire it, + // and the signature is the authorization. Rules live in revoke.ts, shared with packages/web. + app.post<{ Params: { agent_id: string } }>("/v1/agents/:agent_id/revocation", async (req, reply) => { + const r = await revokeAgentRequest(req.body, store, { now: new Date(now()), agentIdFromPath: req.params.agent_id }); + if (!r.ok) return err(reply, r.status, r.error, r.message, r.issues ? { issues: r.issues } : undefined); + return reply.code(200).send(revocationResponseBody(r)); + }); + // ---------------------------------------------------------------- assertions app.get<{ Params: { assertion_ulid: string } }>("/v1/assertions/:assertion_ulid", async (req, reply) => { const u = req.params.assertion_ulid; diff --git a/packages/api/src/envelope.ts b/packages/api/src/envelope.ts index 7598062..1e85d56 100644 --- a/packages/api/src/envelope.ts +++ b/packages/api/src/envelope.ts @@ -11,6 +11,7 @@ import { keyResolverPath, wellKnownKeysUrl, type KeyDocument, + type Revocation, type VerificationAssertion, type VerificationLevel, } from "@agenid/core"; @@ -50,6 +51,13 @@ export interface ResolutionEnvelope { total_assertions: number; }; assertions: AssertionView[]; + /** + * The operator-signed revocation, when `status` is `REVOKED`; otherwise null. Carried in + * full so a verifier can check that the revocation was signed by this agent's own + * operator key (role=operator, controller=agent_id) rather than taking the status on + * the registry's word. Always present so the envelope shape does not depend on status. + */ + revocation: Revocation | null; /** How a third party re-verifies this envelope without trusting the registry. */ verify_instructions: string; } @@ -73,6 +81,8 @@ export async function buildEnvelope(store: RegistryStore, rec: AgentRecord, now: } } + const revocation = rec.status === "REVOKED" ? await store.getAgentRevocation(rec.agent_id) : null; + return { agenid_envelope_version: "1.0", agent_id: rec.agent_id, @@ -92,11 +102,13 @@ export async function buildEnvelope(store: RegistryStore, rec: AgentRecord, now: }, verification: { level, valid_assertions: valid, total_assertions: stored.length }, assertions: views, + revocation, verify_instructions: "Recompute sha256(RFC8785(manifest)) and compare to manifest_digest; fetch the operator key from BOTH discovery paths " + "(operator_key.discovery.registry_path on this registry's origin, and operator_key.discovery.well_known_url on the operator's domain) " + "and require they agree, and that both agree with operator_key.document; " + "verify Ed25519 over RFC8785(proof minus signature); for each assertion, fetch the authority key, check role=authority, verify Ed25519 over " + - "RFC8785(assertion minus signature), check the validity window and that assertion.manifest_digest matches. Spec: https://github.com/AgenID-protocol/spec", + "RFC8785(assertion minus signature), check the validity window and that assertion.manifest_digest matches; if status is REVOKED, verify revocation (Ed25519 over " + + "RFC8785(revocation minus signature)) against operator_key.document and require revocation.revokes = agent_id. Spec: https://github.com/AgenID-protocol/spec", }; } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 3668e33..992507e 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,5 +1,5 @@ export { buildApp, type AppOptions } from "./app.js"; -export { MemoryStore, type RegistryStore, type AgentRecord, type AgentStatus, type LedgerEvent } from "./store.js"; +export { MemoryStore, type RegistryStore, type AgentRecord, type AgentStatus, type LedgerEvent, type RevokeAgentInput } from "./store.js"; export { buildEnvelope, type ResolutionEnvelope, type AssertionView } from "./envelope.js"; export { SupabaseStore, supabaseStoreFromEnv } from "./supabase-store.js"; export { registrationTime, MAX_FORWARD_SKEW_MS, type RegistrationTime } from "./registration-time.js"; @@ -18,3 +18,4 @@ export { type KeyReader, } from "./serve-key.js"; export { originForm, rawKeyPathSegment, rawKeyQueryValues, rawKeyTargetFailure, type RawTargetResult } from "./raw-key-target.js"; +export { revokeAgentRequest, revocationDigestHex, revocationResponseBody, REVOCATION_DISCLOSURES, REVOCATION_WINDOW_MS, type RevokeResult } from "./revoke.js"; diff --git a/packages/api/src/revoke.ts b/packages/api/src/revoke.ts new file mode 100644 index 0000000..123b00c --- /dev/null +++ b/packages/api/src/revoke.ts @@ -0,0 +1,165 @@ +/** + * The single agent-revocation implementation, shared by the Fastify registry + * (`POST /v1/agents/:agent_id/revocation`) and the deployed web routes + * (`packages/web/lib/revoke.ts` is a thin wrapper that supplies the store). + * + * WHAT A REVOCATION IS. The operator's signed statement that an agent identity is + * retired, for good. Spec v1.1.1 §12: `REVOKED` identities are permanent and publicly + * resolvable — the identifier is never reissued, the record never disappears, and every + * badge and card renders the revocation instead of a level. + * + * WHO MAY REVOKE. Only the key that controls the agent: the operator key its + * ManifestProof names, with role=operator and controller=agent_id. A registry that could + * revoke an agent on its own say-so would be the same trust failure as one that could + * verify an agent on its own say-so, pointed the other way — so the request is a signed + * object and the registry only checks and records it. AgenID holds no key that can do + * this, and this file contains no path that revokes without a valid operator signature. + * + * THE OBJECT. The v1.2-draft `Revocation` from `@agenid/core` (schema + * https://agenid.com/schemas/v1.2/revocation.json), with `revokes_type: "agent"`. The + * v1.2 layer is draft and not normative; this path uses its revocation object because + * v1.1.1 defines the REVOKED status but no signed object for reaching it, and inventing a + * second, registry-local shape for the same statement would give verifiers two things to + * check. The draft label is carried in every response. + * + * WHAT IT PROVES. That the holder of the operator key asked for this. It does not prove + * why, and `reason` is the operator's own claim. It does not prove the key was not + * stolen — which is exactly the case revocation exists for: a compromised operator key + * can be used by its rightful owner to kill the identity before the thief uses it. + * + * REPLAY. `revoked_at` must be within REVOCATION_WINDOW_MS of the registry clock, so a + * revocation cannot be pre-signed and held, or back-dated. Replaying a captured one is + * harmless: the agent is already REVOKED, and the store refuses to record a second. + */ +import { + Revocation, + verifyRevocation, + canonicalize, + sha256, + hex, + generateUlid, + type Revocation as RevocationType, +} from "@agenid/core"; +import type { RegistryStore } from "./store.js"; + +export const REVOCATION_WINDOW_MS = 5 * 60 * 1000; + +export type RevokeResult = + | { ok: true; agent_id: string; revocation: RevocationType; recorded_at: string } + | { ok: false; status: number; error: string; message: string; issues?: unknown[] }; + +const fail = (status: number, error: string, message: string, issues?: unknown[]): RevokeResult => ({ + ok: false, + status, + error, + message, + ...(issues ? { issues } : {}), +}); + +/** sha256 over the RFC 8785 bytes of the full signed object — what the ledger points at. */ +export function revocationDigestHex(rev: RevocationType): string { + return hex(sha256(new TextEncoder().encode(canonicalize(rev)))); +} + +export async function revokeAgentRequest( + body: unknown, + store: RegistryStore, + opts: { now?: Date; agentIdFromPath?: string } = {}, +): Promise { + const parsed = Revocation.safeParse(body); + if (!parsed.success) { + // Issue paths and messages only — never the submitted values. + const issues = parsed.error.issues.map((i) => ({ path: i.path, message: i.message })); + return fail(400, "invalid_revocation", "revocation failed schema validation", issues); + } + const rev = parsed.data; + + if (rev.revokes_type !== "agent") { + return fail(400, "unsupported_revocation_target", "this registry records agent revocations only (revokes_type must be \"agent\")"); + } + if (opts.agentIdFromPath !== undefined && opts.agentIdFromPath !== rev.revokes) { + return fail(400, "agent_id_mismatch", "revocation.revokes must equal the agent identifier in the request path"); + } + + const now = opts.now ?? new Date(); + const skew = Math.abs(now.getTime() - Date.parse(rev.revoked_at)); + if (skew > REVOCATION_WINDOW_MS) { + return fail(400, "revocation_expired", "revoked_at must be within five minutes of the registry clock; sign a fresh revocation"); + } + + let agent; + let key; + try { + agent = await store.getAgent(rev.revokes); + key = agent ? await store.getKey(rev.key_id) : null; + } catch { + return fail(503, "registry_unavailable", "the registry could not be read; nothing was changed"); + } + if (!agent) return fail(404, "agent_not_found", "no agent is registered under this identifier"); + if (agent.status === "REVOKED") { + return fail(409, "already_revoked", "this agent is already revoked; revocation is permanent"); + } + + // Standing, before the signature: the key must be THIS agent's operator key. + if (agent.proof.key_id !== rev.key_id) { + return fail(403, "key_not_operator", "key_id is not the operator key that signed this agent's manifest proof"); + } + if (!key || key.role !== "operator" || key.controller !== rev.revokes) { + return fail(403, "key_not_operator", "key_id does not resolve to this agent's operator key"); + } + + const check = verifyRevocation(rev, key, { now: now.toISOString() }); + if (!check.ok) { + if (check.code === "signature_invalid") return fail(403, "signature_invalid", "the signature does not verify against the operator key"); + if (check.code === "key_not_active_at_signing_time") return fail(403, "key_not_active", "the operator key was not active at revoked_at"); + return fail(403, check.code, "the revocation does not verify against the operator key"); + } + + const recorded_at = now.toISOString(); + let outcome; + try { + outcome = await store.revokeAgent({ + agent_id: rev.revokes, + revocation: rev, + recorded_at, + event_id: `evt_${generateUlid()}`, + detail_ref: { + revocation_id: rev.revocation_id, + revocation_digest: revocationDigestHex(rev), + reason: rev.reason, + }, + }); + } catch (e) { + console.error("revocation failed", { error: e instanceof Error ? e.message : e }); + return fail(503, "registry_unavailable", "the registry could not be written to; nothing was changed"); + } + if (outcome === "not_found") return fail(404, "agent_not_found", "no agent is registered under this identifier"); + if (outcome === "already_revoked") return fail(409, "already_revoked", "this agent is already revoked; revocation is permanent"); + + return { ok: true, agent_id: rev.revokes, revocation: rev, recorded_at }; +} + +/** Every revocation response carries these, so the surfaces cannot drift apart. */ +export const REVOCATION_DISCLOSURES = [ + "Revocation is permanent. The identifier stays resolvable, renders REVOKED everywhere, and is never reissued.", + "The revocation was signed by this agent's operator key. Its reason is the operator's own statement; the registry checks who signed it, not why.", + "The signed object is the v1.2-draft Revocation (not normative). The REVOKED status itself is defined by protocol v1.1.1 §12.", +] as const; + +/** + * The success body, built in one place so the Fastify registry and the deployed web + * route return byte-identical responses for the same revocation. + */ +export function revocationResponseBody(r: Extract) { + return { + agent_id: r.agent_id, + status: "REVOKED" as const, + revocation_id: r.revocation.revocation_id, + reason: r.revocation.reason, + revoked_at: r.revocation.revoked_at, + recorded_at: r.recorded_at, + revocation_digest: { alg: "sha-256" as const, value: revocationDigestHex(r.revocation) }, + links: { card: `/a/${r.agent_id}`, envelope: `/api/resolve/${r.agent_id}` }, + disclosures: REVOCATION_DISCLOSURES, + }; +} diff --git a/packages/api/src/store.ts b/packages/api/src/store.ts index ef1ccd9..5dfc28a 100644 --- a/packages/api/src/store.ts +++ b/packages/api/src/store.ts @@ -6,9 +6,14 @@ * A durable implementation (Postgres/Supabase) plugs in behind the same * interface without touching routes. */ -import type { KeyDocument, Manifest, ManifestProof, VerificationAssertion } from "@agenid/core"; +import type { KeyDocument, Manifest, ManifestProof, Revocation, VerificationAssertion } from "@agenid/core"; -/** Agent-level status (spec §12). CHANGED/STALE/SUSPENDED transitions are Phase 3; the field is stored now so the envelope shape is stable. */ +/** + * Agent-level status (spec §12). `REVOKED` is written by an operator-signed revocation + * (see `revokeAgent` below) and is permanent: a revoked identity stays publicly resolvable + * and is never reissued. CHANGED/STALE/SUSPENDED transitions are not written yet; the + * values exist so the envelope shape is stable. + */ export type AgentStatus = "ACTIVE" | "CHANGED" | "STALE" | "SUSPENDED" | "REVOKED"; export interface AgentRecord { @@ -51,6 +56,30 @@ export interface RegistryStore { appendEvent(e: LedgerEvent): Promise; listEvents(agentId: string): Promise; + + /** + * Revoke an agent, atomically: store the operator-signed `Revocation`, set the agent's + * status to `REVOKED`, and append an `agent.revoked` ledger event — all three or none. + * A revocation that was stored without the status change (or the reverse) would leave + * the public record disagreeing with the signed object it depends on. + * + * The caller has already verified the revocation; the store only records it. + * Resolves `already_revoked` without writing anything if the agent is already REVOKED, + * so a retried request cannot append a second event or replace the first revocation. + */ + revokeAgent(input: RevokeAgentInput): Promise<"revoked" | "already_revoked" | "not_found">; + /** The agent-targeted revocation recorded for this agent, if any. */ + getAgentRevocation(agentId: string): Promise; +} + +export interface RevokeAgentInput { + agent_id: string; + revocation: Revocation; + /** The registry's own clock at the time of recording — `updated_at` and the event time. */ + recorded_at: string; + event_id: string; + /** Hashes/references only (spec §13). */ + detail_ref: Record; } export class MemoryStore implements RegistryStore { @@ -58,6 +87,7 @@ export class MemoryStore implements RegistryStore { private keys = new Map(); private assertions = new Map(); private events: LedgerEvent[] = []; + private revocations = new Map(); async getAgent(agentId: string) { return this.agents.get(agentId) ?? null; @@ -91,4 +121,23 @@ export class MemoryStore implements RegistryStore { async listEvents(agentId: string) { return this.events.filter((e) => e.agent_id === agentId); } + async revokeAgent(input: RevokeAgentInput) { + const rec = this.agents.get(input.agent_id); + if (!rec) return "not_found" as const; + if (rec.status === "REVOKED") return "already_revoked" as const; + // Single-threaded, so these three writes cannot interleave with another request. + this.revocations.set(input.agent_id, input.revocation); + this.agents.set(input.agent_id, { ...rec, status: "REVOKED", updated_at: input.recorded_at }); + this.events.push({ + event_id: input.event_id, + agent_id: input.agent_id, + type: "agent.revoked", + occurred_at: input.recorded_at, + detail_ref: input.detail_ref, + }); + return "revoked" as const; + } + async getAgentRevocation(agentId: string) { + return this.revocations.get(agentId) ?? null; + } } diff --git a/packages/api/src/supabase-store.ts b/packages/api/src/supabase-store.ts index a42141d..0639dfc 100644 --- a/packages/api/src/supabase-store.ts +++ b/packages/api/src/supabase-store.ts @@ -7,8 +7,8 @@ * constructed with an anon/publishable key, and must never run in a browser context. */ import { createClient, type SupabaseClient } from "@supabase/supabase-js"; -import type { KeyDocument, Manifest, ManifestProof, VerificationAssertion } from "@agenid/core"; -import type { AgentRecord, LedgerEvent, RegistryStore } from "./store.js"; +import type { KeyDocument, Manifest, ManifestProof, Revocation, VerificationAssertion } from "@agenid/core"; +import type { AgentRecord, LedgerEvent, RegistryStore, RevokeAgentInput } from "./store.js"; interface AgentRow { agent_id: string; @@ -190,6 +190,39 @@ export class SupabaseStore implements RegistryStore { if (error) throw new Error(`SupabaseStore.listEvents: ${error.message}`); return ((data as EventRow[] | null) ?? []).map((e) => ({ ...e, occurred_at: toUtcZ(e.occurred_at) })); } + + /** + * One call to `revoke_agent` (migration 0006), which stores the revocation, flips the + * status and appends the ledger event inside a single Postgres transaction. Done as a + * function rather than three PostgREST writes because PostgREST has no multi-statement + * transaction, and registration's four un-transacted writes are already a recorded + * trade-off this path should not repeat. + */ + async revokeAgent(input: RevokeAgentInput): Promise<"revoked" | "already_revoked" | "not_found"> { + const { data, error } = await this.client.rpc("revoke_agent", { + p_agent_id: input.agent_id, + p_revocation_id: input.revocation.revocation_id, + p_document: input.revocation, + p_revoked_at: input.revocation.revoked_at, + p_recorded_at: input.recorded_at, + p_event_id: input.event_id, + p_detail_ref: input.detail_ref, + }); + if (error) throw new Error(`SupabaseStore.revokeAgent: ${error.message}`); + if (data === "revoked" || data === "already_revoked" || data === "not_found") return data; + throw new Error(`SupabaseStore.revokeAgent: unexpected result ${JSON.stringify(data).slice(0, 60)}`); + } + + async getAgentRevocation(agentId: string): Promise { + const { data, error } = await this.client + .from("revocations") + .select("document") + .eq("revokes_type", "agent") + .eq("revokes", agentId) + .maybeSingle(); + if (error) throw new Error(`SupabaseStore.getAgentRevocation: ${error.message}`); + return data ? (data as { document: Revocation }).document : null; + } } /** Reads SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY. Returns null (not a thrown error) when either is unset, diff --git a/packages/api/supabase/migrations/0006_revocations.sql b/packages/api/supabase/migrations/0006_revocations.sql new file mode 100644 index 0000000..56af790 --- /dev/null +++ b/packages/api/supabase/migrations/0006_revocations.sql @@ -0,0 +1,85 @@ +-- AgenID — operator-signed agent revocation. +-- +-- Spec v1.1.1 §12: REVOKED identities are permanent and publicly resolvable. This adds +-- the storage for the signed object that proves a revocation was the operator's act, +-- and one function that records it atomically. +-- +-- WHY A TABLE AND NOT A COLUMN. The revocation is a complete signed object +-- (v1.2-draft `Revocation`, schema https://agenid.com/schemas/v1.2/revocation.json), +-- and a verifier must be able to fetch and re-check it without trusting this registry. +-- The event ledger is not the place for it: §13 keeps the ledger to hashes and +-- references, so the ledger event points at this row by revocation_id and digest. +-- +-- WHY A FUNCTION. PostgREST cannot run several statements in one transaction. A +-- revocation that was stored while the status write failed (or the reverse) would leave +-- the public record disagreeing with the signed object it depends on. revoke_agent does +-- all three writes in one transaction, and is a no-op on an agent already REVOKED, so a +-- retried request can neither append a second event nor replace the first revocation. +-- +-- SECURITY. SECURITY INVOKER (the default): it runs with the caller's rights, and only +-- the service role can write these tables. EXECUTE is still revoked from PUBLIC so the +-- function is not even reachable at /rest/v1/rpc/ with the publishable key. +-- +-- Reversible: `drop function public.revoke_agent(...)` and `drop table public.revocations`. +-- Revoked agents' status would remain REVOKED, which is the protocol's rule anyway. + +create table if not exists public.revocations ( + revocation_id text primary key check (revocation_id ~ '^revocation:[0-7][0-9A-HJKMNP-TV-Z]{25}$'), + revokes text not null, + revokes_type text not null check (revokes_type in ('agent', 'grant', 'key', 'assertion')), + document jsonb not null, + -- The signed instant, from the document. Plain column, not generated: text -> + -- timestamptz is not IMMUTABLE (the 42P17 lesson from 0001). + revoked_at timestamptz not null, + recorded_at timestamptz not null +); + +-- One revocation per agent. Grants, keys and assertions are not written by this +-- registry yet; when they are, they get their own uniqueness rule. +create unique index if not exists revocations_one_per_agent + on public.revocations (revokes) where revokes_type = 'agent'; + +alter table public.revocations enable row level security; + +-- Public read, like every registry table: a revocation exists to be checked by anyone. +-- No write policies; writes go through the service role only. +drop policy if exists "revocations_public_read" on public.revocations; +create policy "revocations_public_read" on public.revocations for select using (true); + +create or replace function public.revoke_agent( + p_agent_id text, + p_revocation_id text, + p_document jsonb, + p_revoked_at timestamptz, + p_recorded_at timestamptz, + p_event_id text, + p_detail_ref jsonb +) returns text +language plpgsql +set search_path = public +as $$ +declare + v_status text; +begin + select status into v_status from public.agents where agent_id = p_agent_id for update; + if not found then + return 'not_found'; + end if; + if v_status = 'REVOKED' then + return 'already_revoked'; + end if; + + insert into public.revocations (revocation_id, revokes, revokes_type, document, revoked_at, recorded_at) + values (p_revocation_id, p_agent_id, 'agent', p_document, p_revoked_at, p_recorded_at); + + update public.agents set status = 'REVOKED', updated_at = p_recorded_at where agent_id = p_agent_id; + + insert into public.events (event_id, agent_id, type, occurred_at, detail_ref) + values (p_event_id, p_agent_id, 'agent.revoked', p_recorded_at, p_detail_ref); + + return 'revoked'; +end; +$$; + +revoke all on function public.revoke_agent(text, text, jsonb, timestamptz, timestamptz, text, jsonb) from public, anon, authenticated; +grant execute on function public.revoke_agent(text, text, jsonb, timestamptz, timestamptz, text, jsonb) to service_role; diff --git a/packages/api/tests/revocation.test.ts b/packages/api/tests/revocation.test.ts new file mode 100644 index 0000000..840fd95 --- /dev/null +++ b/packages/api/tests/revocation.test.ts @@ -0,0 +1,111 @@ +/** + * Fastify registry: POST /v1/agents/:agent_id/revocation, signed with core's own + * signRevocation. The rules are the shared revoke.ts, also exercised end to end through + * the deployed web route in packages/web/test/revocation.test.ts; this file proves the + * standalone registry exposes the same behavior and the same response body. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + generateAgentId, + generateKeyId, + generateKeyPair, + generateRevocationId, + makeKeyDocument, + signManifestProof, + signRevocation, + verifyRevocation, + REVOCATION_SCHEMA_ID, + type SigningKey, +} from "@agenid/core"; +import { buildApp } from "../src/app.js"; +import { MemoryStore } from "../src/store.js"; + +const NOW = "2026-10-19T12:00:00Z"; +const store = new MemoryStore(); +const app = buildApp({ store, now: () => NOW }); + +function fixture() { + const agentId = generateAgentId(); + const op = generateKeyPair(); + const doc = makeKeyDocument({ keyId: generateKeyId(), publicKey: op.publicKey, role: "operator", controller: agentId, createdAt: "2026-10-01T00:00:00Z" }); + const key: SigningKey = { privateKey: op.privateKey, document: doc }; + const manifest = { + manifest_version: "1.0", + agent_id: agentId, + identity: { name: "Desk" }, + ownership: { operator: "Acme LLC", operator_domain: "acme.example", contact: "ops@acme.example" }, + purpose: { summary: "Answers the front desk line", channels: ["voice"] }, + disclosure: { is_ai: true, discloses_to_user: false, human_escalation: false }, + }; + const proof = signManifestProof(manifest, key, { createdAt: "2026-10-19T11:59:00Z", expiresAt: "2027-01-01T00:00:00Z" }); + return { agentId, key, doc, manifest, proof }; +} + +const revoke = (agentId: string, key: SigningKey, revokedAt = NOW) => + signRevocation( + { + $schema: REVOCATION_SCHEMA_ID, + revocation_id: generateRevocationId(), + revokes: agentId, + revokes_type: "agent", + reason: "agent_retired", + revoked_at: revokedAt, + key_id: key.document.key_id, + }, + key, + ); + +beforeAll(async () => { + await app.ready(); +}); +afterAll(async () => { + await app.close(); +}); + +describe("POST /v1/agents/:agent_id/revocation", () => { + it("revokes, and the envelope carries a revocation that verifies against its own operator key", async () => { + const f = fixture(); + expect((await app.inject({ method: "POST", url: "/v1/agents", payload: { manifest: f.manifest, proof: f.proof, key_document: f.doc } })).statusCode).toBe(201); + + const rev = revoke(f.agentId, f.key); + const r = await app.inject({ method: "POST", url: `/v1/agents/${f.agentId}/revocation`, payload: rev }); + expect(r.statusCode).toBe(200); + expect(r.json().status).toBe("REVOKED"); + + const env = (await app.inject({ method: "GET", url: `/v1/agents/${f.agentId}` })).json(); + expect(env.status).toBe("REVOKED"); + expect(verifyRevocation(env.revocation, env.operator_key.document, { now: NOW }).ok).toBe(true); + + const events = (await app.inject({ method: "GET", url: `/v1/agents/${f.agentId}/events` })).json().events; + expect(events.map((e: { type: string }) => e.type)).toContain("agent.revoked"); + }); + + it("is permanent: a second revocation is 409 already_revoked", async () => { + const f = fixture(); + await app.inject({ method: "POST", url: "/v1/agents", payload: { manifest: f.manifest, proof: f.proof, key_document: f.doc } }); + expect((await app.inject({ method: "POST", url: `/v1/agents/${f.agentId}/revocation`, payload: revoke(f.agentId, f.key) })).statusCode).toBe(200); + const again = await app.inject({ method: "POST", url: `/v1/agents/${f.agentId}/revocation`, payload: revoke(f.agentId, f.key) }); + expect(again.statusCode).toBe(409); + expect(again.json().error).toBe("already_revoked"); + }); + + it("another agent's operator key has no standing", async () => { + const victim = fixture(); + const attacker = fixture(); + for (const f of [victim, attacker]) { + await app.inject({ method: "POST", url: "/v1/agents", payload: { manifest: f.manifest, proof: f.proof, key_document: f.doc } }); + } + const r = await app.inject({ method: "POST", url: `/v1/agents/${victim.agentId}/revocation`, payload: revoke(victim.agentId, attacker.key) }); + expect(r.statusCode).toBe(403); + expect(r.json().error).toBe("key_not_operator"); + expect((await store.getAgent(victim.agentId))?.status).toBe("ACTIVE"); + }); + + it("refuses a stale revocation", async () => { + const f = fixture(); + await app.inject({ method: "POST", url: "/v1/agents", payload: { manifest: f.manifest, proof: f.proof, key_document: f.doc } }); + const r = await app.inject({ method: "POST", url: `/v1/agents/${f.agentId}/revocation`, payload: revoke(f.agentId, f.key, "2026-10-19T11:00:00Z") }); + expect(r.statusCode).toBe(400); + expect(r.json().error).toBe("revocation_expired"); + }); +}); diff --git a/packages/web/app/a/[agenid]/page.tsx b/packages/web/app/a/[agenid]/page.tsx index 6a1dd41..a343b49 100644 --- a/packages/web/app/a/[agenid]/page.tsx +++ b/packages/web/app/a/[agenid]/page.tsx @@ -148,6 +148,17 @@ function Card({ env, id, keyState }: { env: Envelope; id: string; keyState: KeyS + {env.revocation && ( + <> + {/* Shown only when the registry returned a signed revocation; the status + pill above already reads REVOKED through the trust module. These rows + let a reader see who signed it and check it themselves. */} + + + + + + )} diff --git a/packages/web/app/api/v1/agents/[agenid]/revocation/route.ts b/packages/web/app/api/v1/agents/[agenid]/revocation/route.ts new file mode 100644 index 0000000..9fc06cc --- /dev/null +++ b/packages/web/app/api/v1/agents/[agenid]/revocation/route.ts @@ -0,0 +1,73 @@ +/** + * POST /api/v1/agents/{agenid}/revocation — permanently revoke an agent identity. + * + * The body is an operator-signed `Revocation` (v1.2-draft object, `revokes_type: + * "agent"`). The rules live in `@agenid/api`'s revoke.ts, the single implementation + * shared with the Fastify registry; this route only shapes the request and response. + * + * WHAT IS ACCEPTED: public material only — the signed revocation. The operator signs it + * where the key lives (the browser tab at /revoke, or their own tooling); the registry + * checks it against the operator key it already holds. No private key, and no AgenID + * credential, can revoke an agent. + * + * Cross-origin callable, like registration: operator tooling must be able to reach it. + * The signature is the authorization, so origin is irrelevant to who may revoke. + */ +import { API_URL } from "@/lib/api"; +import { revokeAgent } from "@/lib/revoke"; +import { revocationResponseBody } from "@agenid/api"; +import { POLICIES, checkRateLimit, rateLimitHeaders, tooManyRequests } from "@/lib/rate-limit"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const HEADERS = { "content-type": "application/json", "access-control-allow-origin": "*", "cache-control": "no-store" }; +const json = (body: unknown, status: number, extra: Record = {}) => + new Response(JSON.stringify(body), { status, headers: { ...HEADERS, ...extra } }); + +export async function POST(req: Request, ctx: { params: Promise<{ agenid: string }> }) { + const rl = await checkRateLimit(req, POLICIES.register); + if (!rl.allowed) return tooManyRequests(rl, { "access-control-allow-origin": "*" }); + const rlHeaders = rateLimitHeaders(rl); + + const { agenid } = await ctx.params; + + let body: unknown; + try { + body = await req.json(); + } catch { + return json({ error: "invalid_json", message: "request body must be JSON" }, 400, rlHeaders); + } + + // Same precedence as registration: a configured standalone registry is the writer. + if (API_URL) { + try { + const r = await fetch(`${API_URL}/v1/agents/${encodeURIComponent(agenid)}/revocation`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return json(await r.json(), r.status, rlHeaders); + } catch (e) { + console.error("registry revocation failed", { error: e instanceof Error ? e.message : e }); + return json({ error: "registry_unavailable", message: "upstream registry could not be reached" }, 503, rlHeaders); + } + } + + const result = await revokeAgent(body, { agentIdFromPath: agenid }); + if (!result.ok) { + return json( + { error: result.error, message: result.message, ...(result.issues ? { issues: result.issues } : {}) }, + result.status, + rlHeaders, + ); + } + return json(revocationResponseBody(result), 200, rlHeaders); +} + +export async function OPTIONS() { + return new Response(null, { + status: 204, + headers: { ...HEADERS, "access-control-allow-methods": "POST, OPTIONS", "access-control-allow-headers": "content-type" }, + }); +} diff --git a/packages/web/app/api/v1/openapi.json/route.ts b/packages/web/app/api/v1/openapi.json/route.ts index 350e8ce..736b4f5 100644 --- a/packages/web/app/api/v1/openapi.json/route.ts +++ b/packages/web/app/api/v1/openapi.json/route.ts @@ -184,6 +184,35 @@ function buildSpec(origin: string) { responses: { "200": { description: "The badge script.", content: { "application/javascript": { schema: { type: "string" } } } } }, }, }, + "/api/v1/agents/{agenid}/revocation": { + post: { + operationId: "revokeAgent", + summary: "Permanently revoke an agent identity (operator-signed)", + description: + "Records an operator-signed `Revocation` (v1.2-draft object, `revokes_type: \"agent\"`, schema " + + "https://agenid.com/schemas/v1.2/revocation.json) and sets the agent's status to `REVOKED`, which spec v1.1.1 §12 " + + "makes permanent: the identifier stays resolvable and is never reissued. Only the agent's own operator key — the key " + + "its ManifestProof names, role=operator, controller=agent_id — has standing; AgenID holds no key that can revoke. " + + "`revoked_at` must be within five minutes of the registry clock. The status write, the stored revocation and the " + + "`agent.revoked` ledger event are recorded atomically. The resolution envelope then carries the full signed object in " + + "`revocation` so a verifier can check it offline against `operator_key.document`.", + tags: ["Registry"], + parameters: [{ name: "agenid", in: "path", required: true, schema: { type: "string", pattern: "^agenid:[0-7][0-9A-HJKMNP-TV-Z]{25}$" } }], + requestBody: { + required: true, + content: { "application/json": { schema: { $ref: "https://agenid.com/schemas/v1.2/revocation.json" } } }, + }, + responses: { + "200": { description: "Revoked. Body carries agent_id, status REVOKED, revocation_id, reason, revoked_at, recorded_at, revocation_digest, links and disclosures.", content: { "application/json": { schema: { type: "object", additionalProperties: true } } } }, + "400": { description: "`invalid_revocation`, `unsupported_revocation_target`, `agent_id_mismatch` or `revocation_expired`. Nothing was changed.", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, + "403": { description: "`key_not_operator`, `key_not_active` or `signature_invalid` — the request does not come from this agent's operator key. Nothing was changed.", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, + "404": { description: "`agent_not_found`.", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, + "409": { description: "`already_revoked` — revocation is permanent; the first revocation stands.", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, + "429": { description: "Rate limited.", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, + "503": { description: "`registry_unavailable`. Nothing was changed.", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, + }, + }, + }, "/api/v1/directory": { get: { operationId: "listDirectory", @@ -613,7 +642,7 @@ function buildSpec(origin: string) { properties: { agenid_envelope_version: { type: "string", enum: ["1.0"] }, agent_id: { type: "string", pattern: "^agenid:[0-7][0-9A-HJKMNP-TV-Z]{25}$" }, - status: { type: "string" }, + status: { type: "string", enum: ["ACTIVE", "CHANGED", "STALE", "SUSPENDED", "REVOKED"] }, registered_at: { type: "string", format: "date-time" }, manifest: { type: "object", additionalProperties: true }, manifest_digest: { @@ -642,6 +671,14 @@ function buildSpec(origin: string) { }, }, assertions: { type: "array", items: { type: "object", additionalProperties: true } }, + revocation: { + type: "object", + nullable: true, + additionalProperties: true, + description: + "The operator-signed Revocation (v1.2-draft object) when status is REVOKED; null otherwise. Verify it against " + + "operator_key.document and require revokes = agent_id.", + }, verify_instructions: { type: "string" }, }, }, diff --git a/packages/web/app/badge/page.tsx b/packages/web/app/badge/page.tsx index 26dec8d..7bd4463 100644 --- a/packages/web/app/badge/page.tsx +++ b/packages/web/app/badge/page.tsx @@ -117,8 +117,9 @@ export default function BadgePage() {

- The protocol defines agent status transitions and the badge renders them, but no write path on this deployment - sets them yet: every registered agent is ACTIVE, and there is no revocation flow today. + The protocol defines agent status transitions and the badge renders them. This deployment writes one: an operator + can permanently revoke their own agent at /revoke, and the badge then + reads REVOKED. CHANGED, STALE and SUSPENDED are never set, and there is no key-revocation flow.

diff --git a/packages/web/app/page.tsx b/packages/web/app/page.tsx index 448d4c1..8b23f00 100644 --- a/packages/web/app/page.tsx +++ b/packages/web/app/page.tsx @@ -550,7 +550,7 @@ export default function Home() { {''}

- Re-resolves from the registry every time it renders, so it always shows whatever the registry currently reports for that identifier — it is never a static image of a past result. The badge is the human-facing UI; the signed record underneath is what actually carries trust. The protocol defines agent status transitions (CHANGED, STALE, SUSPENDED, REVOKED) and the badge renders them, but no write path on this deployment sets them yet — every registered agent is ACTIVE, and there is no revocation flow today. + Re-resolves from the registry every time it renders, so it always shows whatever the registry currently reports for that identifier — it is never a static image of a past result. The badge is the human-facing UI; the signed record underneath is what actually carries trust. The protocol defines agent status transitions (CHANGED, STALE, SUSPENDED, REVOKED) and the badge renders them. This deployment writes one: an operator can permanently revoke their own agent at /revoke, signed with its operator key, and the badge then reads REVOKED. The others are never set, and there is no key-revocation flow.

diff --git a/packages/web/app/revoke/page.tsx b/packages/web/app/revoke/page.tsx new file mode 100644 index 0000000..a867524 --- /dev/null +++ b/packages/web/app/revoke/page.tsx @@ -0,0 +1,62 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { Breadcrumbs } from "@/components/ui/Breadcrumbs"; +import { RevokeAgentForm } from "@/components/RevokeAgentForm"; +import { REVOCATION_DISCLOSURES } from "@/lib/revoke"; +import { pageMetadata } from "@/lib/seo"; + +export const metadata: Metadata = pageMetadata({ + title: "Revoke an AI Agent Identity", + description: + "Permanently revoke an AgenID agent identity with its operator key. The revocation is signed in your browser, recorded publicly, and checkable by anyone.", + path: "/revoke", +}); + +export default function RevokePage() { + return ( +
+ +

Revoke an agent identity

+

+ Use this when an agent is retired, replaced, or its operator key may be compromised. You sign a revocation with + the key file you downloaded when you registered; the private key is used in this tab and never uploaded. The + registry checks that the signature comes from this agent’s own operator key and records it. No one else, + including AgenID, can revoke your agent. +

+
    + {REVOCATION_DISCLOSURES.map((d) => ( +
  • + + {d} +
  • + ))} +
+ +
+ +
+

What changes

+
    +
  • The Verification Card and both badges read REVOKED instead of a level.
  • +
  • The resolution envelope carries your signed revocation, so a verifier can check it offline against your operator key.
  • +
  • The agent leaves the public directory and cannot be listed again.
  • +
  • The identifier keeps resolving, so anyone who meets it later learns it was revoked rather than finding nothing.
  • +
+

Lost the key file?

+

+ Then no one can revoke this identity, which is the cost of a registry that cannot act on your behalf. Register a + new identity at /issue and stop + presenting the old one. +

+

From your own tooling

+

+ POST /api/v1/agents/{agenid}/revocation with a signed{" "} + Revocation{" "} + (revokes_type: "agent"). See the{" "} + API reference. +

+
+
+
+ ); +} diff --git a/packages/web/app/sitemap.ts b/packages/web/app/sitemap.ts index f372ff2..7715123 100644 --- a/packages/web/app/sitemap.ts +++ b/packages/web/app/sitemap.ts @@ -35,6 +35,7 @@ export default async function sitemap(): Promise { { url: `${SITE_URL}/docs/onboarding`, changeFrequency: "monthly", priority: 0.8 }, { url: `${SITE_URL}/badge`, changeFrequency: "monthly", priority: 0.6 }, { url: `${SITE_URL}/agents`, changeFrequency: "daily", priority: 0.7 }, + { url: `${SITE_URL}/revoke`, changeFrequency: "monthly", priority: 0.4 }, ]; // The long-form library (glossary, guides, comparisons, use cases, blog, report). Static diff --git a/packages/web/app/trust/page.tsx b/packages/web/app/trust/page.tsx index 0735ad7..1034095 100644 --- a/packages/web/app/trust/page.tsx +++ b/packages/web/app/trust/page.tsx @@ -106,8 +106,10 @@ export default function TrustPage() { active. The same is true one level up: agent status transitions (CHANGED, STALE,{" "} SUSPENDED, REVOKED) are defined in the - protocol and stored in the registry’s schema, and no write path sets them. There is no revocation - flow today, and this page will say so until there is. + protocol and stored in the registry’s schema. Exactly one is written: an operator can permanently revoke + their own agent at /revoke, and only with its operator key. The + resolution envelope then carries the signed revocation so anyone can check who made the change. The others are + never set, and there is no key-revocation flow.

diff --git a/packages/web/components/DirectoryConsent.tsx b/packages/web/components/DirectoryConsent.tsx index b44f6c6..c96f5df 100644 --- a/packages/web/components/DirectoryConsent.tsx +++ b/packages/web/components/DirectoryConsent.tsx @@ -72,9 +72,10 @@ export function ListInDirectory({ agentId, keyId, keyPair }: { agentId: string; ); } -type KeyFile = { agent_id: string; key_id: string; private_key_hex: string }; +export type KeyFile = { agent_id: string; key_id: string; private_key_hex: string }; -function parseKeyFile(text: string): KeyFile | string { +/** Shared with the revocation form, so both read the key file /issue offers identically. */ +export function parseKeyFile(text: string): KeyFile | string { try { const o = JSON.parse(text) as Record; if (typeof o.agent_id !== "string" || typeof o.key_id !== "string" || typeof o.private_key_hex !== "string") { diff --git a/packages/web/components/RevokeAgentForm.tsx b/packages/web/components/RevokeAgentForm.tsx new file mode 100644 index 0000000..f8c978f --- /dev/null +++ b/packages/web/components/RevokeAgentForm.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { parseKeyFile, type KeyFile } from "@/components/DirectoryConsent"; +import { + keyPairFromPrivateHex, + signAgentRevocation, + REVOCATION_REASONS, + type ClientKeyPair, + type ClientRevocationReason, +} from "@/lib/client-crypto"; + +/** + * Permanent agent revocation, signed in the browser. + * + * The key file is read in this tab; the private key signs a Revocation and is never + * uploaded. Only the signed object is sent. Nothing is pre-selected and nothing acts on + * its own: the operator chooses a reason, types the identifier, and confirms that the + * action is permanent before the button enables. A revocation cannot be undone, so the + * form asks for each of those as a separate, deliberate act. + */ + +const REASON_LABEL: Record = { + key_compromise: "The operator key may be compromised", + agent_retired: "The agent has been retired", + superseded: "Replaced by a new identity", + no_longer_authorized: "The operator no longer runs this agent", + operator_request: "Operator request (no further reason)", + issued_in_error: "Registered in error", + unspecified: "Prefer not to say", +}; + +type State = + | { kind: "idle" } + | { kind: "busy" } + | { kind: "done"; agentId: string; revocationId: string } + | { kind: "error"; message: string }; + +async function submit(keyPair: ClientKeyPair, file: KeyFile, reason: ClientRevocationReason): Promise { + const revocation = signAgentRevocation(keyPair, file.agent_id, file.key_id, reason); + try { + const res = await fetch(`/api/v1/agents/${encodeURIComponent(file.agent_id)}/revocation`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(revocation), + }); + const body = (await res.json().catch(() => ({}))) as { message?: string; error?: string; revocation_id?: string }; + if (!res.ok) return { kind: "error", message: body.message ?? body.error ?? `request failed (${res.status})` }; + return { kind: "done", agentId: file.agent_id, revocationId: body.revocation_id ?? revocation.revocation_id }; + } catch { + return { kind: "error", message: "The registry could not be reached. Nothing was changed." }; + } +} + +export function RevokeAgentForm() { + const [file, setFile] = useState(null); + const [fileError, setFileError] = useState(null); + const [reason, setReason] = useState(""); + const [typed, setTyped] = useState(""); + const [understood, setUnderstood] = useState(false); + const [state, setState] = useState({ kind: "idle" }); + + const ready = file !== null && reason !== "" && typed.trim() === file.agent_id && understood && state.kind !== "busy"; + + if (state.kind === "done") { + return ( +
+

Revoked

+

+ The identity is permanently revoked. Its card and badges now read REVOKED, and the signed revocation is part of + its public record so anyone can check that you, not the registry, made this change. +

+

{state.revocationId}

+

+ + Open the Verification Card + +

+
+ ); + } + + return ( +
{ + e.preventDefault(); + if (!ready) return; // `ready` narrows file and reason (aliased condition) + setState({ kind: "busy" }); + let keyPair: ClientKeyPair; + try { + keyPair = keyPairFromPrivateHex(file.private_key_hex); + } catch { + setState({ kind: "error", message: "The key in that file could not be read." }); + return; + } + setState(await submit(keyPair, file, reason)); + }} + > + + { + setState({ kind: "idle" }); + setTyped(""); + const f = e.target.files?.[0]; + if (!f) return setFile(null); + const parsed = parseKeyFile(await f.text()); + if (typeof parsed === "string") { + setFile(null); + setFileError(parsed); + } else { + setFile(parsed); + setFileError(null); + } + }} + /> + {fileError && ( +

+ {fileError} +

+ )} + {file &&

{file.agent_id}

} + + + +

The reason is signed and public. It is your statement; the registry does not check it.

+ + + setTyped(e.target.value)} + disabled={!file} + /> + + + +
+ + {state.kind === "error" && ( + + {state.message} + + )} +
+
+ ); +} diff --git a/packages/web/lib/client-crypto.ts b/packages/web/lib/client-crypto.ts index 783e198..8d5c770 100644 --- a/packages/web/lib/client-crypto.ts +++ b/packages/web/lib/client-crypto.ts @@ -313,3 +313,59 @@ export function keyPairFromPrivateHex(hex: string): ClientKeyPair { const publicKey = ed25519.getPublicKey(privateKey); return { privateKey, publicKey, publicKeyHex: hexEncode(publicKey), publicKeyB64u: b64uEncode(publicKey) }; } + +// --------------------------------------------------------------------------- +// Agent revocation (v1.2-draft Revocation object, revokes_type "agent") +// --------------------------------------------------------------------------- + +/** Must equal REVOCATION_SCHEMA_ID in @agenid/core (test-pinned). */ +const REVOCATION_SCHEMA_ID = "https://agenid.com/schemas/v1.2/revocation.json"; + +/** Must equal @agenid/core's RevocationReason enum (test-pinned). */ +export const REVOCATION_REASONS = [ + "key_compromise", + "superseded", + "no_longer_authorized", + "operator_request", + "agent_retired", + "issued_in_error", + "unspecified", +] as const; +export type ClientRevocationReason = (typeof REVOCATION_REASONS)[number]; + +export interface SignedRevocation { + $schema: string; + revocation_id: string; + revokes: string; + revokes_type: "agent"; + reason: ClientRevocationReason; + revoked_at: string; + key_id: string; + signature: string; +} + +/** + * Sign a permanent agent revocation with the agent's operator key, in the browser. + * RFC 8785 canonical bytes of the object without `signature` (`$schema` included), pure + * Ed25519 — the same construction @agenid/core's signRevocation uses, cross-checked by test. + * Only the returned object is ever sent. + */ +export function signAgentRevocation( + keyPair: ClientKeyPair, + agentId: string, + keyId: string, + reason: ClientRevocationReason, + now: Date = new Date(), +): SignedRevocation { + const payload = { + $schema: REVOCATION_SCHEMA_ID, + revocation_id: `revocation:${generateUlid()}`, + revokes: agentId, + revokes_type: "agent" as const, + reason, + revoked_at: now.toISOString(), + key_id: keyId, + }; + const signature = ed25519.sign(canonicalizeToBytes(payload), keyPair.privateKey); + return { ...payload, signature: b64uEncode(signature) }; +} diff --git a/packages/web/lib/content/blog.ts b/packages/web/lib/content/blog.ts index abe1461..d4ee025 100644 --- a/packages/web/lib/content/blog.ts +++ b/packages/web/lib/content/blog.ts @@ -305,7 +305,7 @@ export const POSTS: readonly BlogPost[] = [ }, { q: "What happens to a retired key?", - a: "Retired and revoked keys stay resolvable so historical signatures remain checkable. There is no revocation flow deployed today.", + a: "Retired and revoked keys stay resolvable so historical signatures remain checkable. There is no key-revocation flow deployed today; an operator can revoke the whole agent instead.", }, ], related: ["/glossary/two-path-key-discovery", "/glossary/well-known-uri", "/glossary/operator-key", "/learn/how-to-verify-an-ai-agent", "/verify"], @@ -597,7 +597,7 @@ export const POSTS: readonly BlogPost[] = [ }, { q: "Is the registration permanent?", - a: "The identifier is permanent. Agent status values exist in the schema, but no revocation or status-change flow is deployed today.", + a: "The identifier is permanent. Its operator can revoke it at any time with the operator key, and a revoked identifier stays resolvable and is never reissued.", }, ], related: ["/issue", "/verify", "/docs/onboarding", "/glossary/manifest-proof", "/blog/removing-fabricated-verification"], diff --git a/packages/web/lib/content/compare.ts b/packages/web/lib/content/compare.ts index fc9c1eb..5c80b80 100644 --- a/packages/web/lib/content/compare.ts +++ b/packages/web/lib/content/compare.ts @@ -17,7 +17,7 @@ const CEILING = "Issuance ceiling: the reference deployment at agenid.com issues nothing above L1_REGISTERED today. The specification defines L2_DOMAIN_VERIFIED, L3_ORGANIZATION_VERIFIED and L4_DEPLOYMENT_VERIFIED, but each requires a VerificationAssertion signed by a root authority key, and that key has not been created yet. L1_REGISTERED is a self-declaration: it means an operator-signed manifest is in the registry, not that anyone checked the operator. L5 is reserved and not issuable."; const NO_REVOCATION = - "No revocation flow is deployed. Agent status values such as SUSPENDED and REVOKED exist in the schema so the envelope shape is stable, but nothing in production writes them, so every registered agent reads ACTIVE. The specification keeps retired and revoked keys resolvable permanently so that historical signatures remain checkable."; + "Only operator-signed agent revocation is deployed: an operator can permanently mark their own agent REVOKED with its operator key, and the envelope carries the signed revocation. No other status (CHANGED, STALE, SUSPENDED) is written, and there is no key-revocation flow. The specification keeps retired and revoked keys resolvable permanently so that historical signatures remain checkable."; const NO_AUTHORIZATION = "No authorization object exists in v1.1.1. The DECLARED, VERIFIED and AUTHORIZED claim states are never inferred from one another, AUTHORIZED has no signed object, and `permissions` and `authorizations` are reserved manifest keys that a v1.1.1 validator rejects. A v1.2 authorization layer is a draft in development, not something available."; @@ -207,7 +207,7 @@ export const COMPARISONS: readonly ComparisonPage[] = [ ["Keys and cryptography", "Pure Ed25519 over RFC 8785 canonical JSON", "OAuth 2.0 access tokens requested with blueprint credentials"], ["Scope", "Public and portable; no tenant or account needed to verify", "Tenant-scoped, for resources that accept Microsoft Entra tokens"], ["Authorization model", "None in v1.1.1; a v1.2 authorization layer is a draft", "Delegated and application permissions, admin consent, blocked high-privilege roles, inheritable permissions"], - ["Governance and monitoring", "Append-only registry event ledger; no revocation flow deployed", "Conditional Access, ID Protection, identity governance, sign-in and audit logs"], + ["Governance and monitoring", "Append-only registry event ledger; operator-signed agent revocation; no key revocation or suspension", "Conditional Access, ID Protection, identity governance, sign-in and audit logs"], ["Openness and license", "Specification MIT-licensed; independent conformance suite MIT", "Microsoft service; Microsoft states Entra security features for agents require Microsoft Agent 365"], ["Deployment status", "Live at www.agenid.com; ceiling L1_REGISTERED", "Microsoft states Agent ID is available for all Microsoft Entra customers"], ], diff --git a/packages/web/lib/content/glossary-a.ts b/packages/web/lib/content/glossary-a.ts index 13b4fca..580f989 100644 --- a/packages/web/lib/content/glossary-a.ts +++ b/packages/web/lib/content/glossary-a.ts @@ -508,7 +508,7 @@ export const GLOSSARY_A: readonly GlossaryTerm[] = [ { heading: "What it does not mean", body: [ - "Holding the key proves control of the key, not honesty. No revocation flow is deployed today: the status values exist in the schema, and retired or revoked keys would stay resolvable so old signatures remain checkable, but nothing currently writes them. The key also signs identity claims only; it is not a credential for what the agent is allowed to do.", + "Holding the key proves control of the key, not honesty. No key-revocation flow is deployed today: retired or revoked keys would stay resolvable so old signatures remain checkable, but nothing currently writes those key states. If the key is compromised, its holder can permanently revoke the agent it controls. The key also signs identity claims only; it is not a credential for what the agent is allowed to do.", ], }, ], diff --git a/packages/web/lib/content/glossary-b.ts b/packages/web/lib/content/glossary-b.ts index 4912c62..f555cae 100644 --- a/packages/web/lib/content/glossary-b.ts +++ b/packages/web/lib/content/glossary-b.ts @@ -807,7 +807,7 @@ export const GLOSSARY_B: readonly GlossaryTerm[] = [ body: [ "In the spec, rotation means a new key with its own ULID, and the old key marked `retired`. Proofs created before `retired_at` remain valid. `revoked_at` invalidates proofs created after it regardless of signature, and every signed payload carries its own `expires_at`, checked independently.", "Retired and revoked key documents stay resolvable forever, so historical signatures remain checkable. Without that, revoking a key would silently rewrite history.", - "What is deployed: no revocation flow is deployed on agenid.com, and there is no rotation flow either. Agent status values such as `SUSPENDED` and `REVOKED` exist in the schema, but nothing writes them; every registered agent is `ACTIVE`.", + "What is deployed: agenid.com has no key-rotation or key-revocation flow, so every published key is `active`. An operator can permanently revoke a whole agent at [/revoke](/revoke), signed with its operator key; that sets the agent to `REVOKED` and leaves the key document unchanged. `CHANGED`, `STALE` and `SUSPENDED` are never written.", ], }, { @@ -826,7 +826,7 @@ export const GLOSSARY_B: readonly GlossaryTerm[] = [ faqs: [ { q: "Can I revoke my agent's key on agenid.com today?", - a: "No. No revocation flow is deployed. The spec defines the model; the reference deployment does not implement it yet.", + a: "Not the key itself. There is no key-revocation flow; the spec defines the model and the reference deployment does not implement it yet. You can permanently revoke the whole agent at [/revoke](/revoke) with the key file, which is what to do if the key is compromised.", }, { q: "Why keep a revoked key resolvable?", diff --git a/packages/web/lib/content/guides.ts b/packages/web/lib/content/guides.ts index 72c5095..dcc2d97 100644 --- a/packages/web/lib/content/guides.ts +++ b/packages/web/lib/content/guides.ts @@ -97,7 +97,7 @@ export const GUIDES: readonly ContentPage[] = [ "A key document carries `status` (`active`, `retired` or `revoked`) and the timestamps `retired_at` and `revoked_at`. Evaluate the key against the instant the proof claims to have been signed, its `created_at`, not against the current time. A proof created before a key was retired stays valid; a proof created after `revoked_at` fails regardless of its signature. The failure is `key_not_active_at_signing_time`. The [key rotation and revocation](/glossary/key-rotation-and-revocation) entry covers the model.", "Retired and revoked keys stay resolvable forever. Historical signatures must remain checkable. A 200 from the key endpoint is therefore not a statement that the key is usable today. Read the fields.", "Then check the proof's own window: `created_at` must be at or before now, and now must be at or before `expires_at`. Proofs expire, so operators re-sign. Verification applies no clock leeway. Pass the current time in explicitly so the result is reproducible. Outcomes are `not_yet_valid` and `expired`.", - "One limit belongs here. The key model can express retirement and revocation, but the reference deployment has no write path that retires or revokes a key, and no agent revocation or suspension flow. Agent status values exist in the schema, and nothing writes them today. Every registered agent shows `ACTIVE`. Treat that field as unpopulated, not as an assurance.", + "One limit belongs here. The key model can express retirement and revocation, but the reference deployment has no write path that retires or revokes a key, and no suspension flow. The one status it writes is `REVOKED`, and only when the agent's own operator key signs a revocation; the envelope then carries the signed object so you can check who made the change. `CHANGED`, `STALE` and `SUSPENDED` are never set, so treat `ACTIVE` as meaning only that its operator has not revoked it, not as an assurance.", ], }, { @@ -146,7 +146,7 @@ export const GUIDES: readonly ContentPage[] = [ bullets: [ "L1 ceiling: " + CEILING, "One-source case: if the operator's domain serves no `.well-known/agenid/keys.json`, two-path discovery gives you one source and the substitution defense does not apply.", - "No revocation flow: agent statuses are stored but never written, and there is no deployed path to retire or revoke a key.", + "Revocation is operator-only: an operator can permanently revoke their own agent with its operator key, but no other status is ever written and there is no deployed path to retire or revoke a key.", "Operator attestations such as `discloses_to_user` and `human_escalation` are self-declared and unverifiable by anyone, including AgenID.", "Object, not request: a valid proof shows who signed a declaration, not that the live request in front of you came from that agent. See [agent-to-agent authentication](/learn/agent-to-agent-authentication).", ], @@ -195,7 +195,7 @@ export const GUIDES: readonly ContentPage[] = [ }, { q: "Can I check whether an agent has been revoked?", - a: "Not through an agent revocation flow, because none is deployed. Agent status values exist in the schema but nothing writes them. Key documents do carry status, retired_at and revoked_at, and you should evaluate them against the proof's signing time.", + a: "Yes, for operator revocation. Resolve the agent: a revoked one has `status: REVOKED` and its envelope carries the operator-signed revocation, which you should verify against the envelope's operator key. No other status is written. Key documents carry status, retired_at and revoked_at, but no key-revocation flow is deployed.", }, { q: "How do I test my own verifier implementation?", @@ -285,7 +285,7 @@ export const GUIDES: readonly ContentPage[] = [ ["Provenance", "A ManifestProof binding the manifest by SHA-256 digest, a registration time from the registry's clock, and an append-only event ledger", "Any check that the declared content is true"], ["Operator accountability", "Operator name, domain and contact, signed by the operator; domain-control evidence via a DNS TXT record at /verify/domain", "A verified binding to a legal entity or a person. L2 and L3 are defined but not issuable today"], ["Authorization", "Nothing. v1.1.1 reserves the `permissions` and `authorizations` keys and defines no signed authorization object", "Scopes, grants, delegation limits. A v1.2 authorization layer is a draft in development"], - ["Monitoring", "Stable identifiers that logs and reputation can attach to; key documents with status fields", "Behavioral monitoring, agent revocation or suspension. L5 is reserved and no revocation flow is deployed"], + ["Monitoring", "Stable identifiers that logs and reputation can attach to; key documents with status fields", "Behavioral monitoring, suspension, or revocation by anyone but the operator. L5 is reserved; only operator-signed agent revocation is deployed"], ], }, }, @@ -453,7 +453,7 @@ export const GUIDES: readonly ContentPage[] = [ ["Identifier permanence", "If an identifier can be reassigned, history and reputation can be hijacked", "Immutable for the life of the record and never reissued, even for a revoked agent"], ["Platform independence", "An identity tied to one platform breaks when the agent moves", "The identifier is non-semantic and survives platform and deployment changes"], ["Key rotation", "Keys must change without breaking historical signatures", "A new key gets its own ULID; retired and revoked keys stay resolvable forever. No rotation write path is deployed yet"], - ["Status and revocation", "Relying parties need to know when to stop accepting an agent", "Status values are defined, but nothing writes them today and no revocation flow is deployed"], + ["Status and revocation", "Relying parties need to know when to stop accepting an agent", "Operator-signed agent revocation is deployed and permanent; no other status is written and no key-revocation flow exists"], ["Who can write", "Open registration scales but admits anything; gated registration concentrates power", "Anyone holding an operator key may register, rate limited. Registration is a self-declaration at L1"], ["What an answer means", "A registry answer can be an assertion or just a pointer", "A pointer to signed material the verifier re-checks"], ], diff --git a/packages/web/lib/content/hubs.ts b/packages/web/lib/content/hubs.ts index d66d73f..f05892c 100644 --- a/packages/web/lib/content/hubs.ts +++ b/packages/web/lib/content/hubs.ts @@ -105,7 +105,7 @@ export const WHY_HUB: ContentPage = { ], bullets: [ "The issuance ceiling. " + CEILING + " L1_REGISTERED means the registry validated the schema, the digest binding and the operator's signature; it is a self-declaration and renders amber, never emerald.", - "No revocation flow. Agent status values exist in the schema so the envelope's shape is stable, but nothing writes them. Every registered agent is active, and there is no deployed way to revoke an agent or a key.", + "Revocation is operator-only. An operator can permanently revoke their own agent, signed with its operator key, and the envelope carries the signed revocation. No other status is ever written, and there is no deployed way to revoke a key.", "No authorization object in v1.1.1. The protocol defines no signed statement of what an agent may do; `permissions` and `authorizations` are reserved manifest keys. A v1.2 authorization layer exists only as a draft in development. Permission today stays with the operator and with the party on the other side.", "No request-level binding. Nothing yet ties a live request, or a live call, to a registered agent. A signature authenticates a signed object, and an object can be handed over by anyone who has seen it.", "Operator attestations are self-declared. Whether an agent discloses that it is an AI, or lets a human take over, is the operator's signed statement. No one, including AgenID, can verify it from an API.", @@ -237,7 +237,7 @@ export const VOICE_HUB: ContentPage = { body: [ "The largest limit is recorded in AgenID's own security analysis as GAP-C1: nothing yet binds a live request, and therefore a live call, to a registered agent in v1.1.1. An agent can state an AgenID, and the business can confirm that the identifier resolves and that its manifest verifies. It cannot confirm from the protocol alone that the voice on the line is the agent that identifier belongs to, because a signature authenticates a signed object and an object can be repeated by anyone who has seen it.", "The scenarios show one way this could be closed: the business issues a fresh challenge during the call and the agent answers it with its key. Challenge-response is a proposed pattern. v1.1.1 defines the signed objects, not that exchange, and nothing in the reference deployment performs it today.", - "Two further limits apply. There is no deployed revocation flow, so an operator cannot yet mark an agent as withdrawn. And there is no signed authorization object in v1.1.1, so an AgenID never tells a business what a caller is permitted to do.", + "Two further limits apply. Only the operator can revoke an agent, so a business cannot ask AgenID to withdraw an agent that is misbehaving; it can only stop trusting it. And there is no signed authorization object in v1.1.1, so an AgenID never tells a business what a caller is permitted to do.", ], }, ], diff --git a/packages/web/lib/content/report.ts b/packages/web/lib/content/report.ts index c630213..fbae031 100644 --- a/packages/web/lib/content/report.ts +++ b/packages/web/lib/content/report.ts @@ -127,7 +127,7 @@ export const REPORT: ContentPage = { "What works today. An agent gets an identifier of the form `agenid:`; the spec's example is `agenid:01J8Z3P2K8VW4RN7XTQ6MYD5HC`. The operator signs a ManifestProof with Ed25519 over RFC 8785 canonical JSON, binding the manifest by its SHA-256 digest. The reference registry at agenid.com resolves `https://www.agenid.com/a/` to a Verification Card for browsers and to a canonical resolution envelope as JSON, which a verifier can re-check offline; the registry cannot forge an operator signature. Registration happens in the browser at [/issue](/issue), where the key is generated locally and never sent to AgenID, and anyone can check an agent at [/verify](/verify).", "Two-path key discovery is deployed on the registry side: an operator key can be fetched from `https://www.agenid.com/v1/keys/` and compared with the operator's own copy at `https:///.well-known/agenid/keys.json`. The second path is published by the operator. Where the operator publishes no copy, a verifier has one source, not two.", "The issuance ceiling. Protocol v1.1.1 defines the levels DECLARED, L1_REGISTERED, L2_DOMAIN_VERIFIED, L3_ORGANIZATION_VERIFIED and L4_DEPLOYMENT_VERIFIED; L5 is reserved and not issuable. The reference deployment at agenid.com issues nothing above L1_REGISTERED today. L2 and higher require a VerificationAssertion signed by a root authority key, and that key has not been created yet. L1 is a registration of a self-declaration; operator attestations in the manifest are self-declared and cannot be verified by anyone, including AgenID. Confirming a `_agenid` DNS TXT record at [/verify/domain](/verify/domain) records evidence of domain control and raises nothing above L1.", - "What it does not do yet. There is no revocation flow deployed: agent status values exist in the schema, but nothing writes them, so no agent is revoked or suspended today. Retired keys stay resolvable so historical signatures remain checkable. v1.1.1 defines no signed authorization object; `permissions` and `authorizations` are reserved manifest keys, and a v1.2 authorization layer exists only as a draft in development. Nothing binds a live HTTP request to a registered agent. The partner documents for voice and agent platforms are integration patterns, not packages. In the terms of this report, AgenID addresses identity and offline re-verification; delegation, revocation and request binding remain open for AgenID as they do for the field. The [trust model](/trust) page describes these limits in more detail.", + "What it does not do yet. Revocation is operator-only: an operator can permanently revoke their own agent with its operator key, and the signed revocation travels in the envelope, but no other status is written, nothing is ever suspended, and there is no key-revocation flow. Retired keys stay resolvable so historical signatures remain checkable. v1.1.1 defines no signed authorization object; `permissions` and `authorizations` are reserved manifest keys, and a v1.2 authorization layer exists only as a draft in development. Nothing binds a live HTTP request to a registered agent. The partner documents for voice and agent platforms are integration patterns, not packages. In the terms of this report, AgenID addresses identity and offline re-verification; delegation, revocation and request binding remain open for AgenID as they do for the field. The [trust model](/trust) page describes these limits in more detail.", ], }, { diff --git a/packages/web/lib/revoke.ts b/packages/web/lib/revoke.ts new file mode 100644 index 0000000..7137cfb --- /dev/null +++ b/packages/web/lib/revoke.ts @@ -0,0 +1,17 @@ +/** + * Agent revocation for the deployed registry. The rules live in `@agenid/api`'s + * `revoke.ts` — the single implementation, shared with the Fastify registry — and this + * wrapper only supplies the deployment's store. A route's job is to shape the request + * and the response, never to decide what a revocation means. + */ +import { revokeAgentRequest, type RevokeResult, type RegistryStore } from "@agenid/api"; +import { getStore } from "@/lib/api"; + +export { REVOCATION_DISCLOSURES, REVOCATION_WINDOW_MS, revocationDigestHex, type RevokeResult } from "@agenid/api"; + +export function revokeAgent( + body: unknown, + opts: { now?: Date; store?: RegistryStore; agentIdFromPath?: string } = {}, +): Promise { + return revokeAgentRequest(body, opts.store ?? getStore(), { now: opts.now, agentIdFromPath: opts.agentIdFromPath }); +} diff --git a/packages/web/scripts/e2e.mjs b/packages/web/scripts/e2e.mjs index 1a5b664..fcc5195 100644 --- a/packages/web/scripts/e2e.mjs +++ b/packages/web/scripts/e2e.mjs @@ -12,6 +12,7 @@ import next from "next"; import { buildApp } from "@agenid/api"; import { generateAgentId, generateKeyId, generateKeyPair, makeKeyDocument, signManifestProof, keyIdToWire, + signRevocation, verifyRevocation, generateRevocationId, REVOCATION_SCHEMA_ID, } from "@agenid/core"; const API_PORT = 3901; @@ -206,6 +207,31 @@ env.operator_key?.discovery?.registry_path === `/v1/keys/${wire}` && !/not (serv ? ok("3k. the envelope advertises the registry path and no longer disclaims it") : fail("3k. envelope key-discovery pointer or verify_instructions is stale"); +// --- Revocation through the web route, forwarded to the HTTP registry. Runs last: it is permanent. +const revocation = signRevocation( + { $schema: REVOCATION_SCHEMA_ID, revocation_id: generateRevocationId(), revokes: agentId, revokes_type: "agent", reason: "agent_retired", revoked_at: NOW, key_id: opDoc.key_id }, + { privateKey: op.privateKey, document: opDoc }, +); +r = await fetch(`${W}/api/v1/agents/${agentId}/revocation`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(revocation) }); +const revBody = await r.json(); +r.status === 200 && revBody.status === "REVOKED" + ? ok("3s. an operator-signed revocation is accepted through the web route in HTTP-registry mode") + : fail(`3s. revocation: ${r.status} ${JSON.stringify(revBody).slice(0, 160)}`); +r = await fetch(`${W}/a/${agentId}`, { headers: { accept: "application/json" } }); +const revEnv = await r.json(); +revEnv.status === "REVOKED" && verifyRevocation(revEnv.revocation, revEnv.operator_key?.document, { now: NOW }).ok + ? ok("3t. the envelope is REVOKED and carries a revocation that verifies against its own operator key") + : fail(`3t. revoked envelope: status=${revEnv.status} revocation=${Boolean(revEnv.revocation)}`); +r = await fetch(`${W}/a/${agentId}`, { headers: { accept: "text/html" } }); +const revHtml = await r.text(); +r.status === 200 && revHtml.includes("REVOKED") && revHtml.includes(revocation.revocation_id) + ? ok("3u. the Verification Card renders REVOKED and names the signed revocation") + : fail(`3u. revoked card: ${r.status}`); +r = await fetch(`${W}/api/v1/agents/${agentId}/revocation`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(revocation) }); +r.status === 409 && (await r.json()).error === "already_revoked" + ? ok("3v. revocation is permanent: a replay is 409 already_revoked") + : fail(`3v. replay: ${r.status}`); + await new Promise((res) => web.close(res)); await api.close(); await app.close(); diff --git a/packages/web/test/content.test.ts b/packages/web/test/content.test.ts index 9fcac12..4ba468b 100644 --- a/packages/web/test/content.test.ts +++ b/packages/web/test/content.test.ts @@ -73,7 +73,7 @@ const DEAD_HOSTS = /agenid\.org|agenid\.ai|api\.agenid\.com/i; const STATIC_ROUTES = [ "/", "/issue", "/verify", "/verify/domain", "/trust", "/ecosystem", "/why-agent-identity", "/how-it-works", - "/docs", "/docs/onboarding", "/docs/partners", "/badge", "/agents", + "/docs", "/docs/onboarding", "/docs/partners", "/badge", "/agents", "/revoke", ]; const KNOWN = new Set([ ...STATIC_ROUTES, diff --git a/packages/web/test/design-system.test.ts b/packages/web/test/design-system.test.ts index 5c26f05..233acb7 100644 --- a/packages/web/test/design-system.test.ts +++ b/packages/web/test/design-system.test.ts @@ -168,7 +168,7 @@ describe("honesty through consolidation", () => { "has not completed its HSM ceremony", "Not yet installable.", "no adapter package exists for any of them", - "there is no revocation flow today", + "there is no key-revocation flow.", "Not a third-party check of the", "Verification ≠ compliance.", ]) { diff --git a/packages/web/test/public-links.test.ts b/packages/web/test/public-links.test.ts index dec2ca6..601aaca 100644 --- a/packages/web/test/public-links.test.ts +++ b/packages/web/test/public-links.test.ts @@ -234,16 +234,18 @@ describe("T-4 — no public surface claims a capability this deployment does not expect(bad, `Implies an unpublished package can be installed:\n ${bad.join("\n ")}`).toEqual([]); }); - it("claims no live status transition, because no write path sets one", () => { - // Agent status (CHANGED/STALE/SUSPENDED/REVOKED) and key retirement/revocation are - // in the schemas and rendered by both badges — and nothing writes them. Describing - // them as something that happens to a live identity is a capability claim. - const bad = FILES.filter((rel) => /(suspended|revoked) identity changes/i.test(read(rel))); + it("claims no status transition beyond operator revocation, because no other write path exists", () => { + // Since 2026-10-19 exactly one transition is written: an operator-signed REVOKED + // (lib/revoke.ts). CHANGED/STALE/SUSPENDED and key retirement/revocation are still in + // the schemas, rendered by both badges, and written by nothing. Describing them as + // something that happens to a live identity is a capability claim — and so is + // describing revocation as something anyone but the operator can do. + const bad = FILES.filter((rel) => /suspended identity changes|(registry|agenid|authority) (can|will) revoke/i.test(read(rel))); expect(bad, `Claims a status transition that cannot occur:\n ${bad.join("\n ")}`).toEqual([]); for (const rel of [HOME, TRUST]) { - expect(read(rel), `${rel} does not disclose that there is no revocation flow`).toMatch( - /no revocation flow/i, - ); + const src = read(rel).replace(/\s+/g, " "); + expect(src, `${rel} does not say revocation is operator-only`).toMatch(/revoke their own agent/i); + expect(src, `${rel} does not disclose that there is no key-revocation flow`).toMatch(/no key-revocation flow/i); } }); diff --git a/packages/web/test/revocation.test.ts b/packages/web/test/revocation.test.ts new file mode 100644 index 0000000..472c0dd --- /dev/null +++ b/packages/web/test/revocation.test.ts @@ -0,0 +1,369 @@ +/** + * Agent revocation, end to end, against the real route handler, the real registration + * implementation and a real MemoryStore. No mocked crypto, no mocked verification. + * + * The properties that matter, in the order a reviewer should check them: + * 1. only the agent's own operator key can revoke it — not another agent's key, not an + * authority key, not a tampered signature; + * 2. a refused request changes nothing — status, ledger and stored revocation are + * untouched; + * 3. revocation is permanent — a second revocation, a re-registration and a directory + * listing are all refused afterwards; + * 4. a third party can check it without trusting the registry — the envelope carries + * the signed object, and it verifies offline against the envelope's operator key; + * 5. every surface renders it — the shared trust module returns REVOKED. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { MemoryStore, revocationDigestHex, buildEnvelope } from "@agenid/api"; +import { + verifyRevocation, + REVOCATION_SCHEMA_ID, + RevocationReason, + generateGrantId, + type Revocation, +} from "@agenid/core"; +import { + generateKeyPair, + signAgent, + signAgentRevocation, + signDirectoryConsent, + REVOCATION_REASONS, + type ClientKeyPair, +} from "../lib/client-crypto"; +import { __resetMemoryLimiter } from "../lib/rate-limit"; +import { presentEnvelopeTrust } from "../lib/trust-presentation"; +import { registerAgent } from "../lib/register"; +import { applyDirectoryConsent, MemoryDirectoryStore } from "../lib/directory"; +import { revokeAgent } from "../lib/revoke"; + +let store = new MemoryStore(); +vi.mock("@/lib/api", async () => { + const actual = await vi.importActual("../lib/api"); + return { ...actual, API_URL: undefined, SITE_URL: "https://www.agenid.com", getStore: () => store }; +}); + +const INPUT = { + operator: "Acme Health, Inc.", + operatorDomain: "acmehealth.com", + purposeSummary: "Books and reschedules patient appointments.", + contact: "ops@acmehealth.com", + disclosesToUser: false, + humanEscalation: false, +}; + +interface Registered { + keyPair: ClientKeyPair; + agentId: string; + keyId: string; +} + +async function register(name = "Front desk"): Promise { + const keyPair = generateKeyPair(); + const s = await signAgent(keyPair, name, INPUT); + const r = await registerAgent({ manifest: s.manifest, proof: s.proof, key_document: s.keyDocument }); + if (!r.ok) throw new Error(`fixture registration failed: ${r.error}`); + return { keyPair, agentId: s.agentId, keyId: s.keyId }; +} + +async function post(agentId: string, body: unknown) { + const { POST } = await import("../app/api/v1/agents/[agenid]/revocation/route"); + const res = await POST( + new Request(`https://www.agenid.com/api/v1/agents/${agentId}/revocation`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ agenid: agentId }) }, + ); + return { res, json: (await res.json()) as Record }; +} + +async function snapshot(agentId: string) { + return { + status: (await store.getAgent(agentId))?.status, + events: (await store.listEvents(agentId)).map((e) => e.type), + revocation: await store.getAgentRevocation(agentId), + }; +} + +beforeEach(() => { + __resetMemoryLimiter(); + store = new MemoryStore(); +}); + +describe("the operator revokes their own agent", () => { + it("200, REVOKED, and one agent.revoked event pointing at the signed object by digest", async () => { + const a = await register(); + const rev = signAgentRevocation(a.keyPair, a.agentId, a.keyId, "agent_retired"); + const { res, json } = await post(a.agentId, rev); + + expect(res.status).toBe(200); + expect(res.headers.get("access-control-allow-origin")).toBe("*"); + expect(json.status).toBe("REVOKED"); + expect(json.revocation_id).toBe(rev.revocation_id); + expect(json.reason).toBe("agent_retired"); + + const after = await snapshot(a.agentId); + expect(after.status).toBe("REVOKED"); + expect(after.events).toEqual(["key.published", "agent.registered", "agent.revoked"]); + const ev = (await store.listEvents(a.agentId)).at(-1)!; + expect(ev.detail_ref.revocation_id).toBe(rev.revocation_id); + expect(ev.detail_ref.revocation_digest).toBe(revocationDigestHex(rev as unknown as Revocation)); + expect((json.revocation_digest as { value: string }).value).toBe(ev.detail_ref.revocation_digest); + // The ledger holds references, never the object itself (spec §13). + expect(JSON.stringify(ev.detail_ref)).not.toContain(rev.signature); + }); + + it("the envelope carries the revocation, and it verifies offline against the envelope's own operator key", async () => { + const a = await register(); + const rev = signAgentRevocation(a.keyPair, a.agentId, a.keyId, "key_compromise"); + expect((await post(a.agentId, rev)).res.status).toBe(200); + + const rec = (await store.getAgent(a.agentId))!; + const env = await buildEnvelope(store, rec, new Date().toISOString()); + expect(env.status).toBe("REVOKED"); + expect(env.revocation).toEqual(rev); + + // A verifier holding only the envelope: + const check = verifyRevocation(env.revocation, env.operator_key.document, { now: new Date().toISOString() }); + expect(check.ok).toBe(true); + expect(env.revocation!.revokes).toBe(env.agent_id); + expect(env.operator_key.document!.controller).toBe(env.agent_id); + expect(env.verify_instructions).toMatch(/REVOKED/); + + // The operator proof still verifies — revocation retires the identity, it does not + // pretend the historical signature was never valid. + expect(env.proof_check.ok).toBe(true); + }); + + it("every badge and card renders it as REVOKED through the one trust module", async () => { + const a = await register(); + await post(a.agentId, signAgentRevocation(a.keyPair, a.agentId, a.keyId, "operator_request")); + const env = await buildEnvelope(store, (await store.getAgent(a.agentId))!, new Date().toISOString()); + const t = presentEnvelopeTrust(env); + expect(t.badgeLabel).toBe("REVOKED"); + expect(t.verified).toBe(false); + expect(t.tone).toBe("alert"); + }); + + it("an ACTIVE agent's envelope carries revocation: null, so the shape does not depend on status", async () => { + const a = await register(); + const env = await buildEnvelope(store, (await store.getAgent(a.agentId))!, new Date().toISOString()); + expect(env).toHaveProperty("revocation", null); + }); +}); + +describe("only the agent's own operator key has standing", () => { + it("another agent's operator key is refused, even with a valid signature", async () => { + const victim = await register("Victim"); + const attacker = await register("Attacker"); + // Correctly signed by the attacker's key, naming the attacker's key, targeting the victim. + const rev = signAgentRevocation(attacker.keyPair, victim.agentId, attacker.keyId, "operator_request"); + const before = await snapshot(victim.agentId); + const { res, json } = await post(victim.agentId, rev); + expect(res.status).toBe(403); + expect(json.error).toBe("key_not_operator"); + expect(await snapshot(victim.agentId)).toEqual(before); + }); + + it("naming the victim's key id but signing with a different key fails the signature check", async () => { + const victim = await register(); + const rev = signAgentRevocation(generateKeyPair(), victim.agentId, victim.keyId, "operator_request"); + const before = await snapshot(victim.agentId); + const { res, json } = await post(victim.agentId, rev); + expect(res.status).toBe(403); + expect(json.error).toBe("signature_invalid"); + expect(await snapshot(victim.agentId)).toEqual(before); + }); + + it("a one-character change to a signed revocation is refused", async () => { + const a = await register(); + const rev = signAgentRevocation(a.keyPair, a.agentId, a.keyId, "agent_retired"); + const tampered = { ...rev, reason: "issued_in_error" }; + const { res, json } = await post(a.agentId, tampered); + expect(res.status).toBe(403); + expect(json.error).toBe("signature_invalid"); + expect((await snapshot(a.agentId)).status).toBe("ACTIVE"); + }); + + it("an authority-role key cannot revoke an agent", async () => { + const a = await register(); + const authority = generateKeyPair(); + const authorityKeyId = "agenid:key:01JZZZZZZZZZZZZZZZZZZZZZZZ"; + await store.putKey({ + key_id: authorityKeyId, + key_type: "Ed25519", + public_key_b64u: authority.publicKeyB64u, + role: "authority", + controller: "agenid:authority:agenid", + created_at: new Date(Date.now() - 60_000).toISOString(), + status: "active", + retired_at: null, + revoked_at: null, + } as never); + const rev = signAgentRevocation(authority, a.agentId, authorityKeyId, "operator_request"); + const { res, json } = await post(a.agentId, rev); + expect(res.status).toBe(403); + expect(json.error).toBe("key_not_operator"); + expect((await snapshot(a.agentId)).status).toBe("ACTIVE"); + }); +}); + +describe("validation, replay and permanence", () => { + it("a revocation signed more than five minutes ago, or ahead, is refused", async () => { + const a = await register(); + for (const offset of [-10 * 60_000, 10 * 60_000]) { + const rev = signAgentRevocation(a.keyPair, a.agentId, a.keyId, "agent_retired", new Date(Date.now() + offset)); + const { res, json } = await post(a.agentId, rev); + expect(res.status).toBe(400); + expect(json.error).toBe("revocation_expired"); + } + expect((await snapshot(a.agentId)).status).toBe("ACTIVE"); + }); + + it("the path and the signed target must agree", async () => { + const a = await register("A"); + const b = await register("B"); + const rev = signAgentRevocation(a.keyPair, a.agentId, a.keyId, "agent_retired"); + const { res, json } = await post(b.agentId, rev); + expect(res.status).toBe(400); + expect(json.error).toBe("agent_id_mismatch"); + expect((await snapshot(a.agentId)).status).toBe("ACTIVE"); + }); + + it("unknown members, wrong $schema and non-agent targets are refused before any lookup", async () => { + const a = await register(); + const rev = signAgentRevocation(a.keyPair, a.agentId, a.keyId, "agent_retired"); + expect((await post(a.agentId, { ...rev, level: "L4_DEPLOYMENT_VERIFIED" })).json.error).toBe("invalid_revocation"); + expect((await post(a.agentId, { ...rev, $schema: "https://example.com/x.json" })).json.error).toBe("invalid_revocation"); + expect((await post(a.agentId, { ...rev, reason: "because" })).json.error).toBe("invalid_revocation"); + const grantTarget = { ...rev, revokes: generateGrantId(), revokes_type: "grant" }; + expect((await post(a.agentId, grantTarget)).json.error).toBe("unsupported_revocation_target"); + expect((await post(a.agentId, "not an object")).json.error).toBe("invalid_revocation"); + expect((await snapshot(a.agentId)).status).toBe("ACTIVE"); + }); + + it("an unknown agent is 404", async () => { + const rev = signAgentRevocation(generateKeyPair(), "agenid:01JZZZZZZZZZZZZZZZZZZZZZZZ", "agenid:key:01JZZZZZZZZZZZZZZZZZZZZZZZ", "unspecified"); + const { res, json } = await post("agenid:01JZZZZZZZZZZZZZZZZZZZZZZZ", rev); + expect(res.status).toBe(404); + expect(json.error).toBe("agent_not_found"); + }); + + it("revocation is permanent: a second revocation, a replay and re-registration are all refused", async () => { + const keyPair = generateKeyPair(); + const s = await signAgent(keyPair, "Once", INPUT); + const regBody = { manifest: s.manifest, proof: s.proof, key_document: s.keyDocument }; + expect((await registerAgent(regBody)).ok).toBe(true); + + const first = signAgentRevocation(keyPair, s.agentId, s.keyId, "agent_retired"); + expect((await post(s.agentId, first)).res.status).toBe(200); + + const replay = await post(s.agentId, first); + expect(replay.res.status).toBe(409); + expect(replay.json.error).toBe("already_revoked"); + + const second = await post(s.agentId, signAgentRevocation(keyPair, s.agentId, s.keyId, "issued_in_error")); + expect(second.json.error).toBe("already_revoked"); + + // The first revocation stands; nothing replaced it, nothing was appended. + expect((await store.getAgentRevocation(s.agentId))?.revocation_id).toBe(first.revocation_id); + expect((await snapshot(s.agentId)).events.filter((t) => t === "agent.revoked")).toHaveLength(1); + + const rereg = await registerAgent(regBody); + expect(rereg.ok).toBe(false); + if (!rereg.ok) expect(rereg.error).toBe("agent_exists"); + }); + + it("a revoked agent cannot be listed in the directory", async () => { + const a = await register(); + await post(a.agentId, signAgentRevocation(a.keyPair, a.agentId, a.keyId, "agent_retired")); + const consent = signDirectoryConsent(a.keyPair, a.agentId, a.keyId, true); + const r = await applyDirectoryConsent(consent, { store, directory: new MemoryDirectoryStore() }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe("agent_not_active"); + }); + + it("a store failure is 503 and reports nothing as revoked", async () => { + const a = await register(); + const failing = Object.create(store) as MemoryStore; + failing.revokeAgent = async () => { + throw new Error("connection refused"); + }; + const r = await revokeAgent(signAgentRevocation(a.keyPair, a.agentId, a.keyId, "agent_retired"), { store: failing }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.status).toBe(503); + expect((await snapshot(a.agentId)).status).toBe("ACTIVE"); + }); + + it("refusals never echo submitted values back", async () => { + const a = await register(); + const rev = signAgentRevocation(a.keyPair, a.agentId, a.keyId, "agent_retired"); + const { json } = await post(a.agentId, { ...rev, injected: "" }); + expect(JSON.stringify(json)).not.toContain("