Integration Guide
How a peer agent consumes a peer-issued Trust Context Token (TCT) — the common case in AITP v0.1.
What you need
- The peer's signed Manifest (RFC-AITP-0003), fetched from
https://<peer-host>/.well-known/aitp-manifestand verified. - Your own AID (the peer-issued TCT will name you as
subjectandaudience).
That's it. There is no third-party verifier, no separate token-introspection call, no shared secret.
Step 1: Receive the TCT
In a typical flow you already hold the TCT from the Mutual Handshake (RFC-
AITP-0004 §3.4): the peer delivered it inline, verbatim, as the tct field
of MUTUAL_COMMIT_ACK — a compact JWS string, never decoded-and-re-encoded
by the envelope layer. If you need to forward a TCT to a downstream
consumer, pass the compact JWS itself in a request header or metadata
field; it is already transport-safe verbatim (RFC-AITP-0001 §5.4.5) — no
extra encoding layer is needed:
x-aitp-tct: <compact JWS>Step 2: Verify locally
# Pseudocode. Notes below the snippet matter.
import time
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
# A TCT is a compact JWS (RFC-AITP-0005 §1): three base64url segments
# joined by `.`. There is NO canonicalization step and nothing to strip —
# the signature covers the exact transmitted `header.payload` bytes
# (RFC-AITP-0005 §7.1). Do not reach for a JCS library here: that profile
# governs the Manifest and the revocation snapshot (RFC-AITP-0001 §5.4.1),
# never the TCT.
# AITP base64url is unpadded (RFC 4648 §5); a JWS segment carrying `=`
# padding or any character outside `[A-Za-z0-9_-]` MUST be rejected, not
# normalized. For the header and payload segments the base64url TEXT
# itself is part of the signing input, so silently normalizing it would
# verify a different byte sequence than what was actually signed
# (RFC-AITP-0001 §5.4.5 "Strict parsing").
def b64url_decode_strict(s: str) -> bytes:
if "=" in s:
raise ValueError("base64url padding is forbidden in AITP")
pad = (-len(s)) % 4
return base64.urlsafe_b64decode(s + ("=" * pad)) # padding only for the decoder, never accepted on input
# The sole acceptable `alg` is derived from the issuer AID's method, never
# read from the token itself — this is what forecloses `alg: none` and
# algorithm-confusion attacks (RFC-AITP-0001 §5.4.5 "Algorithm pinning").
def expected_alg_for_aid(aid: str) -> str:
if not aid.startswith("aid:pubkey:"):
raise ValueError(f"unsupported AID method: {aid}")
rest = aid[len("aid:pubkey:"):]
if rest.startswith("p256:"):
return "ES256"
if rest.startswith("ed25519:") or len(rest) == 43: # legacy untagged form == Ed25519 (RFC-AITP-0001 §5.3)
return "EdDSA"
raise ValueError(f"unsupported AID method: {aid}")
def verify_tct(tct_jws: str, issuer_aid: str, issuer_pubkey: Ed25519PublicKey, my_aid: str) -> list[str]:
# 1. Strict-parse: exactly three non-empty, dot-separated base64url
# segments (RFC-AITP-0005 §7.2 step 1) — no unsecured JWS, no
# detached payload, no JSON serialization.
parts = tct_jws.split(".")
if len(parts) != 3 or not all(parts):
raise ValueError("malformed compact JWS")
header_b64, payload_b64, sig_b64 = parts
header = json.loads(b64url_decode_strict(header_b64))
# 2. Enforce `typ` (RFC-AITP-0005 §7.2 step 2).
if header.get("typ") != "aitp-tct+jwt":
raise ValueError("TOKEN_TYP_MISMATCH")
# 3. Pin `alg` from the issuer AID and reject everything else,
# including "none" (RFC-AITP-0005 §7.2 step 3).
if header.get("alg") != expected_alg_for_aid(issuer_aid):
raise ValueError("TOKEN_ALG_MISMATCH")
if set(header) != {"alg", "typ"}:
raise ValueError("protected header must contain exactly alg and typ")
# 4. Verify the signature over the exact transmitted bytes — NOT a
# re-canonicalized or re-serialized form. There is no
# canonicalization step for a compact JWS (RFC-AITP-0005 §7.1).
signing_input = f"{header_b64}.{payload_b64}".encode("ascii")
sig = b64url_decode_strict(sig_b64)
issuer_pubkey.verify(sig, signing_input) # raises on failure
# 5. Only now decode and validate claims (RFC-AITP-0005 §7.2 step 5) —
# nothing above this line is trusted before the signature checks out.
claims = json.loads(b64url_decode_strict(payload_b64))
assert claims["ver"] == "aitp/0.2", "Unknown version"
assert claims["exp"] > time.time(), "TCT expired"
assert claims["aud"] == my_aid, "Audience mismatch" # no wildcards (RFC-AITP-0005 §5.1)
# `cnf.jkt` MUST match the thumbprint of the key encoded in `sub` — the
# verifier derives the EXPECTED thumbprint from `sub` itself and never
# trusts `cnf.jkt` as freestanding (RFC-AITP-0001 §5.4.4). Pinned
# thumbprint vectors are at schemas/conformance/known-answer/jwk-thumbprints.json.
if claims["cnf"]["jkt"] != jwk_thumbprint_from_aid(claims["sub"]):
raise ValueError("cnf.jkt does not match sub")
# 6. Reject any unknown top-level claim outside the `ext` slot
# (RFC-AITP-0001 §5.4.5 "Strict parsing"). Silently ignoring unknown
# claims would create signature-scope ambiguity across implementations.
KNOWN = {"ver", "jti", "iss", "sub", "aud", "iat", "exp", "grants", "cnf", "ext"}
unknown = set(claims) - KNOWN
assert not unknown, f"Unknown TCT claims: {unknown}"
return claims["grants"]Both issuer_aid and issuer_pubkey come from the issuing peer's
manifest.aid (aid:pubkey:ed25519:<43-char-base64url>, or the legacy
untagged aid:pubkey:<43-char-base64url> form → decode → 32-byte raw
Ed25519 public key, loaded via Ed25519PublicKey.from_public_bytes()).
issuer_aid is what expected_alg_for_aid() pins the header alg
against — the algorithm is never read from the token itself. A P-256
issuer AID (aid:pubkey:p256:<44-char-base64url>) carries the same shape
with ES256 / SEC1-compressed key material instead; this walkthrough
sticks to the Ed25519 case for brevity (RFC-AITP-0001 §5.3).
Also check the Manifest-expiry bound when you can. If you hold the issuing peer's Manifest — you do immediately after a Mutual Handshake, where it is exchanged inline — additionally verify
claims["exp"] <= issuer_manifest["expires_at"]and reject withTCT_EXPIRES_AFTER_MANIFESTon violation (RFC-AITP-0005 §10.4). A peer-issued TCT must not outlive the Manifest credential that authenticates its issuer's key. This check is conditional — skip it if the issuer Manifest is not on hand; do not fetch it solely for this purpose. The Step 2 expiry check (expin the future) always applies regardless.
Step 3: Enforce grants
def check_grant(grants: list[str], required: str) -> None:
if required not in grants:
raise PermissionError(f"Grant '{required}' not present in TCT")Proof-of-possession
cnf — specifically cnf.jkt, the RFC 7638 thumbprint of the subject's
public key — is required on every v0.2 peer-issued TCT (RFC-AITP-0005 §3).
There is no bearer-TCT profile. The v0.1 raw-public-key binding.cnf form
does not appear in v0.2 tokens (RFC-AITP-0001 §5.4.4) — if you are
looking for it on a v0.2 TCT's claims, it is not there; use cnf.jkt.
Whether to verify PoP at consumption time is governed by the issuing
peer's per-grant policy (RFC-AITP-0005 §6): consumers MUST verify PoP for
any grant the issuing peer marks as requiring it, and SHOULD verify PoP for
all grants unless the deployment provides equivalent channel binding (mTLS
with bound client certs, an authenticated message bus, etc.).
The RECOMMENDED marking convention is a #pop_required suffix on the
grant string (<capability>#pop_required): a consumer that recognizes the
suffix MUST run the challenge/response below before authorizing that grant,
and MUST reject the invocation if no valid pop_response arrives within the
challenge's freshness window (RFC-AITP-0005 §6).
To verify PoP, send a fresh challenge nonce (via pop_challenge envelope),
ask the peer to sign sha256(base64url_decode(nonce)) — the holder MUST
hash the decoded raw bytes of the nonce, never the base64url ASCII
string (the unified PoP signing-input convention is in
RFC-AITP-0001 §5.4.2)
— then verify pop_signature against the subject's public key, i.e. the
key encoded in the TCT's sub AID, and confirm cnf.jkt equals that same
key's RFC 7638 thumbprint (RFC-AITP-0005 §6.2). pop_signature is a raw
Ed25519/ECDSA signature over a hash, not a JCS-canonicalized object — it
was never JCS-signed JSON, in v0.1 or v0.2, so there is nothing here to
canonicalize; do not reuse the TCT's (JWS) verification code path for it,
and do not reach for JCS either. The Mutual Handshake's round-2 PoP
exchange already binds the TCT to a live key — downstream PoP is the same
proof, repeated when a TCT is presented after the handshake.
Delegating verification to the issuing peer
If you prefer to delegate verification to the issuing peer instead of
verifying locally, you can POST to the issuing peer's Verify endpoint
(RFC-AITP-0005 §10). The endpoint's URL path and request/response shape
are deployment-defined in v0.1 — the RFC names the operation but does
not pin a wire format. The example below is non-normative; consult the
issuing peer's published API contract:
{
"tct_token": "<base64url TCT>",
"expected_audience": "aid:pubkey:11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo",
"required_grants": ["macp.mode.task.v1"]
}On success, the response typically carries the verified grants list. The issuing peer is authoritative for revocation, so this also serves as a freshness check.
Pairing with MACP
A common pairing is using AITP to authorize calls into a MACP runtime (see the Multi-Agent Coordination Protocol).
The short version:
- TCT arrives via
x-aitp-tctHTTP header or as part of a Mutual Handshake. - The MACP runtime maps
grantstoallowed_modesfor session participation. - AITP is the trust layer; MACP is the coordination layer; they compose.