Concepts
Envelopes and canonical JSON
Every message on the wire is one Envelope: a frozen, signed record with
amp="0.1", a UUID id, a type, sender (serialized as "from" on the wire),
to, session_id, seq, created_at, a base64 body, and sig. The Ed25519
signature covers a domain-separated view of every field except sig. Bodies are
base64: ratchet ciphertext for session traffic, sealed JSON for handshake frames.
The envelope cap is 1 MiB with no chunking — larger payloads travel out-of-band via
amp.ref/1 pointers.
The model is declared extra="allow": unknown fields from a newer peer are
preserved and included in the signed canonical payload, so additive wire fields
never break cross-version signature agreement.
Everything signed or used as a KDF salt is serialized as canonical JSON: keys
sorted by Unicode code point, no insignificant whitespace, UTF-8 NFC strings, no
NaN/Infinity, and no floats — durations are integer milliseconds (ttl_ms) or
strings. One logical payload has exactly one byte representation.
Signatures are domain-separated: the signing input is
uint16be(len(tag)) || tag || canonical_json(payload) with
tag = "fg-amp/v1/" + context (contexts: envelope, group-roster, relay-pull,
relay-ack, relay-ws). Identity artifacts are signed under the separate
fg-agent-id/v1 domain and never re-signed — an AMP artifact and an identity artifact
can never be confused, even if their payloads canonicalize identically.
Identity
Self-certifying addresses
An address is amp:key:<base58(ed25519_pub)> — the address is the signing key.
Anyone can verify a signature against a sender address with no lookup, and nobody can
squat a name. An optional DID form did:amp:<base58> exists, with a DID Document
carrying publicKeyMultibase entries for both the Ed25519 signing key and the X25519
agreement key.
Owner keys vs agent keys
Agents hold hot, rotatable keys that live on the wire. Owners hold cold keys
that stay off it, and authorize agents via signed delegation chains
(Delegation.grant(...) → DelegationChain). Every session verifies both sides'
chains during the handshake and exposes the results:
session.peer_owner— the chain root (the cold key that ultimately vouches)session.peer_scopes— the scopes the chain grants
session.require_scope("negotiate", owner=acme_address) checks the
handshake-verified chain — never claims made inside messages. One sharp edge: scopes
are only a real gate when the policy pins trusted_issuers. Without that, a peer can
self-sign a chain granting itself any scope, and scopes are merely advisory (a warning
is logged). The card's operator field must match the verified chain root or the
initiation is refused.
Agreement prekey ring
Initiators seal the first knock to the X25519 agreement prekey in the responder's
current card. node.rotate_prekey() mints a fresh prekey (returning a new card to
re-register); the node keeps one previous prekey for a grace window, and dropping the
old private key makes past knocks unrecoverable — forward secrecy for the handshake
itself, independent of identity-key secrecy.
Revocation
Each node carries a RevocationRegistry. A key-revoked sender is refused outright,
regardless of policy. Delegation chains are re-verified fresh against current
revocations on every initiate, accept, and resume — revoked or expired authority is
never reinstated by a stale session.
Handshake
initiator responder
initiate() ── handshake.initiate (sealed) ──▶ verify sig + card + chain
policy engine
◀── handshake.accept (sealed) ───── derive session key
verify responder card + chain session established
The HandshakeInitiate frame — sealed to the responder's agreement prekey —
carries the session id, the initiator's card and delegation chain, mode, purpose,
payload types, ttl_ms, an ephemeral X25519 key, capability offers, an optional
PQ KEM key, and an optional witness block. Its transcript_salt() is the SHA-256 of
its canonical JSON (an absent witness is omitted, not null, keeping key derivation
byte-stable against the golden vectors). The HandshakeAccept adds the accepted
payload types and PQ ciphertext, and carries the responder's own chain — identity
verification is mutual. Rejects are plaintext {session_id, reason}, and only the
addressed peer may reject a pending handshake.
Guards on establishing frames (handshake.initiate, session.resume): a 300-second
clock-skew freshness window plus bounded seen-id caches block replays. Pending
initiations are capped at 1024; deferred knocks (wait=False) carry a monotonic
deadline equal to their TTL and are reaped by forget_expired_initiations().
Capability negotiation takes the intersection of both offers. The PQ token is
stripped from session.capabilities unless a KEM secret was actually exchanged — an
app gating on CAP_PQ_ML_KEM_768 is never lied to.
Encryption
Primitives: Ed25519 signatures, X25519 agreement, optional ML-KEM-768, ChaCha20-Poly1305 AEAD, HKDF-SHA256, SHA-256. Any all-zero X25519 shared secret (low-order point) is rejected.
Two constructions:
- Seal / open_sealed — anonymous-sender confidentiality to a static X25519 key:
ephemeral-static ECDH → HKDF-SHA256 → ChaCha20-Poly1305. Wire format is
ephemeral_pub(32) || nonce(12) || ciphertext, with the ephemeral key doubling as HKDF salt and AEAD AAD (info stringamp/0.1/seal). Used for handshake and resume payloads, before a session key exists. - Session key — ephemeral-ephemeral ECDH bound to the handshake transcript:
HKDF(x25519_shared + pq_shared, salt=transcript, info="amp/0.1/session"). Concatenating the ML-KEM shared secret makes the key hybrid — secure if either the classical or the post-quantum primitive holds (harvest-now-decrypt-later defense). An emptypq_sharedreproduces the classical-only key byte-for-byte. The salt binds both handshake ephemerals (SHA256(initiate.transcript_salt() + responder_ephemeral_pub)), so the key commits to the full handshake, not just the initiator's contribution.
The double ratchet, AMP variant
Session traffic runs a double ratchet: a symmetric HKDF chain per direction gives a fresh AEAD key per message (forward secrecy); each message header carries the sender's current X25519 public key, and a new header key mixes a fresh DH secret into the root and derives new chains (post-compromise security after each direction turn).
Where AMP departs from vanilla Signal: either party may send first, including simultaneously. The responder→initiator direction is seeded with a symmetric chain-0 so the responder can send immediately; the initiator takes an immediate send-side DH step. The sender's DH key changes only inside a receive-triggered step — never on send — which makes simultaneous sends safe. (Wiring detail: the accept-receiver is the ratchet initiator; the accept-sender is the responder.)
Decryption is peek/commit: decrypt_prepare returns a message key and an apply
callback, and state mutates only after decrypt-and-validate succeeds — a bad frame can
never desync the ratchet. Decode is strictly in-sequence (the reorder buffer serializes
it), so no skipped-message-key store exists. burn() zeroes root and chain keys on
close; close frames authenticate with a key derived from the initial root, so a close
verifies regardless of ratchet position.
Wake semantics
AMP delivery is pull-based — the recipient long-polls the relay — which only works while the recipient is running. Wake closes the gap for sleeping agents:
- The agent advertises
endpoints.wake = "https://host/path"in its signed card. - When the relay accepts mail for an address nobody is polling, it POSTs a
content-free ping — empty JSON
{}, no sender, no message id, no counts — to that URL. The entire semantic is "connect and pull". WakeNotifierdebounces pings per wake host, not per recipient address — addresses are free to mint, so per-address debounce would let an attacker land N pings on one victim URL. The debounce map is LRU-bounded and refuses admission rather than evicting live entries; pings past a concurrency cap are dropped, not queued.WakePolicy(anSsrfPolicy) is https-only, refuses private/loopback/metadata targets, unwraps embedded IPv4, blocks redirects, and re-checks the address inside the resolver at connect time — defeating DNS rebinding.WakeReceiveris the listener half: answers204fast, runs the asyncon_wakecallback without blocking the response, and (by default) coalesces a burst into at most one in-flight pull plus one queued.
A wake is a hint, not a delivery guarantee — the poll loop stays the source of
truth, so a dropped ping costs latency, never correctness. On the caller side, use a
non-blocking initiate so a sleeping peer doesn't stall you:
pending = await node.initiate(card, wait=False); session = await pending.wait().
Resume
Persistent sessions (SessionMode.PERSISTENT) export to a SessionStore as a
SessionRecord containing no key material — just the peer card, counters,
transcript head, verified owner/scopes, and witness posture. Resume:
- re-authenticates both sides with identity keys and re-verifies the delegation chain fresh — expired or revoked authority is not reinstated, scopes are recomputed;
- checks that the transcript head and sequence counters mirror;
- mints a fresh session key (post-compromise recovery), salted by
SHA256(transcript_head + nonce).
The per-attempt nonce is echoed in the accept and blocks replaying a captured accept from an earlier attempt (the session id is stable across resumes). A live session with the same id is superseded only after full validation.
Transcript hash chain
Both parties maintain a tamper-evident SHA-256 chain over canonical envelopes:
head' = SHA256(head + canonical_json(envelope.to_wire())), genesis 32 zero bytes.
Comparing heads (session.transcript.head or transcript_matches(expected)) proves
both sides hold an identical, untampered history — without exchanging it. The head
survives session close as evidence even after key material is burned.
Reliability layer
Under the crypto, sessions run a real transport discipline:
- per-session sequence numbers with strict in-order decode and a bounded reorder buffer (256);
- a retransmit buffer (512) with cumulative-ACK/NACK receipts (a monotonic
rseqdrops replayed receipts) and an RTO timer that recovers tail loss; - windowed flow control — the receiver advertises remaining inbox capacity, the sender
stalls at zero;
max_inflightgives applications real backpressure; - orphan and TTL reaping.
An unfillable gap deterministically closes the session so a resume can recover it.
The contract: no acknowledged message is ever lost. Everything is observable via
session.stats.
Contact policy
Consent is enforced in code by the PolicyEngine, which runs on every initiation
after signature, card, and chain verification — deliberately not an LLM decision:
refusing unwanted contact must not be promptable. ContactPolicy is a frozen record
with four modes (OPEN, CREDENTIALED, ALLOWLIST, CLOSED) plus per-peer rate
limits (LRU-bounded), a session ceiling, a TTL cap, participant-kind and session-mode
gating, accepted payload types, and optional human approval via approval_fn.
Convenience constructors: ContactPolicy.open(),
.credentialed(scopes, trusted_issuers=...), .allowlist(addresses=..., operators=...).
Typed bodies — the app layer
A typed body is a schema-validated payload whose content_type is <name>/<version>
with an integer version — amp.task/1. The integer is what distinguishes it from a
free-form MIME type: text/plain stays in the untyped fallback tier and never hits the
registry. Bodies are validated at both pipe ends — outbound before encrypt, inbound
after decrypt. Unknown typed types deliver opaque, unless the sender marked
metadata.critical, in which case they are rejected. BodyRegistry extends
immutable-by-copy; default_registry() is process-wide with the six built-ins.
| Body | Purpose | Lifecycle enforcement |
|---|---|---|
amp.task/1 |
Work lifecycle: request → accept/reject → progress → complete/fail, requester-only cancel | TaskTracker — illegal transition raises TaskLifecycleError without mutating state |
amp.payment/1 |
x402 payment profile: quote → authorization → settled/failed | PaymentTracker + spend caps (below) |
amp.mcp/1 |
One MCP JSON-RPC message verbatim, with an opaque mcp_session correlator |
McpBridge correlates calls/responses; bridges layer per mcp_session |
amp.receipt/1 |
Application-level ack (distinct from transport receipts) | References exactly one of task_id / message_id |
amp.ref/1 |
Pointer to an external artifact (uri, kind, content_hash) |
The out-of-band path for >1 MiB payloads |
amp.claim/1 |
Knowledge claim with pedigree (statement, confidence, evidence refs) |
— |
Payments follow the "AMP carries, x402 settles" principle: the x402
payment-requirements/payload objects ride verbatim and unvalidated under the x402
field. What AMP does enforce is authority: an outbound authorization is cap-verified
against the sender's own delegation chain (SpendAuthority) before it is even
encrypted, and inbound against the peer's handshake-verified chain; SpendLedger
tracks cumulative authorized amounts per asset so total<= caps hold across
authorizations. Violations raise SpendRejectedError.
Witnessed posture
A negotiated middle ground between sealed E2E and auditability
(CAP_WITNESSED_V1). Both parties name the same witness — an address plus X25519
key — inside the signed, sealed handshake. The initiate's witness block feeds the
transcript salt, so the agreement is cryptographically bound into the session key; a
divergent or missing block is a handshake reject. On every send, the sender
additionally seals the plaintext to the witness as a signed witness.copy envelope
bound to (session_id, seq, sender) — both by the envelope signature and by a sealed
inner duplicate, so splicing is detectable. A sender that cannot produce the witness
copy refuses to send (seq and ratchet untouched). The posture survives resume via
SessionRecord.witness, and the sealed default remains byte-for-byte unchanged when
no witness is negotiated. The spec is deliberately honest about what is
cryptographically bound versus merely detectable-not-preventable.
Groups
create_group(member_cards) builds a full mesh of pairwise encrypted sessions —
every message is E2E to every member individually. The roster is a founder-signed
GroupInfo (with epoch and roster_digest); founder-only add_member /
remove_member bump the epoch, and members receive GroupEvents
(joined, left, removed, epoch, equivocation). Exclusion on removal works by
tearing down the pairwise sessions — there is no shared group key to rotate. There is
no cross-member message ordering. MLS-style groups are roadmap.
Security model, condensed
What is enforced: sender authenticity on every envelope (verified before the body is touched, with session traffic pinned to the peer's card address); confidentiality against relays and on-path parties; forward secrecy (ratchet + prekey ring + burn on close); post-compromise security (DH turns + fresh key on resume); optional PQ hybrid; code-enforced consent; mutually verified delegation authority; spend caps; replay resistance (freshness windows, bounded seen-caches, per-session sequencing, canonical base64 signature spelling so one signature has exactly one wire form); relay hardening (audience-bound signed pull/ack, single-use pull guard, rate limits, SSRF-guarded wake); domain-separated signatures; tamper-evident transcripts; witnessed auditability without relay plaintext.
What is not guaranteed — repeat it until it sticks: sender authenticity is not content safety; Sybil resistance is rate-limiting only; the relay sees the social graph; wake is a hint; 1 MiB envelopes; one key per identity.
Deployment fine print
PendingInitiation.wait(timeout=...)timing out does not abandon the handshake — a laterwait()can still collect the session, and even a no-timeout wait ends at the handshake's own TTL deadline.- Bind your relay to an audience.
create_relay_app(audience="https://relay.example")binds signed pulls/acks to that relay, so a captured pull request can't be replayed against a different relay. Passstate=SqliteRelayState(path)for persistence. - Payment quotes are strict:
amount,asset, andpay_toare required onkind=quote(model validator), and asettled/failedreport removes the payment from the tracker. - An independent JS implementation lives in the repo under
reference/js/with a conformance runner against the golden vectors.