From 1f1cc0145e0445b8f1f48b8a7c97df82731455c8 Mon Sep 17 00:00:00 2001 From: Arunima Chaudhuri Date: Tue, 29 Sep 2026 00:24:44 +0530 Subject: [PATCH 01/10] docs: add documentation index --- docs/README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 docs/README.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..a0ea5ac --- /dev/null +++ b/docs/README.md @@ -0,0 +1,15 @@ +# AgentAuth documentation + +| Doc | Read it if you want to… | +|---|---| +| [Why AgentAuth](why.md) | understand the problem and why existing tools (API keys, OAuth, mTLS) fall short for autonomous agents | +| [Architecture](architecture.md) | see the moving parts: registry, SDK, relying services, and how a request is checked | +| [Protocol specification](protocol.md) | implement a compatible client or verifier in another language | +| [Integration guide](integration-guide.md) | give an agent an identity, or protect your service with AgentAuth | +| [Threat model](threat-model.md) | know what AgentAuth defends against, and what it doesn't (yet) | +| [Operations](operations.md) | run the registry and manage keys in a real deployment | +| [Design decisions](design-decisions.md) | learn why things are the way they are, and what was traded off | +| [Glossary](glossary.md) | look up a term (DID, KEL, pre-rotation, principal, attenuation, …) | +| [Roadmap](roadmap.md) | see what's done and what's next | + +New here? Read **Why**, then **Architecture**, then follow the quick start in the [main README](../README.md). From 7ef244c7030bbe2199ad604c0f2a124fb86f5ce9 Mon Sep 17 00:00:00 2001 From: Arunima Chaudhuri Date: Tue, 29 Sep 2026 00:24:45 +0530 Subject: [PATCH 02/10] docs: explain why AgentAuth exists The problem with API keys, OAuth and mTLS for autonomous agents, what AgentAuth does differently, and who it's for. --- docs/why.md | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/why.md diff --git a/docs/why.md b/docs/why.md new file mode 100644 index 0000000..af3bffc --- /dev/null +++ b/docs/why.md @@ -0,0 +1,62 @@ +# Why AgentAuth + +## The short version + +AI agents are starting to act on their own: calling APIs, spending money, reading files, and handing work to other agents. The ways we authenticate software today were built for two kinds of caller, **humans** (who log in) and **services** (which a team deploys and owns). Autonomous agents are neither. They're created on the fly, run where you can't fully see, delegate to each other, and act *on behalf of* someone. + +AgentAuth gives each agent a **cryptographic identity it owns**, lets it **prove who it is on every request without sharing a secret**, and lets a human **hand it a limited, revocable slice of their authority**. That slice can be passed down to sub-agents, but it can only shrink along the way. + +## What goes wrong today + +### API keys are the wrong shape + +Most agents today authenticate with an API key pasted into an environment variable. That causes predictable problems: + +- **Keys leak.** Agents put their context everywhere: logs, prompts, tool outputs, traces, other agents' memories. A bearer secret in an agent's context is one prompt injection away from being exfiltrated. +- **A leaked key works anywhere, forever.** Nothing ties it to a particular request, service or time window. +- **Keys identify an account, not an agent.** When ten agents share a key, you can't tell which one did something, and you can't turn off just one. +- **No delegation.** If an agent needs to hand a subtask to a sub-agent, its only option is to share the key, giving away all of its authority. +- **No limits.** A key can do everything the account can. "This agent may spend up to 50 USD on API credits this afternoon" can't be expressed. + +### OAuth helps, but assumes a human in the loop + +OAuth 2.0 was designed for a *user* granting an *app* access through a browser consent screen. It's a much better fit than API keys, and ideas like scopes and short-lived tokens carry over directly. But: + +- **Tokens are bearer credentials.** Whoever holds one can use it. There are proof-of-possession extensions (DPoP, mTLS-bound tokens), but they aren't the default and the tooling is uneven. +- **Delegation chains aren't native.** Token exchange can model "A on behalf of B", but multi-hop chains (human → agent → sub-agent → sub-sub-agent) with narrowing at each hop get complicated quickly. +- **The authorization server is the root of trust for identity.** An agent's identity is whatever the provider's database says it is. +- **Budgets, rates and use counts** aren't part of the model. + +### mTLS and workload identity (SPIFFE, cloud IAM) fit infrastructure, not agents + +Workload identity is great for services a platform team deploys. Agents break its assumptions: + +- They're created dynamically, sometimes by other agents. +- They run across many environments: laptops, notebooks, SaaS sandboxes, other people's clouds. +- They need to carry *a human's intent*, not just "this is pod X". + +## What AgentAuth does differently + +| Need | AgentAuth's answer | +|---|---| +| An identity the agent owns | A **self-certifying DID**, derived from the agent's own signed key material. No registry can mint, reassign or forge it. | +| No secrets on the wire | Every request is **signed** with the agent's private key. The signature is bound to the method, path, body, target service, a timestamp and a single-use nonce, so it can't be replayed or reused elsewhere. | +| Survive key compromise | **Pre-rotation**: each identity commits to its *next* key in advance, so a stolen current key can't be used to take the identity over. | +| Know who's responsible | An optional **controller** (the human or org that owns the agent) co-signs its creation and holds a **kill switch**. | +| Act on someone's behalf | A human issues a **capability grant**: scoped, short-lived, and tied to specific services. | +| Delegate safely | Agents can re-delegate a *narrower* slice to sub-agents. Scopes, audiences, lifetime and depth can only shrink, and every action traces back to a human principal. | +| Put a ceiling on damage | **Limits** on resources, per-call and total spend, rate, and number of uses. Budgets are shared across everything delegated beneath a grant. | +| Stop things quickly | **Revoke** a grant, or **deactivate** an agent, and everything downstream stops working. | +| Don't trust the middleman | Services can replay an agent's full **key event log** and verify it themselves, instead of trusting the registry's answer. | + +## Who it's for + +- **Teams building agents** that call third-party or internal APIs, and want something better than a shared API key. +- **Service owners** who want to accept calls from agents and know *which* agent is calling, *who* authorized it, and *what* it's allowed to do. +- **Platform and security teams** who need to answer "which agents can spend money, and on whose authority?", and need to be able to turn one off. + +## What it isn't + +- **Not a replacement for user login.** Humans still log in the way they do today. AgentAuth gives *them* an identity they can use to authorize agents. +- **Not a policy engine.** AgentAuth checks that a request is within the authority it was granted. Deciding *which* principals your service trusts, and for what, is your call (see `root_policy` in the [integration guide](integration-guide.md)). +- **Not production-hardened yet.** See the [threat model](threat-model.md) and [roadmap](roadmap.md) for what's still missing. From 6984f984c35e1bca5bce29543439a05a2a5a3698 Mon Sep 17 00:00:00 2001 From: Arunima Chaudhuri Date: Tue, 29 Sep 2026 00:24:45 +0530 Subject: [PATCH 03/10] docs: add architecture overview with diagrams Components, the registry as witness, identity lifecycle, request verification flow, authority and budget propagation, cache freshness. --- docs/architecture.md | 119 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 docs/architecture.md diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5759226 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,119 @@ +# Architecture + +## Components + +```mermaid +flowchart LR + subgraph Principal["Human / org"] + H[Principal identity
did:agent:…] + end + subgraph Agents + A[Agent
SDK + keys] + S[Sub-agent
SDK + keys] + end + R[(Registry
key event logs
revocations)] + SVC[Relying service
RequestVerifier +
GrantVerifier] + + H -- "grant (signed)" --> A + A -- "delegated grant (narrower)" --> S + H & A & S -- "register / rotate / deactivate" --> R + S -- "signed request + AgentGrant chain" --> SVC + SVC -- "resolve keys, replay logs,
check revocations" --> R +``` + +| Component | Code | Role | +|---|---|---| +| **Crypto primitives** | `agentauth/crypto.py` | Ed25519 keys, base58btc/multibase, multikeys, canonical JSON, SHA-256 multihash digests | +| **Identity** | `agentauth/identity.py` | Builds and verifies key event log (KEL) events; derives DIDs; produces DID documents. Pure functions with no I/O. | +| **Request signing** | `agentauth/httpsig.py` | `AgentSig` header: signing and verification, replay (nonce) cache | +| **Grants** | `agentauth/grants.py` | Capability grants, delegation chains, scope algebra, revocation events, `GrantVerifier` | +| **Limits** | `agentauth/limits.py` | Resource/spend/rate/use limits, attenuation rules, `UsageLedger` | +| **Keystore** | `agentauth/keystore.py` | Encrypted key files; current and recovery keys stored separately | +| **SDK** | `agentauth/sdk.py` | `AgentIdentity`, `RegistryClient`, httpx/httpx2 auth hook, caching resolvers for services | +| **Integrations** | `agentauth/integrations.py` | FastAPI dependencies: `fastapi_dependency` (identity) and `fastapi_authorizer` (scopes + limits) | +| **Registry** | `agentauth/server.py`, `agentauth/store.py` | HTTP service storing KELs and revocations (SQLite) | +| **CLI** | `agentauth/cli.py` | `agentauth` command wrapping the SDK and registry | + +## The registry is a witness, not an authority + +The registry stores each identity's key event log and serves it back. It **validates** every event before accepting it, but it **cannot forge** one: + +- A DID is the digest of its own signed inception event, so the registry can't create or change one. +- Every later event is signed and hash-chained to the one before it. +- `RegistryKeyResolver` (used by services) downloads the **full log** and replays it locally by default, instead of trusting the registry's summary. + +What the registry *can* do is withhold events or show different clients different logs. See [threat model: registry equivocation](threat-model.md#registry-equivocation). + +## Lifecycle of an identity + +```mermaid +stateDiagram-v2 + [*] --> Active: inception
(key₀, commits to H(key₁)) + Active --> Active: rotation
(reveals key₁, commits to H(key₂)) + Active --> Deactivated: deactivation
(recovery key or controller) + Deactivated --> [*] +``` + +1. **Inception.** The agent generates two keypairs: a *current* key and a *next* (recovery) key. It signs an inception event containing the current public key and a hash of the next one. If it has a controller, the controller countersigns. The DID is the digest of this event. +2. **Rotation.** To rotate, the agent reveals the pre-committed next key, signs the rotation with it, and commits to a new next key. Holding only the current key isn't enough. +3. **Deactivation.** Either the holder of the recovery key or the controller (with its current key) can permanently deactivate the identity. Nothing can be appended afterwards. + +## What happens on a request + +```mermaid +sequenceDiagram + participant S as Sub-agent + participant SVC as Relying service + participant R as Registry + + S->>SVC: POST /purchase
Authorization: AgentSig …
AgentGrant: g_root,g_child + Note over SVC: 1. Authenticate + SVC->>R: resolve S (log replay, cached) + SVC->>SVC: audience ✓ timestamp ✓ kid current ✓
signature ✓ nonce unused ✓ + Note over SVC: 2. Authorize + SVC->>R: resolve each issuer (cached) + SVC->>SVC: each link signed by issuer's current key ✓
chain unbroken ✓ only narrows ✓
leaf.sub == S ✓ root principal trusted ✓ + SVC->>R: revocation check (cached) + SVC->>SVC: scope covers "payments.buy" ✓ + Note over SVC: 3. Enforce limits + SVC->>SVC: resource & per-call spend on every link ✓
ledger: charge budget/rate/uses on every link (all-or-nothing) + SVC-->>S: 200 OK (or 401 / 402 / 403 / 429) +``` + +**Status codes:** + +| Code | Meaning | +|---|---| +| **401** | Authentication failed: bad signature, stale timestamp, replayed nonce, wrong audience, unknown or deactivated agent, rotated key, or no grant presented | +| **403** | Authorization failed: grant invalid, untrusted principal, revoked, scope not covered, resource not covered, per-call spend exceeded, or uses exhausted | +| **402** | Budget exhausted | +| **429** | Rate limit reached (the message says when to retry) | +| **400** | Malformed input, or a requested resource containing `..` | + +## How authority flows + +```mermaid +flowchart TD + P["Principal: arunima
trusted by service for tools.*, payments.*"] + G1["Grant 1 → researcher
scope: payments.*
budget 5000¢, depth 1"] + G2["Grant 2 → helper
scope: payments.buy
budget 3000¢, depth 0"] + G3["Grant 3 → shopper
scope: payments.buy
budget 3000¢, depth 0"] + P --> G1 --> G2 + G1 --> G3 + G2 -. "spends count against G2 and G1" .-> L[(Usage ledger)] + G3 -. "spends count against G3 and G1" .-> L +``` + +Every call is charged against **every** grant in its chain. Grants 2 and 3 each allow 30 USD, but together they can't exceed Grant 1's 50 USD. Revoking Grant 1 (or deactivating `researcher`) cuts off both sub-agents at once. + +## Caching and freshness + +Services cache lookups so they don't hit the registry on every request. The cache TTLs set how quickly a change takes effect: + +| Cache | Default | What a stale entry means | +|---|---|---| +| `RegistryKeyResolver` (keys and active status) | 30 s | A deactivated agent keeps working for up to 30 s. A **rotation** is picked up immediately, because a newer `kid` forces a refresh. | +| `RegistryRevocationChecker` ("not revoked" answers) | 10 s (5 s in the example service) | A revoked grant keeps working for up to that long. "Revoked" answers are cached permanently. | +| Nonce cache | 2 × max clock skew (240 s) | In-memory and per process (see [operations](operations.md)). | + +Set any TTL to `0` for immediate effect, at the cost of a registry round-trip per request. From 4558d664f5edb8f4ac74247fdc1051eeb277eabf Mon Sep 17 00:00:00 2001 From: Arunima Chaudhuri Date: Tue, 29 Sep 2026 00:24:45 +0530 Subject: [PATCH 04/10] docs: add protocol specification Wire formats and verification rules for identities, AgentSig, AgentGrant, limits, revocation and the registry API. --- docs/protocol.md | 407 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 docs/protocol.md diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..4fe465a --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,407 @@ +# Protocol specification (v0.3) + +This document describes AgentAuth's wire formats and verification rules precisely enough to write a compatible client or verifier in another language. The Python package is the reference implementation. If this document and the code disagree, the code wins, and the disagreement should be fixed here. + +The key words MUST, MUST NOT, SHOULD and MAY are used as in RFC 2119. + +- [1. Primitives](#1-primitives) +- [2. Identifiers and key event logs](#2-identifiers-and-key-event-logs) +- [3. Request authentication (`AgentSig`)](#3-request-authentication-agentsig) +- [4. Capability grants (`AgentGrant`)](#4-capability-grants-agentgrant) +- [5. Limits](#5-limits) +- [6. Revocation](#6-revocation) +- [7. Registry HTTP API](#7-registry-http-api) +- [8. Version strings and constants](#8-version-strings-and-constants) + +--- + +## 1. Primitives + +### 1.1 Signatures + +- **Algorithm:** Ed25519 (RFC 8032). +- **Encoding:** the 64-byte signature, base64url **without padding** (`b64u`). + +### 1.2 Public keys (multikey) + +A public key is encoded as a multibase base58btc string of the multicodec `ed25519-pub` prefix (`0xed 0x01`) followed by the 32 raw key bytes: + +``` +multikey = "z" + base58btc(0xED 0x01 || pubkey[32]) → starts with "z6Mk" +``` + +This is the same encoding `did:key` uses. + +### 1.3 Digests + +A digest is a self-describing SHA-256 multihash, multibase-encoded: + +``` +digest(x) = "z" + base58btc(0x12 0x20 || SHA-256(x)) +``` + +### 1.4 Canonical JSON + +Everything that gets signed or hashed is serialized as canonical JSON: + +- object keys sorted lexicographically (by code point); +- no insignificant whitespace (separators `,` and `:`); +- UTF-8, with non-ASCII characters emitted as-is (not `\u` escaped); +- **floating-point numbers MUST NOT appear**. Only strings, integers, booleans, `null`, arrays and objects are allowed. + +For the payloads AgentAuth signs (ASCII object keys, integers within ±2⁵³), this produces the same bytes as RFC 8785 (JCS). Implementations SHOULD NOT rely on equivalence outside that range: JCS sorts keys by UTF-16 code units and treats all numbers as IEEE doubles. + +### 1.5 Base58btc alphabet + +`123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz`. Leading zero bytes are encoded as leading `1`s. + +--- + +## 2. Identifiers and key event logs + +### 2.1 Events + +An identity is an ordered list of **events**. Each event is an envelope: + +```json +{ + "body": { ... }, + "signer": "did:agent:…", + "sig": "", + "controller_proof": { "key": "", "sig": "" } +} +``` + +`controller_proof` appears only on inception events that name a controller. + +The **event digest** is `digest(canonical_json(body))`. + +### 2.2 Inception + +```json +{ + "v": "agentauth/1", + "type": "inception", + "seq": 0, + "prev": null, + "key": "", + "next": "", + "controller": null | "did:agent:…", + "meta": { }, + "ts": "2026-09-26T09:30:00Z", + "did": "did:agent:…" +} +``` + +- **Key commitment:** `commitment(k) = digest(utf8(k))`, where `k` is the next key's multikey *string*. +- **DID derivation:** take the body **without** the `did` field, compute `d = digest(canonical_json(body_without_did))`, and drop the leading `z`: + + ``` + did = "did:agent:" + d[1:] → e.g. did:agent:QmfJZ… + ``` + +A verifier MUST check that: + +1. `v == "agentauth/1"`, `type == "inception"`, `seq == 0` (an integer, not a boolean), `prev == null`, `ts` is a string. +2. `key` is a valid Ed25519 multikey. +3. `next` is a string. Pre-rotation is mandatory. +4. `did` equals the derivation above. +5. `signer == did`, and `sig` verifies against `key`. +6. `controller` is `null`, or an agent DID different from `did`. +7. `meta` is an object. +8. If `controller` is set: `controller_proof.sig` MUST verify over `canonical_json(body)` with `controller_proof.key`. A registry MUST additionally check that `controller_proof.key` is the controller's **current** key. + +### 2.3 Rotation + +```json +{ + "v": "agentauth/1", "type": "rotation", "did": "…", "seq": n, "prev": "", + "key": "", "next": "", "ts": "…" +} +``` + +On top of the common checks (§2.5), a verifier MUST check that: + +- `signer == did`; +- `commitment(key) == state.next`, meaning the revealed key matches the previous commitment; +- `next` is a string, and `next != commitment(key)`; +- `sig` verifies against the **new** `key`. + +### 2.4 Deactivation + +```json +{ + "v": "agentauth/1", "type": "deactivation", "did": "…", "seq": n, "prev": "…", + "key": "", "next": null, "ts": "…" +} +``` + +A deactivation is valid if either of these holds: + +- **Self-deactivation:** `signer == did`, and `commitment(key) == state.next` (signed with the pre-committed recovery key); or +- **Controller deactivation:** `signer == state.controller`, and `key` is the controller's current key. A registry checks this against live state. An auditor MAY accept any key the controller has ever held. + +In both cases `sig` MUST verify against `key` and `next` MUST be `null`. After deactivation the identity has no key, and no further events are accepted. + +### 2.5 Checks common to every non-inception event + +- The identity MUST be active. +- `body.did` MUST equal the identity's DID. +- `seq` MUST be exactly `state.seq + 1`. +- `prev` MUST equal the digest of the previous event. + +### 2.6 Derived state and key IDs + +Replaying the log gives the identity's state: `{did, seq, key, next, controller, meta, active, created, updated, last_digest, key_seq}`. The **key ID** of the current key is: + +``` +kid = did + "#key-" + key_seq (key_seq = seq of the event that established the key) +``` + +### 2.7 DID document + +The registry serves a W3C DID resolution result: + +```json +{ + "didDocument": { + "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1"], + "id": "did:agent:…", + "controller": ["did:agent:…", ""], + "verificationMethod": [{ "id": "", "type": "Multikey", "controller": "", "publicKeyMultibase": "" }], + "authentication": [""], + "assertionMethod": [""], + "agent": { "...meta..." } + }, + "didDocumentMetadata": { + "created": "…", "updated": "…", "deactivated": false, "versionId": "", "nextKeyCommitment": "…" + } +} +``` + +A deactivated identity has empty `verificationMethod`, `authentication` and `assertionMethod` lists. + +--- + +## 3. Request authentication (`AgentSig`) + +### 3.1 Header + +``` +Authorization: AgentSig did="",kid="",aud="",ts="",nonce="",sig="" +``` + +Parameter values MUST NOT contain `"`. The nonce SHOULD have at least 128 bits of entropy (the reference client uses 16 random bytes, base64url-encoded). + +### 3.2 Signing string + +``` +agentauth-request/1 \n + \n + \n + \n + \n + \n + \n + +``` + +The lines are joined with `\n`, with no trailing newline, and encoded as UTF-8. `aud` is the `host[:port]` the client is sending to, as written in the URL's authority. An empty body is hashed as the empty byte string. + +### 3.3 Verification + +A verifier configured with a set of accepted audiences MUST, in this order: + +1. Parse the header. All six parameters are required. +2. Reject if `aud` (lower-cased) is not one of its audiences. +3. Reject if `|now − ts| > max_skew`. The default is 120 s. +4. Resolve `did` to its current `(key, kid)`. Reject if unknown or deactivated. If the presented `kid` has a **higher** key sequence than the cached one, the verifier SHOULD refresh its cache once before rejecting. +5. Reject if the presented `kid` isn't the current `kid`. +6. Verify `sig` over the signing string, using the verifier's own view of method, path, body and `AgentGrant` header. +7. Reject if `(did, nonce)` has been seen within the replay window (at least 2 × `max_skew`). Record the nonce **only after** the signature verifies. + +--- + +## 4. Capability grants (`AgentGrant`) + +### 4.1 Grant body + +```json +{ + "v": "agentauth-grant/1", + "id": "", + "iss": "", + "kid": "", + "sub": "", + "scope": ["tools.search"], + "aud": ["tools.example.com"], + "nbf": 1790431690, + "exp": 1790432590, + "depth": 0, + "parent": null | "", + "limits": { } +} +``` + +- `scope` and `aud` are sorted and de-duplicated, and `aud` is lower-cased. +- `limits` is omitted when there are no limits (see §5). + +### 4.2 Encoding + +``` +grant = b64u(canonical_json(body)) + "." + b64u(signature over canonical_json(body)) +header = grant_root "," grant_1 "," … "," grant_leaf (at most 8 links) +``` + +The header name is `AgentGrant`. + +### 4.3 Scopes + +- A scope is a non-empty string of at most 128 characters with no whitespace. +- `*` may only appear as the whole scope (`"*"`) or as a trailing `.*` (`"tools.*"`). +- `covers(p, c)` holds when `p == "*"`, or `p == c`, or `p` ends in `.*` and `c` starts with `p` minus the `*` and `c != that prefix`. So `tools.*` covers `tools.search` and `tools.*`, but not `tools`. +- A set of scopes P covers a set C if every `c` in C is covered by some `p` in P. + +### 4.4 Audiences + +A set of audiences P covers C if `"*"` is in P, or every element of C is in P (compared case-insensitively). + +### 4.5 Verification + +Given the `AgentGrant` header, the authenticated requester DID `R`, and the request audience `A`, a verifier MUST: + +For each link `g[i]`, root first: + +1. Check `v`, and that `id`, `iss`, `kid`, `sub` are strings, `scope` and `aud` are lists, and `nbf`, `exp`, `depth` are integers (not booleans). +2. Check that every scope is valid (§4.3). +3. Check `nbf − 60 ≤ now < exp + 60`, and `exp − nbf ≤ 86400`. +4. Check that `aud` covers `A`. +5. Parse `limits` (§5). Unknown limit fields make the grant invalid. +6. If `i == 0`: `parent` MUST be `null`. + Otherwise, with `p = g[i−1]`: + - `g.parent == p.id` and `g.iss == p.sub`; + - `p.depth ≥ 1` and `g.depth ≤ p.depth − 1`; + - `p.scope` covers `g.scope`, and `p.aud` covers `g.aud`; + - `g.nbf ≥ p.nbf` and `g.exp ≤ p.exp`; + - `p.limits` is only narrowed by `g.limits` (§5.2). +7. Resolve `g.iss` to its current `(key, kid)`. Reject if the issuer is unknown or deactivated, or if `g.kid` isn't the issuer's **current** kid (the grant must be re-issued after the issuer rotates). Then verify the signature. + +Then: + +8. The leaf's `sub` MUST equal `R`. +9. Apply the service's **root policy** to `(root.iss, A)`. It returns the scopes that principal may authorize at this service, or nothing if the principal isn't trusted. Reject if nothing is returned, or if the returned scopes don't cover `root.scope`. +10. Reject if any `(g.iss, g.id)` in the chain has been revoked (§6). + +The effective scopes are the **leaf's** scopes. The endpoint's required scopes MUST be covered by them. + +--- + +## 5. Limits + +### 5.1 Schema + +```json +"limits": { + "resources": ["/reports/*", "repo:acme/website"], + "spend": { "unit": "USD-cents", "per_call": 2000, "total": 5000 }, + "rate": { "count": 10, "per": 60 }, + "uses": 100 +} +``` + +| Field | Rules | +|---|---| +| `resources` | 1–32 patterns of at most 256 characters, with no whitespace or control characters. A `*` may appear only as the last character, meaning prefix match. | +| `spend.unit` | Non-empty string. Required if `spend` is present. | +| `spend.per_call`, `spend.total` | Positive integers. At least one is required. | +| `rate.count`, `rate.per` | Positive integers. `per` is at most 86400 seconds. | +| `uses` | Positive integer | + +Any other top-level field makes the grant **invalid**, so a limit a verifier doesn't understand is never silently ignored. + +### 5.2 Narrowing (child vs parent) + +For each limit the parent has, the child MUST also have it and MUST be at least as strict: + +- **resources:** every child pattern is covered by some parent pattern. A parent pattern `x*` covers a child pattern `c` if `c` starts with `x`. An exact parent pattern covers only itself. +- **spend:** same unit; `child.per_call ≤ parent.per_call`; `child.total ≤ parent.total`. +- **rate:** `child.count × parent.per ≤ parent.count × child.per`, meaning the child's rate is no faster. +- **uses:** `child.uses ≤ parent.uses`. + +Issuers SHOULD copy any limit the child leaves unset from the parent, so every grant is self-describing. + +### 5.3 Enforcement per call + +A service declares, per endpoint, the `resource` the call touches (optional), the `amount` it costs (a non-negative integer, default 0) and the `unit` of that amount. For **every link** in the chain: + +- If the link has `resources`: + - the endpoint MUST declare a resource, otherwise reject (fail closed); + - reject resources that contain `*`, control characters, or a `.` or `..` path segment; + - the resource MUST match one of the link's patterns. +- If the link has `spend` and `amount > 0`: + - `unit` MUST equal `spend.unit`; + - `amount ≤ per_call` and `amount ≤ total`. + +### 5.4 Stateful counters + +`spend.total`, `rate` and `uses` are tracked per `(iss, id)`. For each call, the verifier atomically, across **all** links that carry any of them: + +- checks that spent + `amount` ≤ `total`, that uses + 1 ≤ `uses`, and that fewer than `rate.count` calls fall inside the last `rate.per` seconds; +- if every link passes, records the call on every link. If any fails, it records nothing. + +Status codes: budget exhausted → 402; uses exhausted → 403; rate limit reached → 429. Refunds MAY return spend (not uses or rate) to every link. + +--- + +## 6. Revocation + +```json +{ + "body": { "v": "agentauth-revocation/1", "grant": "", "iss": "", "ts": 1790431700 }, + "sig": "" +} +``` + +- Revocations are keyed by `(iss, grant)`. A revocation signed by X only ever revokes grants **issued by X**, so it can't touch anyone else's grants. +- Revoking a grant invalidates every chain that includes it, which means every delegation beneath it. +- Revocation is permanent. + +--- + +## 7. Registry HTTP API + +All bodies are JSON. Errors are `{"detail": ""}`. + +| Method & path | Body | Success | Errors | +|---|---|---|---| +| `POST /v1/identities` | Inception event (§2.2) | `201` resolution result | `400` invalid, or controller not active; `409` already registered | +| `POST /v1/identities/{did}/events` | Rotation or deactivation event | `200` resolution result | `400` invalid; `404` unknown DID; `409` concurrent update | +| `GET /v1/identities/{did}` | — | `200` resolution result (`Cache-Control: max-age=30`) | `404` | +| `GET /v1/identities/{did}/log` | — | `200` `{"did", "events": [ … ]}` | `404` | +| `GET /v1/identities/{did}/agents` | — | `200` `{"controller", "agents": [{"did","active"}]}` | — | +| `POST /v1/revocations` | Revocation (§6) | `201` `{"issuer","grant","revoked":true,"new":bool}` | `400` invalid or issuer inactive | +| `POST /v1/revocations/check` | `{"grants": [[iss, id], …]}` (max 32) | `200` `{"revoked": [[iss, id], …]}` | `400` | +| `GET /v1/whoami` | — (requires `AgentSig`) | `200` `{"did","kid"}` | `401` | +| `GET /healthz` | — | `200` `{"ok": true}` | — | + +Event bodies larger than 16 KiB are rejected with `413`. + +--- + +## 8. Version strings and constants + +| Name | Value | +|---|---| +| Identity event version | `agentauth/1` | +| DID method prefix | `did:agent:` | +| Request signing string tag | `agentauth-request/1` | +| Grant version | `agentauth-grant/1` | +| Revocation version | `agentauth-revocation/1` | +| Authorization scheme | `AgentSig` | +| Grant header | `AgentGrant` | +| Request clock skew | 120 s | +| Nonce replay window | 240 s | +| Grant clock skew | 60 s | +| Default grant TTL | 900 s | +| Maximum grant TTL | 86400 s | +| Maximum chain length | 8 | From ac6a2aeeac69553978b830ad44e4c65b26239c10 Mon Sep 17 00:00:00 2001 From: Arunima Chaudhuri Date: Tue, 29 Sep 2026 00:24:46 +0530 Subject: [PATCH 05/10] docs: add integration guide for agents and services --- docs/integration-guide.md | 243 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 docs/integration-guide.md diff --git a/docs/integration-guide.md b/docs/integration-guide.md new file mode 100644 index 0000000..e05f33f --- /dev/null +++ b/docs/integration-guide.md @@ -0,0 +1,243 @@ +# Integration guide + +There are two sides to integrating AgentAuth: + +- **[Agent side](#agent-side):** you're building an agent that calls services. +- **[Service side](#service-side):** you run an API that agents call. + +Both use the same package: + +```bash +pip install "agentauth[server]" # [server] adds FastAPI + uvicorn +``` + +--- + +## Agent side + +### 1. Create and register an identity + +```python +from agentauth import AgentIdentity, RegistryClient + +reg = RegistryClient("https://registry.example.com") + +me, inception = AgentIdentity.create(name="researcher", meta={"model": "claude", "team": "growth"}) +reg.register(inception) + +me.save("~/.agentauth", "researcher", recovery_dir="/secure/vault") +print(me.did) # did:agent:Qm… +``` + +- `save` writes two files: `researcher.key.json` (the current key, which the running agent needs) and `researcher.recovery.json` (the pre-committed next key). **Put the recovery file somewhere the agent runtime can't read.** See [operations → key management](operations.md#key-management). +- Set `AGENTAUTH_PASSPHRASE` (or pass `passphrase=`) to encrypt both files. + +**With an owner.** If a human or org identity should own the agent, and be able to switch it off, pass it as the controller. The controller's key countersigns the inception: + +```python +owner = AgentIdentity.load("~/.agentauth", "arunima") +me, inception = AgentIdentity.create(name="researcher", controller=owner) +``` + +### 2. Load it at runtime + +```python +me = AgentIdentity.load("~/.agentauth", "researcher") # no recovery_dir: enough to sign, not to rotate +``` + +### 3. Call a service + +```python +import httpx + +httpx.post("https://api.example.com/search", json={"q": "…"}, auth=me.httpx_auth()) +``` + +`httpx_auth()` works with both `httpx` and `httpx2` clients. It signs the method, URL, body, timestamp and a fresh nonce on every request. + +Any other HTTP client works too. Compute the header yourself and send it: + +```python +headers = {"Authorization": me.authorization("POST", url, body_bytes)} +``` + +### 4. Act on someone's behalf (grants) + +Services that need to know *who authorized* an agent require a **grant chain**. Someone the service trusts (a principal) issues one: + +```python +chain = owner.grant( + me.did, + scopes=["tools.search", "files.read"], + audiences=["api.example.com"], + ttl=900, # seconds; max 86400 +) +``` + +The agent sends the chain with each request: + +```python +httpx.post("https://api.example.com/search", json={"q": "…"}, auth=me.httpx_auth(chain)) +``` + +Grants are just strings (`chain.encode()`). Pass them to the agent however you pass it configuration, and treat them as sensitive. They're useless without the subject's private key, but there's no reason to leak them. + +### 5. Delegate to a sub-agent + +If a grant allows it (`depth ≥ 1`), the agent can hand a **narrower** slice to another agent: + +```python +root = owner.grant(me.did, ["tools.*"], ["api.example.com"], depth=1) +child = me.grant(helper.did, ["tools.search"], ["api.example.com"], parent=root) +# the helper then calls with helper.httpx_auth(child) +``` + +The SDK refuses anything that would widen authority: scopes, audiences, lifetime, depth or limits. Any service verifying the chain would reject it anyway. + +### 6. Add limits + +```python +from agentauth import Limits + +pay = owner.grant(me.did, ["payments.buy"], ["api.example.com"], depth=1, + limits=Limits.build(per_call=2000, total=5000, rate=(10, 60))) # cents; 10 calls/min +files = owner.grant(me.did, ["files.read"], ["api.example.com"], + limits=Limits.build(resources=["/reports/*"])) +``` + +Limits apply to **every scope in the grant**. If the agent needs a budget for one capability and a resource restriction for another, issue two grants, as above, and send whichever one the call needs. + +### 7. Rotate, revoke, deactivate + +```python +admin = AgentIdentity.load("~/.agentauth", "researcher", recovery_dir="/secure/vault") +reg.rotate(admin); admin.save("~/.agentauth", "researcher", recovery_dir="/secure/vault") + +reg.revoke_grant(owner, root.leaf.id) # kills root and everything delegated from it +reg.deactivate_controlled(owner, me.did) # owner's kill switch +reg.deactivate_self(admin) # agent retires itself (needs the recovery key) +``` + +After a rotation, grants the rotated identity *issued* must be re-issued. Grants it *received* are unaffected. + +--- + +## Service side + +### 1. Identity only: "which agent is this?" + +```python +from fastapi import Depends, FastAPI +from agentauth import RegistryClient, RegistryKeyResolver, RequestVerifier +from agentauth.integrations import fastapi_dependency + +reg = RegistryClient("https://registry.example.com") +keys = RegistryKeyResolver(reg, ttl=30) +require_agent = fastapi_dependency(RequestVerifier(keys, audiences={"api.example.com"})) + +app = FastAPI() + +@app.get("/me") +def me(agent = Depends(require_agent)): + return {"did": agent.did} +``` + +- **`audiences`** must contain the `host[:port]` that clients put in the URL. List every public hostname the service answers to. +- Use this when all you need is a stable, unforgeable caller ID, for example for allow-lists or per-agent quotas you manage yourself. + +### 2. Scoped authority: "who allowed this, and to do what?" + +```python +from agentauth import GrantVerifier, RegistryRevocationChecker, trust_principals +from agentauth.integrations import fastapi_authorizer + +require = fastapi_authorizer( + RequestVerifier(keys, audiences={"api.example.com"}), + GrantVerifier( + keys, + root_policy=trust_principals({ + ORG_DID: ["tools.*", "files.*", "payments.*"], + CONTRACTOR: ["tools.search"], + }), + revoked=RegistryRevocationChecker(reg, ttl=5), + ), +) + +@app.post("/search") +def search(body: dict, agent = Depends(require("tools.search"))): + return {"caller": agent.did, "on_behalf_of": agent.principal, "chain": agent.via} +``` + +The **root policy** is where your service decides whose authority counts. `trust_principals` takes a static dict. For anything dynamic, such as principals stored in your user database, pass any callable `(principal_did, audience) → scopes or None`: + +```python +def root_policy(did, audience): + user = db.users.find_one(agentauth_did=did) + return user.allowed_scopes if user and user.active else None +``` + +**Choosing scope names.** Use `resource.action`, with hierarchy where it helps: `files.read`, `files.write`, `payments.buy`, `tools.web.fetch`. Principals can then grant `files.*` or just `files.read`. + +### 3. Resource and spend limits + +Endpoints declare what a call touches and what it costs. The authorizer enforces every link's limits before your handler runs: + +```python +@app.get("/files") +def read_file(path: str, + agent = Depends(require("files.read", resource=lambda r: r.query_params["path"]))): + ... + +async def price(request): + return int((await request.json())["amount_cents"]) + +@app.post("/purchase") +def purchase(body: dict, agent = Depends(require("payments.buy", amount=price, unit="USD-cents"))): + if not charge_card(body): + agent.refund() # returns the spend to every budget in the chain + raise HTTPException(502, "payment failed") + return {"ok": True, "charged": agent.charged} +``` + +Rules of thumb: + +- **Declare a `resource` on every endpoint that touches named things.** A grant with resource limits is refused at endpoints that don't declare one. That's intentional (fail closed). +- **Declare an `amount` on every endpoint that spends.** Spend limits can only bind endpoints that report a cost. +- **Use a consistent unit string** (`"USD-cents"`, `"tokens"`, `"credits"`). A grant in one unit is refused at an endpoint that charges in another. +- **Normalize resource names** the same way principals write patterns. For example, strip trailing slashes and use absolute paths. `..` and `.` segments are rejected for you. + +### 4. Not using FastAPI? + +The verifiers are framework-agnostic: + +```python +agent = request_verifier.verify(authorization=hdr, method=m, path=path_with_query, body=raw_body, grants=grant_hdr) +authz = grant_verifier.verify(grant_hdr, requester=agent.did, audience=agent.audience) +if not authz.allows("files.read"): deny() +grant_verifier.enforce(authz, resource="/reports/q3.pdf", amount=0) +``` + +Catch `AuthError` (map it to 401) and `GrantError` (use its `.status`). + +### 5. Tuning freshness + +| Setting | Lower it for… | Cost | +|---|---|---| +| `RegistryKeyResolver(ttl=…)` | faster effect of deactivation | more registry calls | +| `RegistryRevocationChecker(ttl=…)` | faster effect of revocation | more registry calls | +| `RegistryKeyResolver(verify_logs=False)` | lower latency | trusts the registry's DID document instead of replaying the log | + +For payments or other irreversible actions, consider `ttl=0` on both. + +### 6. Testing + +Use FastAPI's `TestClient` with an in-process registry. `tests/test_grants.py` in this repo is a complete example: + +```python +from fastapi.testclient import TestClient +from agentauth.server import create_app + +reg = RegistryClient("http://testserver", client=TestClient(create_app(db_path=":memory:", audiences={"testserver"}))) +``` + +`python examples/tool_service.py --trust ` runs a demo service with scoped, resource-limited and spend-limited endpoints you can call from the CLI. From 05bfddfc4addb1471b1a98bae73101637af3294a Mon Sep 17 00:00:00 2001 From: Arunima Chaudhuri Date: Tue, 29 Sep 2026 00:24:46 +0530 Subject: [PATCH 06/10] docs: add threat model Assets, trust assumptions, adversaries and mitigations, and known gaps in v0.3. --- docs/threat-model.md | 120 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 docs/threat-model.md diff --git a/docs/threat-model.md b/docs/threat-model.md new file mode 100644 index 0000000..4d82e70 --- /dev/null +++ b/docs/threat-model.md @@ -0,0 +1,120 @@ +# Threat model + +This document covers what AgentAuth protects, whom it protects against, and where the gaps are as of v0.3. Report security issues privately to the maintainers, not in public issues. + +## Assets + +| Asset | Why it matters | +|---|---| +| Agent private keys (current and recovery) | Whoever holds them *is* the agent | +| Principal (human/org) keys | They are the root of every grant chain | +| Grants | They describe what an agent may do, and on whose behalf | +| Key event logs | They decide which key is current for each DID | +| Revocation records | Revoked authority must stay dead | +| Usage ledger | It enforces budgets, rate limits and use counts | + +## Trust assumptions + +- **Ed25519 and SHA-256 are secure**, and keys are generated with a good randomness source (the `cryptography` library, backed by the OS). +- **Relying services are honest about themselves.** They configure their own audiences and root policy correctly, and declare resources and costs truthfully. +- **Clocks are roughly synchronized**, within 60–120 seconds. +- **The registry is available**, and honest about *what it has*, but it isn't trusted to forge anything. (Replaying logs protects against forgery. It doesn't protect against the registry withholding events or showing different clients different logs; see below.) + +## Adversaries and mitigations + +### Network attacker (sees, replays or modifies traffic) + +| Attack | Mitigation | +|---|---| +| Replay a captured request against the same service | Single-use nonce per DID within the replay window, plus a ±120 s timestamp window | +| Replay it against a different service | The signature covers the audience (`host[:port]`), and each service only accepts its own | +| Replay it against a different endpoint, or with a changed body | The signature covers method, path, query and a digest of the body | +| Swap or strip the `AgentGrant` header | The signature covers a digest of the grant header | +| Steal credentials in transit | There are none to steal. Requests carry signatures, never secrets. (Use TLS anyway: request bodies may be sensitive.) | + +### A stolen grant + +A grant names its subject. Using it requires signing the request with the **subject's** key, so a stolen grant by itself is useless. + +### Theft of an agent's current key + +**Impact:** the attacker can act as the agent until the key is rotated or the agent is deactivated. + +- **Can't take over the identity.** Rotation requires the pre-committed recovery key. +- **Recovery:** the owner rotates (with the recovery key) or deactivates (with the recovery key or the controller's key). Services pick up the change within their cache TTL. A rotation is picked up immediately if the legitimate agent then presents the newer `kid`. +- **Damage is capped** by whatever limits its grants carry: scopes, audiences, TTL, resources, budget, rate and uses. + +### Theft of both current and recovery keys + +**Impact:** full control of the identity, including rotating it to keys only the attacker knows. + +- **Mitigation today:** keep the recovery key off the agent's host (separate machine, vault, KMS or HSM). If the agent has a controller, the controller can still deactivate it. +- **Gap:** without a controller, there's no recovery. Consider making controllers mandatory for high-value agents. + +### A malicious or compromised agent in a delegation chain + +| Attack | Mitigation | +|---|---| +| Delegate wider scopes, audiences, lifetime or depth than it holds | Checked at issue time by the SDK, and again independently by every verifier | +| Drop or loosen a parent's limits | Every link's limits are enforced, and narrowing is checked on every link | +| Split a budget among many sub-agents to multiply it | Counters are charged against **every** link in the chain, so a parent's budget, rate and uses are shared by everything beneath it | +| Keep acting after its authority is withdrawn | Revoking any upstream grant, or deactivating any upstream identity, invalidates the chain | +| Revoke grants it didn't issue | Revocations are keyed by *(issuer, grant id)* and signed by the issuer | + +### A principal a service doesn't trust + +Grants rooted in anyone other than the principals in the service's root policy are rejected. A trusted principal can't grant more than the policy allows it at that service. + +### Resource-name tricks + +A requested resource containing a `.` or `..` path segment, a `*`, or control characters is rejected before matching, so `/reports/../hr/salaries.csv` can't slip past a `/reports/*` limit. Services should still canonicalize resource names consistently. See the [integration guide](integration-guide.md#3-resource-and-spend-limits). + +### A malicious registry + +| Attack | Mitigation | +|---|---| +| Invent an identity, or reassign a DID to other keys | DIDs are self-certifying. Services replay the full log (`RegistryKeyResolver(verify_logs=True)`, the default). | +| Forge a rotation or deactivation | Every event must be signed, chained, and satisfy pre-rotation | +| Squat on a controller relationship | The controller must countersign the inception with its current key | +| Forge a revocation | Revocations must be signed by the grant's issuer. Services currently trust the registry's answer to a revocation check. See the gaps below. | + +## Known gaps (v0.3) + +### Registry equivocation + +A malicious registry could **withhold** events (for example, hide a deactivation so a compromised agent keeps working) or show **different logs to different clients**. It could also falsely claim a grant is revoked (denial of service), or falsely claim it isn't. + +*Planned:* multiple independent witnesses that countersign events, clients that remember the logs they've seen so they can spot conflicting versions, and signed revocation proofs that services verify themselves. + +### Revocation and deactivation aren't instant + +Services cache for performance. By default a deactivation takes effect within 30 s, and a grant revocation within 5–10 s. Set the TTLs to 0 for sensitive endpoints. + +### State is in memory and per process + +The **nonce cache** and **usage ledger** live in memory, which has two consequences: + +- **Across replicas:** a request could be replayed against another replica within the timestamp window, and budgets, rates and uses are counted per replica, so N replicas allow up to N× the limit. +- **On restart:** counters reset. + +*Mitigation:* back both with Redis or your database before running more than one replica. See [operations](operations.md#scaling). + +### Services must declare costs honestly + +Spend limits only bind endpoints that report an `amount`. A service that forgets to (or lies) isn't limited. This is inherent: AgentAuth can't observe what a call really costs. + +### Grants are tied to the issuer's current key + +After an issuer rotates, its outstanding grants stop verifying and have to be re-issued. This is safe, since a compromised old key can't keep issuing valid grants, but it's operationally noisy for long-lived grants. Keep grants short. + +### No registration rate limiting + +Anyone can register identities. A registry exposed to the internet should sit behind rate limiting or authentication. + +### Timing and side channels + +Signature checks use the `cryptography` library. Other comparisons (nonces, kids, digests) aren't constant-time. None of them are secret values, but this hasn't been reviewed. + +### Not independently audited + +The design and code haven't had an external security review. Don't rely on AgentAuth to protect high-value assets until they have. From cbc3a665e5ab75f5dd6da7b854c3eaab7c1bf186 Mon Sep 17 00:00:00 2001 From: Arunima Chaudhuri Date: Tue, 29 Sep 2026 00:24:46 +0530 Subject: [PATCH 07/10] docs: add operations guide Running the registry, configuration, backups, key custody, rotation, incident response and scaling. --- docs/operations.md | 118 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/operations.md diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..6314ec7 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,118 @@ +# Operations + +How to run the registry and manage keys. AgentAuth v0.3 is suitable for development, internal pilots and single-instance deployments. Read [Scaling](#scaling) before running more than one replica. + +## Running the registry + +### With the CLI + +```bash +pip install "agentauth[server]" +agentauth serve --host 0.0.0.0 --port 8000 --db /var/lib/agentauth/agentauth.db \ + --audience registry.example.com +``` + +`--audience` (repeatable) lists the `host[:port]` values clients use to reach the registry. The CLI always adds `:`, `localhost:` and `127.0.0.1:`. Behind a proxy on the standard HTTPS port, clients sign for `registry.example.com` with no port, so list exactly that. + +### With uvicorn (or any ASGI server) + +```bash +AGENTAUTH_DB=/var/lib/agentauth/agentauth.db \ +AGENTAUTH_AUDIENCES=registry.example.com \ +uvicorn agentauth.server:get_app --factory --host 0.0.0.0 --port 8000 +``` + +### Configuration + +| Variable | Used by | Default | Meaning | +|---|---|---|---| +| `AGENTAUTH_DB` | registry (`create_app`) | `agentauth.db` | SQLite database path | +| `AGENTAUTH_AUDIENCES` | registry (`create_app`, not `agentauth serve`) | `localhost:8000,127.0.0.1:8000` | Comma-separated audiences for authenticated endpoints | +| `AGENTAUTH_REGISTRY` | CLI | `http://127.0.0.1:8000` | Registry URL | +| `AGENTAUTH_HOME` | CLI | `~/.agentauth` | Where key files live | +| `AGENTAUTH_PASSPHRASE` | SDK and CLI | unset | Encrypts key files when saving and decrypts them when loading | + +### Behind a reverse proxy + +- **Terminate TLS** at the proxy. AgentAuth doesn't need TLS for its own security, but request bodies and grants may be sensitive. +- **Preserve the path and query exactly.** They're covered by request signatures, so a rewritten path breaks verification. +- **Audience = the public `host[:port]`** clients use, not the internal upstream address. +- **Rate-limit `POST /v1/identities`.** Registration is open by design. +- **Limit body sizes.** The registry rejects events over 16 KiB, but it's cheaper to stop them at the proxy. + +### Health and monitoring + +- `GET /healthz` returns `{"ok": true}`. +- Track the rates of 401/403/402/429 responses on relying services. Spikes in 401 "replayed" or "signature" errors can mean an attack, or a proxy mangling requests. + +## Data and backups + +The registry's SQLite database has three tables: + +| Table | Contents | Loss impact | +|---|---|---| +| `events` | Every signed event, the source of truth | **Critical.** Identities become unresolvable. The events are self-verifying, so they can be re-imported from any copy. | +| `identities` | State derived from `events`, plus a controller index | Can be rebuilt by replaying `events` | +| `revocations` | Signed grant revocations | **Critical.** Revoked grants would come back to life. | + +The database uses WAL mode. Back it up with `sqlite3 agentauth.db ".backup /backups/agentauth-$(date +%F).db"` (safe while running), and test restores. + +## Key management + +### For agents + +| Key | Lives where | Needed for | +|---|---|---| +| Current key (`.key.json`) | On the agent's host, readable only by the agent process (the file is written with mode 0600) | Signing every request, issuing grants | +| Recovery key (`.recovery.json`) | **Somewhere else**: an operator's machine, a vault, or a KMS/HSM-backed store | Rotation and self-deactivation | + +- **Encrypt at rest** with `AGENTAUTH_PASSPHRASE`, supplied from your secrets manager, not hard-coded. +- **Keep the recovery key off the agent host.** This is what makes pre-rotation meaningful: if both keys sit in one directory, stealing that directory steals the whole identity. +- **Give important agents a controller.** It's your kill switch if both keys are lost. + +### For principals (humans and orgs) + +A principal's key sits at the root of many grant chains, so protect it more carefully than any agent's: + +- Keep it on a workstation or in a vault, never on agent infrastructure. +- Issue short-lived grants (minutes to hours), and re-issue them rather than extending them. +- Rotating a principal invalidates every grant it issued. Plan rotations, or treat them as an emergency "revoke everything". + +### Routine rotation + +```bash +agentauth --recovery-dir /secure/vault rotate researcher +``` + +This reveals the pre-committed key, makes it current, and commits to a fresh next key. The new recovery file is written to `--recovery-dir`. Afterwards: + +- Services refresh automatically when they see the new `kid`. +- Grants this agent **issued** must be re-issued. Grants it **holds** are unaffected. + +### Incident response + +| Situation | Action | +|---|---| +| Agent's current key may be exposed | `rotate`, then re-issue any grants it issued | +| Agent is misbehaving | Revoke the grants it holds (`agentauth revoke `), or deactivate it as its controller (`agentauth deactivate --as `) | +| Both of an agent's keys may be exposed | Deactivate it as its controller. Create a new identity and issue new grants. | +| A principal's key may be exposed | Rotate the principal (this invalidates all its grants), then re-issue what's needed | + +Deactivation is permanent. A deactivated DID can never be reused. + +## Scaling + +v0.3 keeps some state in memory **per process**: + +| State | Where | Problem with more than one replica | What to do | +|---|---|---|---| +| Nonce replay cache (`NonceCache`) | Relying services and the registry | A request could be replayed against a different replica within the timestamp window | Implement `check_and_store(did, nonce)` on Redis (`SET key 1 NX EX ttl`) | +| Usage ledger (`UsageLedger`) | Relying services | Budgets, rates and uses are counted per replica, allowing up to N× the limit | Implement `consume`, `refund` and `usage` on Redis (Lua script) or in your database (one transaction) | +| Key and revocation caches | Relying services | Per-replica caches are fine: they only affect freshness | Nothing | +| Registry database | Registry | SQLite is single-node | Run a single registry instance for now. Postgres support is on the [roadmap](roadmap.md). | + +Pass your implementations in through the constructors: `RequestVerifier(..., nonce_cache=...)` and `GrantVerifier(..., ledger=...)`. + +## Upgrading + +The formats are versioned (`agentauth/1`, `agentauth-grant/1`, …; see the [protocol spec](protocol.md#8-version-strings-and-constants)). Until 1.0, minor releases may change them. Check the release notes before upgrading, and upgrade relying services and agents together when a format changes. From cebfdbcd8b253483755acc33c9a71c17c8831ab8 Mon Sep 17 00:00:00 2001 From: Arunima Chaudhuri Date: Tue, 29 Sep 2026 00:24:46 +0530 Subject: [PATCH 08/10] docs: record design decisions --- docs/design-decisions.md | 125 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 docs/design-decisions.md diff --git a/docs/design-decisions.md b/docs/design-decisions.md new file mode 100644 index 0000000..7b1f330 --- /dev/null +++ b/docs/design-decisions.md @@ -0,0 +1,125 @@ +# Design decisions + +Short records of the choices that shaped AgentAuth: what was decided, why, and what it costs. Add a new entry when a decision changes. Don't rewrite old ones; mark them superseded instead. + +--- + +### DD-1: Self-certifying DIDs instead of registry-assigned IDs + +**Decision.** An agent's DID is the digest of its own signed inception event (`did:agent:`). + +**Why.** An identifier the registry hands out is only as trustworthy as the registry. A self-certifying one can't be minted, reassigned or forged by anyone who doesn't hold the inception key, and anyone can check it offline. + +**Cost.** DIDs aren't human-readable. Human-friendly names live in `meta` and aren't authoritative. + +--- + +### DD-2: A KERI-style key event log with mandatory pre-rotation + +**Decision.** Each identity has an append-only, hash-chained log. Every establishment event commits to the hash of the *next* key, and a rotation must reveal that key and be signed by it. + +**Why.** Agents run in exposed environments, so their current key is the likeliest thing to leak. Pre-rotation means leaking it isn't enough to take over the identity. We borrowed the idea from KERI but kept the event model much smaller (no witnesses yet, one key per event, no multisig thresholds) to keep the protocol easy to implement. + +**Alternatives.** +- `did:key`: no rotation at all. +- `did:web`: trusts DNS and the web host. +- Full KERI: powerful, but a large specification for a v0. + +**Cost.** Operators have to look after two keys per identity. Losing the recovery key means the identity can't be rotated again, only deactivated. + +--- + +### DD-3: Sign every request instead of issuing bearer tokens + +**Decision.** Agents authenticate each request with an Ed25519 signature over its method, audience, path, body, timestamp, nonce and grant header (`AgentSig`). + +**Why.** Bearer tokens leak through agent context: logs, prompts and tool outputs. A signature is worthless outside the one request it was made for. + +**Alternatives.** +- **RFC 9421 HTTP Message Signatures.** Close in spirit. We chose a fixed, minimal signing string to avoid negotiating covered components. A future version may align with 9421. +- **DPoP.** Proof-of-possession for OAuth tokens, but it assumes an OAuth deployment. + +**Cost.** Every request costs one signature (microseconds) and one verification. Services need a nonce cache. + +--- + +### DD-4: Our own grant format rather than JWT, Macaroons or Biscuit + +**Decision.** Grants are canonical-JSON bodies with detached Ed25519 signatures. Chains are a comma-separated list of links, each signed by the previous link's subject. + +**Why.** +- **JWT:** allows many algorithms (a long history of `alg` confusion bugs), has no native model for multi-hop delegation, and its claims don't map cleanly onto per-link attenuation. +- **Macaroons:** attenuate beautifully, but rely on a shared HMAC root key, so only the issuer can verify them. We need anyone to be able to verify, with each hop signed by a *different* party. +- **Biscuit:** the closest match (public-key, attenuable, offline-verifiable) and a strong candidate for the future. Its Datalog policy language is more than v0 needed. + +**Cost.** A custom format has to be implemented in each language. The [protocol spec](protocol.md) exists to make that feasible. + +--- + +### DD-5: Services decide whom to trust (the root policy) + +**Decision.** A grant chain is only accepted if its root issuer is a principal the *service* trusts, and only for the scopes the service allows that principal. + +**Why.** There's no global authority on who may do what at your API. The service owner already knows which people and orgs are its customers. AgentAuth makes the chain from that principal to the calling agent verifiable, and leaves the root decision where it belongs. + +**Cost.** Each service configures trust. `trust_principals({...})` covers the simple case, and a callable handles dynamic ones. + +--- + +### DD-6: Grants are only valid under the issuer's current key + +**Decision.** Each link records the issuer's `kid`. Verification fails if the issuer has rotated since the grant was issued. + +**Why.** If a key was rotated *because* it leaked, grants signed with it must stop working. Accepting historical keys would require knowing *when* each grant was signed, which a compromised key could lie about. + +**Cost.** Rotating an issuer invalidates everything it issued. Grants are meant to be short-lived (15 minutes by default, 24 hours at most), so re-issuing them is cheap. + +--- + +### DD-7: Limits apply to the whole grant and fail closed + +**Decision.** A grant's limits (resources, spend, rate, uses) apply to *every* scope in it. A resource-limited grant is refused at endpoints that don't declare a resource. Unknown limit fields make a grant invalid. + +**Why.** Per-scope limits would make both the format and the narrowing rules much more complex. Failing open (ignoring a resource limit when an endpoint doesn't declare a resource) would silently widen authority, which is the one thing a limit must never do. + +**Cost.** To give an agent differently limited capabilities, issue several grants. Services have to declare resources and costs on their endpoints. + +--- + +### DD-8: Stateful limits are charged against every link, all-or-nothing + +**Decision.** Budget, rate and use counters are kept per grant. Each call is charged atomically against every link in its chain that has one. + +**Why.** Without this, an agent with a 50 USD budget could delegate 50 USD to each of ten sub-agents. Charging every link turns a parent's limit into a hard ceiling on everything beneath it. + +**Cost.** The ledger is shared state that services must keep, and it has to be distributed (Redis or a database) once a service runs more than one replica. + +--- + +### DD-9: A single registry as witness, not authority (for now) + +**Decision.** One registry stores and serves logs. Clients replay logs rather than trusting its answers. + +**Why.** It gives the security properties that matter most (no forgery, no key substitution) with a simple deployment. + +**Cost.** The registry can withhold events or show different clients different logs. Multiple witnesses and clients that remember logs they've seen are on the [roadmap](roadmap.md). + +--- + +### DD-10: Canonical JSON without floats + +**Decision.** Signed payloads are JSON with sorted keys, no whitespace and UTF-8, and floats are rejected. Amounts are integers in the smallest unit. + +**Why.** Float serialization differs across languages and is the classic way a "canonical" JSON scheme breaks. Without floats, and with the ASCII keys and safe-range integers AgentAuth uses, this produces the same bytes as RFC 8785 (JCS). + +**Cost.** Money and other quantities have to be expressed as integers, such as cents, and the unit has to be stated. + +--- + +### DD-11: Python and FastAPI first + +**Decision.** The reference implementation is a Python package with FastAPI integrations and an SQLite-backed registry. + +**Why.** Much agent tooling is written in Python, and FastAPI's dependency injection maps directly onto "authenticate, authorize, enforce limits". + +**Cost.** Node/TypeScript agent ecosystems need a port. That's on the [roadmap](roadmap.md), and the [protocol spec](protocol.md) is the contract a port has to follow. From 152c1f2338f8159d975af9db82d78ba379934188 Mon Sep 17 00:00:00 2001 From: Arunima Chaudhuri Date: Tue, 29 Sep 2026 00:24:46 +0530 Subject: [PATCH 09/10] docs: add glossary and roadmap --- docs/glossary.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++ docs/roadmap.md | 67 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 docs/glossary.md create mode 100644 docs/roadmap.md diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 0000000..4bb5fc4 --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,65 @@ +# Glossary + +**Agent.** An autonomous piece of software that acts, such as calling APIs, spending money or delegating work. In AgentAuth, anything with a `did:agent:` identity. Humans and orgs use the same kind of identity when they act as principals. + +**AgentGrant.** The HTTP header that carries a grant chain, root first. See the [protocol](protocol.md#4-capability-grants-agentgrant). + +**AgentSig.** The `Authorization` scheme for signed requests. See the [protocol](protocol.md#3-request-authentication-agentsig). + +**Attenuation.** Narrowing authority when delegating. A child grant may only have a subset of its parent's scopes, audiences, validity window, depth and limits. + +**Audience (`aud`).** The `host[:port]` a request or grant is meant for. Services reject anything addressed to another audience. + +**Chain.** An ordered list of grants from a principal down to the agent making the request. Each link is issued by the previous link's subject. + +**Controller.** An identity (usually a human or org) named in an agent's inception event. It countersigns the agent's creation, can list the agents it controls, and can deactivate them. + +**Current key.** The key an identity signs with right now. It's identified by its `kid`. + +**Deactivation.** A permanent end to an identity. It's signed with the recovery key or by the controller. Afterwards the DID has no key, and every chain through it fails. + +**Delegation depth (`depth`).** How many more times a grant's subject may re-delegate. `0` means it can't delegate at all. + +**DID (Decentralized Identifier).** A W3C-standard identifier. AgentAuth's method is `did:agent:`, and the identifier is derived from the identity's inception event. + +**DID document.** The public description of a DID's current keys, served by the registry in W3C DID resolution format. + +**Grant.** A signed capability: *issuer lets subject do these scopes at these audiences until this time, with these limits.* + +**Inception.** The first event in an identity's log. It sets the first key, commits to the next one, and names the controller. + +**Issuer (`iss`).** The identity that signed a grant. + +**Key event log (KEL).** An identity's append-only, hash-chained list of signed events: inception, rotations and at most one deactivation. Replaying it gives the current key. + +**Key ID (`kid`).** `did#key-N`, where N is the sequence number of the event that established the key. + +**Limits.** Optional restrictions on a grant: `resources`, `spend` (`per_call`, `total`), `rate` and `uses`. See the [protocol](protocol.md#5-limits). + +**Multikey.** A self-describing public key encoding (`z6Mk…` for Ed25519), shared with `did:key`. + +**Nonce.** A random, single-use value in each signed request. It stops replays. + +**Pre-rotation.** Committing to the hash of the next key in advance, so a rotation must be signed by a key that has never been used or exposed. + +**Principal.** The root of a grant chain: the human or org whose authority an agent is exercising. A service decides which principals it trusts. + +**Recovery key.** The pre-committed next key. It's needed to rotate or self-deactivate, and should be stored away from the agent. + +**Registry.** The service that stores key event logs and revocations. It's a witness, not an authority: it validates events but can't forge them. + +**Relying service (relying party).** Any API that verifies agent requests and grants. + +**Revocation.** An issuer-signed statement that one of its grants is withdrawn. It also invalidates every delegation beneath that grant. + +**Root policy.** A service's rule for which principals may authorize which scopes there. + +**Rotation.** Replacing an identity's current key with its pre-committed next key, and committing to a new next key. + +**Scope.** A dotted action name (`files.read`), optionally ending in a wildcard (`files.*`), or `*` for everything. + +**Subject (`sub`).** The identity a grant is issued to. + +**Usage ledger.** The service-side record of spend, call rate and use counts for each grant. + +**Witness.** A party that records and vouches for events without being able to create them. The registry is currently the only witness. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..39313ac --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,67 @@ +# Roadmap + +What's shipped, and what's next. + +## Shipped + +### v0.1: Identity +- Self-certifying `did:agent:` identifiers +- Key event log with mandatory pre-rotation +- Controller countersignature and kill switch +- Signed requests (`AgentSig`) with audience, body, timestamp and nonce binding +- Encrypted keys, with current and recovery keys stored separately +- Registry service, SDK and CLI + +### v0.2: Delegated authority +- Scoped, short-lived capability grants +- Delegation chains that can only narrow (scopes, audiences, lifetime, depth) +- Root policy: each service decides which principals it trusts +- Revocation, and cascading deactivation + +### v0.3: Limits +- Resource patterns, per-call and total spend, rate limits and use counts +- Limits enforced on every link, with shared counters so sub-agents can't multiply a budget +- Refunds, fail-closed resource checks, path-traversal protection + +### Tooling +- Compatibility with both `httpx` and `httpx2` clients +- GitHub Actions CI on Python 3.10–3.13, plus a job with plain `httpx` only + +## Next + +### 1. Audit log +A tamper-evident, hash-chained log of authorized actions. Each entry records the full principal → agent chain, the scope, the resource and the amount, so "who authorized this?" can be answered after the fact. + +### 2. Production hardening +- Redis-backed nonce cache and usage ledger (multi-replica safe) +- Postgres storage for the registry +- Rate limiting and optional authentication for registration +- Pruning of ledger counters for expired grants +- Structured logging and metrics + +### 3. Protection against a dishonest registry +- Multiple independent witnesses countersigning events +- Clients that remember logs they've seen and detect conflicting versions +- Signed revocation proofs that services verify themselves, instead of trusting a registry answer + +### 4. Stronger key custody +- Recovery keys held in a KMS or HSM (cloud KMS, PKCS#11) +- Optional multi-key thresholds (e.g. 2-of-3 recovery keys) + +### 5. Ecosystem +- **TypeScript verifier and client.** Much agent tooling runs on Node. +- **MCP integration.** Let MCP servers authenticate calling agents and require grants per tool. +- **Adapters** for popular agent frameworks +- **Docker image and Helm chart** for the registry +- A **standalone written spec**, and possible alignment with RFC 9421 HTTP Message Signatures + +### 6. Independent security review +An external audit of the protocol and implementation before 1.0. + +## Open questions + +- **Biscuit.** Should grants move to Biscuit tokens for a richer policy language, or stay minimal? +- **Per-scope limits.** Should one grant be able to carry different limits for different scopes, or should "one grant per differently-limited capability" remain the rule? +- **Mandatory controllers.** Should every agent be required to have a controller, so there's always a way to shut it down? + +Ideas and feedback are welcome as GitHub issues. From d47f148de58892e7e972db44d502a40a3f740a26 Mon Sep 17 00:00:00 2001 From: Arunima Chaudhuri Date: Tue, 29 Sep 2026 00:24:46 +0530 Subject: [PATCH 10/10] docs: link documentation from README --- README.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0083943..f16eb62 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,19 @@ did:agent:QmfJZCnucexGhSZqdsAcKnFcv4RxyhPFMUiGjQmDRiMxux └─ sha2-256 multihash of the agent's signed inception event ``` +## Documentation + +| | | +|---|---| +| [Why AgentAuth](docs/why.md) | The problem, and why API keys, OAuth and mTLS fall short for agents | +| [Architecture](docs/architecture.md) | Components, request flow, how authority and limits propagate | +| [Protocol spec](docs/protocol.md) | Wire formats and verification rules, for other implementations | +| [Integration guide](docs/integration-guide.md) | Adding AgentAuth to an agent or a service | +| [Threat model](docs/threat-model.md) | What's defended against, and the known gaps | +| [Operations](docs/operations.md) | Running the registry, key management, incident response, scaling | +| [Design decisions](docs/design-decisions.md) | Why things are the way they are | +| [Glossary](docs/glossary.md) · [Roadmap](docs/roadmap.md) | | + ## Why not just API keys? | Problem with API keys for agents | AgentAuth | @@ -279,6 +292,7 @@ agentauth/ store.py SQLite persistence server.py Registry (FastAPI) cli.py `agentauth` command +docs/ Why, architecture, protocol spec, guides, threat model, decisions examples/ tool_service.py: a demo service with scoped, resource- and spend-limited endpoints tests/ End-to-end tests (attacks included) ``` @@ -313,9 +327,4 @@ tests/ End-to-end tests (attacks included) ## Roadmap -1. ~~Scoped, short-lived capability grants~~ ✅ v0.2 -2. ~~Delegation chains with attenuation and cascading revocation~~ ✅ v0.2 -3. ~~Resource, spend, rate and use limits~~ ✅ v0.3 -4. **Hash-chained audit log** of authorized actions, recording the full principal → agent chain. -5. Production hardening: Redis nonce cache and usage ledger, Postgres, registration rate limits, multi-witness receipts, KMS-held recovery keys. -6. A written spec and a TypeScript verifier; MCP integration. +Shipped: identity (v0.1), delegated grants (v0.2), limits (v0.3). Next up: an audit log, production hardening (Redis/Postgres), protection against a dishonest registry, KMS-held recovery keys, and a TypeScript verifier. See the full [roadmap](docs/roadmap.md).