Concepts
The mental model behind agent-id, in the order you'll meet it.
Addresses and DIDs — the name is the key
An address is the Ed25519 signing public key, base58-encoded, behind a fixed prefix:
amp:key:<base58(ed25519_pub)>
Base58 uses the Bitcoin alphabet, big-endian, leading 0x00 bytes rendered as 1.
Decoding an address must yield exactly 32 bytes. Because the address encodes the key,
there is nothing to resolve: signing_key_from_address() is the entire "lookup".
The DID form is did:amp:<base58> — the same key, registry-free like did:key. DIDs may
carry a path, query, or fragment; the method-id is the first segment. The generated DID
Document renders keys as publicKeyMultibase with multicodec prefixes 0xed01
(ed25519-pub) and 0xec01 (x25519-pub), and maps card endpoints to service types
AMPRelay / AMPInbox / AMPEndpoint.
Canonical JSON — one payload, one byte sequence
Every signed payload serializes to exactly one byte string:
- keys sorted by Unicode code point,
(",", ":")separators, UTF-8, NFC-normalized - no floats, no NaN/Infinity — amounts and fractions travel as ints or strings
- timestamps: RFC 3339 UTC, exactly 3 fractional digits,
Zsuffix, truncated (not rounded) to milliseconds; timezone-naive datetimes are rejected
Determinism is what makes signatures, digests, and content addresses meaningful.
Domain-separated signing
Nothing is ever signed raw. The signing input is:
uint16be(len(tag)) || tag || canonical_json(payload)
tag = "fg-agent-id/v1/<context>"
with seven contexts: agent-card, delegation, revocation, key-revocation,
key-rotation, inception, challenge-response. A signature over one artifact type can
never be replayed as another — a card signature is not a delegation signature even over
identical JSON. Signatures must also be canonically base64-encoded: base64 admits 16
non-canonical spellings of the same bytes, and allowing them would break the
revocation-digest identity (one signature, one wire form).
Cards and critical extensions
An AgentCard is a signed self-description: address, name, kind (agent/human/
service), operator, both public keys, payload types, endpoints, policy summary.
verify() checks the embedded signing key against the address and the signature against
that key — entirely offline.
Two forward-compatibility rules:
- Unknown fields are preserved and signed. A verifier that doesn't understand an extension still covers it with the signature check — extensions can't be silently stripped.
criticallists extension names the verifier MUST understand or reject the card (verify(understood_extensions=...)). When there are no critical extensions the field is absent, not empty — keeping signatures made before the marker existed valid.
Reserved endpoint names: relay, wake.
Delegation chains and scope intersection
A Delegation is a signed grant: issuer → subject, a scope set, and a validity window.
A DelegationChain is root-first: each link's subject issues the next link, and the final
subject is the agent being verified.
The one rule that matters: effective scopes are the intersection of every link's
scopes. A chain can only narrow authority. An empty chain grants nothing. Verification
checks every signature, the issuer/subject continuity, validity windows (with optional
leeway_seconds for clock skew), and revocation status — and returns the effective
scope set.
Revocation — two kinds, plus freshness
Revocationkills one delegation, and only its original issuer can sign it. It targets the delegation'sdigest(sha256 over signing input ‖ decoded signature).KeyRevocationkills an entire identity key: either self-revocation (the key revokes itself) or owner recall (the owner proves authority with a delegation chain rooted at itself). The proof chain deliberately skips expiry and criticality checks — recall must work exactly when a key is compromised, expired grants and all.
Revocations are permanent. A revoked root tears down every chain it ever signed.
The RevocationRegistry enforces verify-before-admit and tracks its own sync age:
"no revocation found" is not the same as "no revocation data". A never-synced registry is
stale, not empty; callers should require_fresh(max_age) and fail closed past their
deployment's bound.
Proof of possession
Holding an address proves nothing; signing a fresh challenge does. A verifier issues a
Challenge (32-byte nonce, audience, purpose, short TTL — 120 s default); the identity
signs a ChallengeResponse; the verifier checks it and gets back the proven address.
Two rules keep this sound:
- The verifier passes its own audience into
verify()— never the audience echoed in the response. Audience and purpose are bound into the signed payload, so a response for one service can't be replayed at another. - Challenges are single-use, enforced by an atomic store (
consume()returns each challenge at most once). Across multiple workers this needs a shared store —RedisChallengeStoreuses RedisGETDELfor atomicity; the in-memory store is single-process only.
Pre-rotation and fork detection
Keys are replaceable; identity survives rotation. The stable name is the inception
address — the first key an identity ever had. Every record in the rotation chain
pre-commits to the next key: sha256(next_signing_pub) (KERI-style pre-rotation).
The security payoff: stealing the in-force key is not enough to hijack the identity — the attacker also needs the pre-committed next key, which is meant to be stored colder.
A RotationRegistry learns chains and resolves the identity name to its current address.
If it ever sees two valid rotations at the same sequence — a fork — that is proof the
key leaked, and the registry fails closed: resolve() refuses to advance for that
identity, and duplicity_evidence() hands you the conflicting records.
Known gap (spec §8.1, reserved): v1 cards and DIDs name the in-force key directly, so a stale card resolves to a superseded key with no built-in rotation signal yet.
Spend scopes
Spending authority rides in ordinary scope strings with a grammar:
pay:<asset>[:tx<=<amount>][:total<=<amount>]
Amounts are exact decimals — no floats anywhere. Composition down a chain is min-cap
intersection: each cap is the minimum across links, and a link with no spend scope for
the asset voids authority entirely (intersection, again). SpendAuthority.verify() does
the authority math only — the caller verifies the chain first and supplies the ledger of
what's already been spent. Settlement is explicitly out of scope.
Device delegations
A device is just a subject holding a delegation from its identity that carries the
device scope (plus whatever working scopes it needs). verify_device() checks the
grant, optionally consults a revocation registry with a freshness bound, and returns the
working scopes with device stripped. There is deliberately no device registry,
enrollment, or sync protocol (spec §11) — devices are pure delegation.
The three versioning knobs
Three version strings, easily confused, doing different jobs:
| Knob | Value | What it versions |
|---|---|---|
| Wire spec | amp/0.2 |
The normative artifact formats in spec/SPEC.md. 0.2 broke from 0.1: domain-separated signing input + pinned timestamp spelling; added proof of possession and rotation. 0.1 artifacts do not verify. |
| Signing-domain tag | fg-agent-id/v1 |
The authoritative crypto-agility knob. A new cipher suite means v2. Unknown tags must be rejected with a distinct "unsupported version" signal — never a bare signature failure. (The rename from fareground-agent-id/v1 was itself wire-breaking.) |
DEFAULT_PROTOCOL_VERSION |
"0.1" |
The embedding protocol's (AMP's) version string stamped into a card's amp field. Embedders may override it. |
Separately, both registries persist snapshots at SNAPSHOT_VERSION = 2 (delegation
digests now cover the decoded signature; v1 rotation snapshots discarded conflict history
and could raise false compromise flags).
Security model and constraints
- Primitives: Ed25519 (deterministic signatures), X25519, SHA-256; all raw public keys are 32 bytes. No invented crypto; no silent downgrade — unknown suites are a hard, distinct rejection.
- Wall-clock dependence: delegation and challenge expiry rely on system time. Run
NTP on verifiers; use
leeway_secondsto tolerate bounded skew. - Extensions:
extramust not shadow known fields, and everycriticalname must point at an extension field actually present. - Keys at rest (Python only): scrypt (N=2^15, r=8, p=1) + ChaCha20-Poly1305 with an authenticated header (a tampered version byte fails to open); passphrases are NFC-normalized for cross-platform agreement. This is a storage recommendation, not a wire construction.
- Feeds are transport, not truth: every entry re-verifies on admit, contiguity checks prevent silent record-dropping, and a half-applied delta never marks the registry synced.
Operational fine print
- The
wakeendpoint URL is a secret. Wake notifications must be content-free, and the spec requires treating the advertised wake URL as a secret — leak it and anyone can ping your agent awake. RotationRegistry.resolve()cannot distinguish "never rotated" from "not synced". With no learned history it falls back to the identity address itself; callers that need the difference must checkstate(identity) is None.- Persist your revocation registry. A registry that forgets on restart is a security
hole — snapshot on shutdown, restore on boot, and track sync age (
require_fresh). is_revokednever raises on malformed input — bad-signature delegations answerFalseby design, so the predicate can't turn a clean rejection into a crash.